u-space / src /ulens /decode.py
loose-bits's picture
Deploy 7123e8e
d58cfe6 verified
Raw History Blame Contribute Delete
12.8 kB
"""Reading a direction back as words: the inverse of the transport.
`Lens` maps words to a direction. `Vocabulary` maps a direction back to the words whose own
transported directions align with it, which is how an axis gets a name — and how you find out
that it has none.
voc = ulens.Lens("qwen3.5-4b", block=23).vocabulary()
voc.top(axis, k=12) # the words this direction reads as
print(voc.report(cone)) # every axis, both ends
Cheaply. Ranking by cosine against every transported row looks like it needs the full
transported vocabulary `D = unit((W_U * gamma) @ J)`, which is gigabytes, but it factorises:
numerator_v = (W_U[v] * gamma) . (J @ u) one matvec, streamed
denominator_v = || (W_U[v] * gamma) @ J || independent of u
so only the denominators have to be precomputed, and they are one float per token:
`sqrt(r_v @ (J J^T) @ r_v)` with `r_v = W_U[v] * gamma`, computed in chunks and cached at
about a megabyte instead of gigabytes. After that each decode is one streamed pass over the
unembedding. `cosine=False` skips the denominators altogether.
Two caveats that the numbers make unavoidable:
* **Transported rows share a large common component**, so raw cosines return much the same
high-frequency words for unrelated directions. `purifier()` estimates the shared direction
and its leading principal components, and `top(..., purify=...)` removes them.
* **An axis decoding to nothing is a real outcome.** Compare any top-k against
`sample_directions()` — what a random direction of the same vocabulary decodes to — before
reading meaning into it.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from ._math import unit
from .models import ModelSpec
from .models import get as _model
#: words a decode will report: alphabetic, at least this long
ALPHA = re.compile(r"^[A-Za-z]+$")
MIN_LEN = 3
#: rows per streamed chunk. The denominator pass is O(chunk * hidden^2) and the decode pass
#: O(chunk * hidden), so the former runs in smaller chunks to bound peak memory.
NORM_CHUNK = 8192
DECODE_CHUNK = 65536
@dataclass(frozen=True)
class Hit:
"""One decoded token."""
word: str
score: float
token_id: int
def __repr__(self) -> str:
return f"{self.word}({self.score:+.3f})"
@dataclass(frozen=True)
class AxisDecode:
"""Both ends of one axis, as words."""
name: str
positive: list[Hit]
negative: list[Hit]
def words(self, sign: str = "+") -> list[str]:
return [h.word for h in (self.positive if sign == "+" else self.negative)]
def __repr__(self) -> str:
return f"AxisDecode({self.name!r}, +{self.words('+')[:4]}, -{self.words('-')[:4]})"
def common_component(D: np.ndarray, k: int = 3):
"""`(mu, V[k, hidden])`: the shared direction of unit rows `D`, and the leading principal
components around it. Computed through the `hidden x hidden` covariance, so the number of
rows only enters linearly."""
X = np.asarray(D, np.float32)
mu = X.mean(0)
if k <= 0:
return unit(mu), np.zeros((0, X.shape[1]), np.float32)
Xc = X - mu
C = (Xc.T @ Xc) / max(len(Xc) - 1, 1)
_, V = np.linalg.eigh(C)
return unit(mu), np.ascontiguousarray(V[:, ::-1][:, :k].T, np.float32)
def purify(v: np.ndarray, mu: np.ndarray, V: np.ndarray) -> np.ndarray:
"""Remove the shared direction and the leading principal components from `v`.
Ranking a purified direction against the raw vocabulary is the same ordering as ranking
the raw direction against a purified vocabulary, so this is applied to the direction.
"""
v = np.asarray(v, np.float32)
mu = np.asarray(mu, np.float32)
v = v - (v @ mu) * mu
V = np.asarray(V, np.float32)
return v - V.T @ (V @ v) if V.size else v
class Vocabulary:
"""Every token of a model, as a transported direction, without materialising them."""
def __init__(self, model: str | ModelSpec, block: int | None = None,
cache_dir: str | Path | None = None):
self.spec = _model(model)
self.block = self.spec.readout_block if block is None else int(block)
self._cache_dir = Path(cache_dir) if cache_dir else None
self._J: np.ndarray | None = None
self._gamma: np.ndarray | None = None
self._norms: np.ndarray | None = None
self._n: int | None = None
self._tok = None
self._word: dict[int, str] = {}
def __repr__(self) -> str:
return f"Vocabulary({self.spec.key!r}, block={self.block})"
# ------------------------------------------------------------------ resources
@property
def J(self) -> np.ndarray:
if self._J is None:
from . import lensfile as _lensfile
self._J = _lensfile.matrix(self.spec.lens, self.block)
return self._J
@property
def n_tokens(self) -> int:
if self._n is None:
from . import unembed
self._n = unembed.unembedding_shape(self.spec.hf, self.spec.revision)[0]
return self._n
def _open(self):
"""`(handle, weight key, gamma)` for the unembedding, read lazily, never copied."""
from . import unembed
from ._deps import require
safe_open = require("safetensors").safe_open
names, shard = unembed._keys_and_shard(self.spec.hf, self.spec.revision)
w_key = (unembed._pick(names, "lm_head.weight")
or unembed._pick(names, "embed_tokens.weight"))
if self._gamma is None:
g_key = unembed._pick(names, "model.norm.weight", ".norm.weight")
with safe_open(shard(g_key), framework="pt") as f:
self._gamma = f.get_tensor(g_key).float().numpy().astype(np.float32)
return safe_open(shard(w_key), framework="pt"), w_key, self._gamma
def _rows(self, chunk: int):
"""Yield `(start, (W_U[start:stop] * gamma))` as float32 `[c, hidden]`."""
handle, w_key, gamma = self._open()
with handle as f:
sl = f.get_slice(w_key)
n = int(sl.get_shape()[0])
for i in range(0, n, chunk):
R = sl[i:min(i + chunk, n), :].float().numpy().astype(np.float32)
yield i, R * gamma[None, :]
@property
def cache_path(self) -> Path:
from .basis import cache_dir
root = self._cache_dir or cache_dir()
return Path(root) / "vocab_norms" / f"{self.spec.key}-b{self.block}.npy"
@property
def norms(self) -> np.ndarray:
"""`[V]` row norms of the transported vocabulary. Computed once, then cached.
`||r_v @ J||^2 = r_v @ (J J^T) @ r_v`, so the `V x hidden` product is never formed.
"""
if self._norms is not None:
return self._norms
p = self.cache_path
if p.exists():
self._norms = np.load(p)
return self._norms
M = (self.J @ self.J.T).astype(np.float32)
parts = [np.sqrt(np.maximum(((R @ M) * R).sum(1), 0.0))
for _, R in self._rows(NORM_CHUNK)]
self._norms = np.concatenate(parts).astype(np.float32)
p.parent.mkdir(parents=True, exist_ok=True)
np.save(p, self._norms)
return self._norms
def word(self, token_id: int) -> str:
"""The decoded, stripped string of one token. Decoded lazily and cached, because a
decode only ever inspects the few hundred candidates it scans."""
t = int(token_id)
if t not in self._word:
if self._tok is None:
from ._deps import require
AutoTokenizer = require("transformers").AutoTokenizer
kw = {"revision": self.spec.revision} if self.spec.revision else {}
self._tok = AutoTokenizer.from_pretrained(self.spec.hf, **kw)
self._word[t] = self._tok.decode([t]).strip()
return self._word[t]
# ------------------------------------------------------------------ decoding
def scores(self, direction, cosine: bool = True, purify_with=None) -> np.ndarray:
"""Alignment of every token's transported direction with `direction`, `[V]`.
`purify_with` is a `(mu, V)` pair from `purifier()`; it is applied to the direction.
"""
u = np.asarray(direction, np.float32).reshape(-1)
if u.shape[0] != self.spec.hidden_size:
raise ValueError(f"direction is {u.shape[0]}-wide, "
f"{self.spec.key} is {self.spec.hidden_size}")
if purify_with is not None:
u = purify(u, *purify_with)
v = (self.J @ u).astype(np.float32)
s = np.concatenate([R @ v for _, R in self._rows(DECODE_CHUNK)]).astype(np.float32)
if cosine:
s = s / (self.norms + 1e-9) / (np.linalg.norm(u) + 1e-9)
return s
def top(self, direction, k: int = 12, sign: str = "+", min_len: int = MIN_LEN,
alpha_only: bool = True, scan: int = 40, cosine: bool = True,
purify_with=None) -> list[Hit]:
"""The `k` tokens best aligned with `direction` (`sign="-"` for the other end).
Candidates are scanned `k * scan` deep and filtered to alphabetic words of at least
`min_len` characters, deduplicated case-insensitively, which is the convention the
reported decodes use: a raw top-k is mostly fragments and punctuation.
"""
s = self.scores(direction, cosine=cosine, purify_with=purify_with)
order = np.argsort(-s if sign == "+" else s)
hits: list[Hit] = []
seen: set[str] = set()
for t in order[: max(k * scan, k)]:
w = self.word(t)
if (alpha_only and not ALPHA.match(w)) or len(w) < min_len or w.lower() in seen:
continue
seen.add(w.lower())
hits.append(Hit(word=w, score=float(s[t]), token_id=int(t)))
if len(hits) >= k:
break
return hits
def decode(self, target, k: int = 12, **kw) -> list[AxisDecode]:
"""Both ends of an Axis, of every axis of a Cone, or of a bare direction."""
from .axes import Axis, Cone
if isinstance(target, Axis):
pairs = [(target.name, target.vector)]
elif isinstance(target, Cone):
pairs = [(n, target.U[:, i]) for i, n in enumerate(target.names)]
else:
pairs = [("direction", np.asarray(target, np.float32))]
return [AxisDecode(name=n, positive=self.top(v, k=k, sign="+", **kw),
negative=self.top(v, k=k, sign="-", **kw)) for n, v in pairs]
def report(self, target, k: int = 12, **kw) -> str:
"""A printable decode of every axis, both ends."""
out = [f"{self.spec.key} block {self.block}, {self.n_tokens} tokens"]
for d in self.decode(target, k=k, **kw):
out.append(f" [{d.name:22s}] + {', '.join(d.words('+'))}")
out.append(f" {'':24s} - {', '.join(d.words('-'))}")
return "\n".join(out)
# ------------------------------------------------------------------ nulls
def sample_directions(self, n: int = 20000, seed: int = 0) -> np.ndarray:
"""`[n, hidden]` unit transported directions of a random token sample.
Feeds `purifier`, and gives the null an axis's decode has to be read against: what a
random direction of this same vocabulary returns.
"""
rng = np.random.default_rng(seed)
idx = np.sort(rng.choice(self.n_tokens, size=min(n, self.n_tokens), replace=False))
pos = {int(t): j for j, t in enumerate(idx)}
out = np.empty((len(idx), self.spec.hidden_size), np.float32)
for start, R in self._rows(DECODE_CHUNK):
want = idx[(idx >= start) & (idx < start + len(R))]
if not len(want):
continue
out[[pos[int(t)] for t in want]] = R[want - start] @ self.J
return unit(out)
def purifier(self, k: int = 3, n_rows: int = 20000, seed: int = 0):
"""`(mu, V)` for `purify`, estimated from a token sample of this vocabulary."""
return common_component(self.sample_directions(n_rows, seed), k=k)
def save_decode(decodes: list[AxisDecode], path: str | Path) -> Path:
"""Write decoded axes to json."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
blob = [{"name": d.name,
"positive": [{"word": h.word, "score": h.score, "id": h.token_id}
for h in d.positive],
"negative": [{"word": h.word, "score": h.score, "id": h.token_id}
for h in d.negative]} for d in decodes]
path.write_text(json.dumps(blob, indent=1) + "\n")
return path