Coverage for transformer_lens/tools/analysis/svd_circuits.py: 99%
138 statements
« prev ^ index » next coverage.py v7.10.1, created at 2026-09-21 19:27 +0000
« prev ^ index » next coverage.py v7.10.1, created at 2026-09-21 19:27 +0000
1"""Singular-vector decomposition of a single attention head's QK and OV maps.
3An attention head is characterized by two low-rank linear maps: the query-key
4map ``W_Q W_K^T`` that scores source positions, and the output-value map
5``W_V W_O`` that writes the attended value back into the residual stream. This
6tool takes the singular value decomposition of each map for one head and exposes
7the singular values (how much each direction matters) together with the left and
8right singular vectors (the output and input directions they act on).
10The decomposition is weight-space only: it reads ``W_Q``/``W_K``/``W_V``/``W_O``
11and needs no forward pass, no activation cache, and no compatibility mode. Each
12map is kept factored through
13:class:`~transformer_lens.FactoredMatrix.FactoredMatrix`, so the
14``d_model x d_model`` product is never materialized and the returned rank is
15bounded by ``d_head``.
17Right singular vectors are read from :attr:`~transformer_lens.FactoredMatrix.FactoredMatrix.V`
18(its columns are the right singular vectors). The historical ``.Vh`` alias is
19deprecated and returns the same tensor, so it is never used here.
21Adjacent singular values closer than a relative gap ``eps`` leave their singular
22directions defined only up to a rotation, so the result carries a per-direction
23degeneracy report. Directions are grouped into contiguous blocks that end only
24at a gap of at least ``eps``; callers attribute such a block as a subspace
25instead of trusting a single, rotation-dependent direction inside it.
27A singular value near zero relative to the top of the spectrum is null rather
28than near-equal: its singular vector is an arbitrary null-space direction, not a
29rotation of a comparable neighbour. Null directions are flagged under their own
30tolerance, ``null_rtol``, keyed to the spectrum's top value the way
31:func:`torch.linalg.matrix_rank` keys its default tolerance.
33Example::
35 from transformer_lens.model_bridge import TransformerBridge
36 from transformer_lens.tools.analysis.svd_circuits import decompose_head
38 model = TransformerBridge.boot_transformers("gpt2", device="cpu")
39 decomposition = decompose_head(model, layer=0, head=0)
40 ov = decomposition.OV
41 for row in ov.rank_report:
42 print(f"direction {row.idx}: sigma={row.sigma:.3f} ratio={row.sigma_ratio:.3f}")
43"""
45from dataclasses import dataclass
46from typing import List, Literal, Optional, Sequence, Tuple
48import torch
49from jaxtyping import Float
51from transformer_lens.FactoredMatrix import FactoredMatrix
53# Which of a head's two maps to decompose: the query-key map or the output-value map.
54Which = Literal["QK", "OV"]
56_VALID_WHICH: Tuple[Which, ...] = ("QK", "OV")
58# Relative-gap threshold: neighbouring singular values closer than this (in
59# relative terms) are treated as one rotation-ambiguous block.
60_DEFAULT_EPS = 1e-2
62# Absolute floor so relative-gap and ratio computations never divide by ~0.
63_SIGMA_FLOOR = 1e-12
66class DegenerateDirectionError(ValueError):
67 """Raised when per-direction attribution is requested for a degenerate direction.
69 Subclasses ``ValueError`` so callers that already ``except ValueError`` keep
70 working, mirroring how the other analysis tools raise ``ValueError`` for
71 their input guards.
72 """
75@dataclass
76class RankReportRow:
77 """One singular direction's summary, aligned with column ``idx`` of ``U``/``V``.
79 Attributes:
80 idx: Position of the direction, matching the column index in ``U`` and ``V``.
81 sigma: The singular value for this direction.
82 sigma_ratio: ``sigma`` normalized by the largest singular value, in ``[0, 1]``.
83 is_degenerate: True when this direction is not attributable on its own: it
84 shares a block with a neighbour (defined only up to a rotation within
85 that block) or it is numerically null.
86 is_null: True when ``sigma_ratio`` falls below ``null_rtol``, so the singular
87 vector is an arbitrary direction from the map's null space.
88 block_id: Index of the contiguous block this direction belongs to.
89 """
91 idx: int
92 sigma: float
93 sigma_ratio: float
94 is_degenerate: bool
95 is_null: bool
96 block_id: int
99@dataclass
100class HeadSVD:
101 """Factored SVD of one head map (``"QK"`` or ``"OV"``) with a degeneracy report.
103 Attributes:
104 which: Which map this decomposes, ``"QK"`` or ``"OV"``.
105 layer: Layer of the decomposed head.
106 head: Head index within the layer.
107 U: Left singular vectors, ``[d_model, rank]`` (column i is output direction i).
108 S: Singular values, ``[rank]``, sorted descending.
109 V: Right singular vectors, ``[d_model, rank]`` (column i is input direction i).
110 The reconstruction is ``U @ S.diag() @ V.transpose(-2, -1)``.
111 rank_report: Per-direction :class:`RankReportRow` list, aligned with the
112 columns of ``U``/``V``.
113 eps: Relative gap below which adjacent directions share a block; every block
114 boundary sits at a gap of at least ``eps``.
115 null_rtol: Relative-to-top-singular-value tolerance below which a direction
116 is numerically null.
117 """
119 which: Which
120 layer: int
121 head: int
122 U: Float[torch.Tensor, "d_model rank"]
123 S: Float[torch.Tensor, "rank"]
124 V: Float[torch.Tensor, "d_model rank"]
125 rank_report: List[RankReportRow]
126 eps: float
127 null_rtol: float
129 def is_degenerate(self, i: int) -> bool:
130 """Whether direction ``i`` is refused: rotation-ambiguous inside a block, or null."""
131 return self.rank_report[i].is_degenerate
133 def block_of(self, i: int) -> List[int]:
134 """Return every direction index sharing direction ``i``'s degeneracy block.
136 The result is a singleton ``[i]`` for an isolated direction and the full
137 run for a degenerate one.
138 """
139 block_id = self.rank_report[i].block_id
140 return [row.idx for row in self.rank_report if row.block_id == block_id]
142 def degenerate_blocks(self) -> List[List[int]]:
143 """Return every degenerate block's indices: the subspaces to attribute whole or skip."""
144 blocks: List[List[int]] = []
145 current: List[int] = []
146 current_block_id: Optional[int] = None
147 degenerate = False
148 # block_ids are contiguous and ascending, so one scan recovers the blocks.
149 for row in self.rank_report:
150 if row.block_id != current_block_id:
151 if degenerate:
152 blocks.append(current)
153 current = []
154 degenerate = False
155 current_block_id = row.block_id
156 current.append(row.idx)
157 degenerate = degenerate or row.is_degenerate
158 if degenerate:
159 blocks.append(current)
160 return blocks
162 def require_isolated(self, i: int) -> None:
163 """Raise :class:`DegenerateDirectionError` unless direction ``i`` is attributable alone.
165 The message names the cause, since a rotation-ambiguous block is still a
166 subspace worth attributing while a null block carries no signal.
167 """
168 row = self.rank_report[i]
169 if not row.is_degenerate:
170 return
171 where = f"Direction {i} of the {self.which} SVD of head L{self.layer}H{self.head}"
172 block = self.block_of(i)
173 if row.is_null:
174 raise DegenerateDirectionError(
175 f"{where} is numerically null (sigma_ratio {row.sigma_ratio:.2e} < "
176 f"null_rtol {self.null_rtol:.2e}), so its singular vector is an arbitrary "
177 f"null-space direction. Attribute the null block {block} as a subspace, "
178 f"or skip it."
179 )
180 raise DegenerateDirectionError(
181 f"{where} lies in block {block}, whose members are not separated by a "
182 f"relative gap of eps={self.eps:g} and so are defined only up to a rotation. "
183 f"Attribute the block as a subspace instead of the single direction."
184 )
187@dataclass
188class HeadDecomposition:
189 """Container returned by :func:`decompose_head`.
191 ``QK`` and ``OV`` hold the :class:`HeadSVD` for each requested map, or
192 ``None`` when that map was not requested.
193 """
195 layer: int
196 head: int
197 QK: Optional[HeadSVD] = None
198 OV: Optional[HeadSVD] = None
201def _read_weight(weight: torch.Tensor) -> torch.Tensor:
202 """Detach one per-head weight and put it in SVD precision.
204 Detached so the returned factors carry no autograd graph into the model. fp16/bf16
205 are promoted because reduced-precision SVD is unstable; float64 is kept so the
206 default ``null_rtol`` matches the precision actually decomposed.
207 """
208 weight = weight.detach()
209 return weight if weight.dtype == torch.float64 else weight.float()
212def _head_weights(
213 model, layer: int, head: int
214) -> Tuple[
215 Float[torch.Tensor, "d_model d_head"],
216 Float[torch.Tensor, "d_model d_head"],
217 Float[torch.Tensor, "d_model d_head"],
218 Float[torch.Tensor, "d_head d_model"],
219]:
220 """Return ``(W_Q_h, W_K_h, W_V_h, W_O_h)`` for one head, detached, in SVD precision.
222 Reads the single block's per-head weights rather than the full-model
223 ``W_Q``/``W_K``/``W_V``/``W_O`` stacks, so only one layer is materialized. On
224 grouped-query attention ``W_K``/``W_V`` carry one row per key-value head, so the
225 query head is mapped to its key-value head (query head ``h`` reads kv head
226 ``h // (n_heads // n_kv_heads)``); a no-op for multi-head attention, where the
227 head counts already match.
228 """
229 attn = model.blocks[layer].attn
230 n_kv_heads = attn.W_K.shape[0]
231 kv_head = head // (model.cfg.n_heads // n_kv_heads)
232 W_Q_h = _read_weight(attn.W_Q[head]) # [d_model, d_head]
233 W_K_h = _read_weight(attn.W_K[kv_head]) # [d_model, d_head]
234 W_V_h = _read_weight(attn.W_V[kv_head]) # [d_model, d_head]
235 W_O_h = _read_weight(attn.W_O[head]) # [d_head, d_model]
236 return W_Q_h, W_K_h, W_V_h, W_O_h
239def _degeneracy_blocks(
240 S: Float[torch.Tensor, "rank"], eps: float, null_rtol: float
241) -> List[List[int]]:
242 """Group singular directions into contiguous blocks separated by relative gaps of ``eps``.
244 ``S`` is sorted descending. Direction ``i`` joins the open block when
245 ``1 - S[i]/S[i-1] < eps``, or when it and its predecessor are both null relative to
246 the top value. Only the gap to the previous direction counts: a block may span far
247 more than ``eps`` end to end, but every boundary is an ``eps`` gap, and that
248 separation from the rest of the spectrum is what makes a block stable under
249 perturbation; no smaller contiguous group inside it is. ``_SIGMA_FLOOR`` keeps the
250 ratios finite when a divisor is ~0.
251 """
252 n = int(S.shape[0])
253 if n == 0: 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true
254 return []
255 values = [float(x) for x in S.tolist()]
256 top = max(values[0], _SIGMA_FLOOR)
257 blocks: List[List[int]] = []
258 current = [0]
259 for i in range(1, n):
260 prev = max(values[i - 1], _SIGMA_FLOOR)
261 near_equal = (1.0 - values[i] / prev) < eps
262 null_run = (values[i] / top) < null_rtol and (values[i - 1] / top) < null_rtol
263 if near_equal or null_run:
264 current.append(i)
265 else:
266 blocks.append(current)
267 current = [i]
268 blocks.append(current)
269 return blocks
272def _build_rank_report(
273 S: Float[torch.Tensor, "rank"], blocks: List[List[int]], null_rtol: float
274) -> List[RankReportRow]:
275 """Summarize each singular direction and tag its degeneracy block.
277 ``blocks`` must partition ``range(len(S))``. Nullness flags a direction on its own:
278 a lone null singular vector is arbitrary even though nothing groups with it.
279 """
280 values = [float(x) for x in S.tolist()]
281 top = max(values) if values else 0.0
282 denominator = top if top > _SIGMA_FLOOR else _SIGMA_FLOOR
283 rows: List[Optional[RankReportRow]] = [None] * len(values)
284 for block_id, block in enumerate(blocks):
285 shared = len(block) > 1
286 for idx in block:
287 sigma_ratio = values[idx] / denominator
288 is_null = sigma_ratio < null_rtol
289 rows[idx] = RankReportRow(
290 idx=idx,
291 sigma=values[idx],
292 sigma_ratio=sigma_ratio,
293 is_degenerate=shared or is_null,
294 is_null=is_null,
295 block_id=block_id,
296 )
297 return [row for row in rows if row is not None]
300def _factored_head_svd(
301 A: Float[torch.Tensor, "d_model d_head"],
302 B: Float[torch.Tensor, "d_head d_model"],
303 *,
304 which: Which,
305 layer: int,
306 head: int,
307 eps: float,
308 null_rtol: Optional[float] = None,
309) -> HeadSVD:
310 """Decompose the factored map ``A @ B`` for one head into a :class:`HeadSVD`.
312 For OV pass ``A = W_V_h`` and ``B = W_O_h``; for QK pass ``A = W_Q_h`` and
313 ``B = W_K_h.transpose(-1, -2)``. The map stays factored through
314 :class:`FactoredMatrix`, so the ``d_model x d_model`` product is never
315 materialized and the rank is bounded by ``d_head``.
317 ``null_rtol`` of ``None`` resolves to ``d_model * torch.finfo(S.dtype).eps``,
318 the same relative tolerance :func:`torch.linalg.matrix_rank` uses by default
319 for a square ``d_model x d_model`` map, so the null cutoff tracks the
320 decomposition's own numerical rank rather than a hand-tuned constant.
321 """
322 U, S, V = FactoredMatrix(A, B).svd()
323 d_model = U.shape[0]
324 resolved_null_rtol = null_rtol if null_rtol is not None else d_model * torch.finfo(S.dtype).eps
325 blocks = _degeneracy_blocks(S, eps, null_rtol=resolved_null_rtol)
326 rank_report = _build_rank_report(S, blocks, null_rtol=resolved_null_rtol)
327 return HeadSVD(
328 which=which,
329 layer=layer,
330 head=head,
331 U=U,
332 S=S,
333 V=V,
334 rank_report=rank_report,
335 eps=eps,
336 null_rtol=resolved_null_rtol,
337 )
340def decompose_head(
341 model,
342 layer: int,
343 head: int,
344 *,
345 which: Sequence[str] = ("QK", "OV"),
346 eps: float = _DEFAULT_EPS,
347 null_rtol: Optional[float] = None,
348) -> HeadDecomposition:
349 """Decompose a head's QK (``W_Q W_K^T``) and/or OV (``W_V W_O``) maps via SVD.
351 Weight-space only: this reads the head's per-block weights via the bridge's
352 ``model.blocks[layer].attn`` accessors and needs no forward pass and no
353 compatibility mode. The returned factors are detached from the model.
355 Args:
356 model: A ``TransformerBridge``.
357 layer: Layer of the head to decompose.
358 head: Head index within the layer.
359 which: Which maps to decompose, a non-empty sequence drawn from
360 ``("QK", "OV")``. A bare string is rejected rather than iterated.
361 eps: Relative gap at which a block of adjacent directions ends; directions
362 closer than this share a block.
363 null_rtol: Relative-to-top-singular-value tolerance below which a direction
364 counts as numerically null. Defaults to ``None``, which resolves to
365 ``d_model * torch.finfo(S.dtype).eps`` per map, matching
366 :func:`torch.linalg.matrix_rank`'s default tolerance.
368 Returns:
369 A :class:`HeadDecomposition` whose ``QK``/``OV`` fields hold a
370 :class:`HeadSVD` for each requested map.
372 Raises:
373 ValueError: If ``layer`` or ``head`` is out of range, or ``which`` is
374 empty, a bare string, or contains an unknown entry.
375 """
376 n_layers = model.cfg.n_layers
377 n_heads = model.cfg.n_heads
378 if not 0 <= layer < n_layers:
379 raise ValueError(f"layer must be in [0, {n_layers}), got {layer!r}")
380 if not 0 <= head < n_heads:
381 raise ValueError(f"head must be in [0, {n_heads}), got {head!r}")
382 if isinstance(which, str):
383 raise ValueError(f"which must be a sequence of map names such as ('QK',), got {which!r}")
384 requested = tuple(which)
385 if not requested:
386 raise ValueError("which must request at least one of 'QK' or 'OV'")
387 invalid = [entry for entry in requested if entry not in _VALID_WHICH]
388 if invalid:
389 raise ValueError(f"which entries must be in {_VALID_WHICH}, got {invalid!r}")
391 W_Q_h, W_K_h, W_V_h, W_O_h = _head_weights(model, layer, head)
392 qk = None
393 ov = None
394 if "QK" in requested:
395 qk = _factored_head_svd(
396 W_Q_h,
397 W_K_h.transpose(-1, -2),
398 which="QK",
399 layer=layer,
400 head=head,
401 eps=eps,
402 null_rtol=null_rtol,
403 )
404 if "OV" in requested:
405 ov = _factored_head_svd(
406 W_V_h, W_O_h, which="OV", layer=layer, head=head, eps=eps, null_rtol=null_rtol
407 )
408 return HeadDecomposition(layer=layer, head=head, QK=qk, OV=ov)