Coverage for transformer_lens/tools/analysis/backward_lens.py: 90%
436 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"""Backward Lens gradient-factor capture and vocabulary projection.
3The Backward Lens represents a linear weight gradient as a sum of token-position
4outer products and projects residual-width factors into the model vocabulary.
5The public API supports raw dense-MLP decoder-only ``TransformerBridge`` models
6(for example GPT-2 and Pythia/GPT-NeoX), reading each MLP projection's weight
7layout from the Bridge component rather than the model class.
8"""
10from __future__ import annotations
12from collections.abc import Callable, Iterator, Sequence
13from contextlib import contextmanager
14from dataclasses import dataclass
15from typing import Any, Literal, cast
17import torch
18import torch.nn.functional as F
19from jaxtyping import Bool, Float, Int
21WeightLayout = Literal["in_out", "out_in"]
22ProjectedFactor = Literal["forward_inputs", "output_gradients"]
23DEFAULT_TOP_K = 10
26@dataclass(frozen=True)
27class LinearGradientFactors:
28 """Detached factors and reconstruction for one linear weight gradient.
30 ``forward_inputs`` and ``output_gradients`` have shapes ``[position, in]``
31 and ``[position, out]``. ``output_gradients`` and ``weight_gradient`` preserve
32 the raw ``d(loss)/d(tensor)`` sign; they are not negated into update directions.
33 Gradient tensors use the requested storage layout. All tensors are cloned to
34 CPU in float32 so the result owns no autograd graph.
35 """
37 forward_inputs: Float[torch.Tensor, "position in_features"]
38 output_gradients: Float[torch.Tensor, "position out_features"]
39 weight_gradient: Float[torch.Tensor, "weight_dim_0 weight_dim_1"]
40 reconstructed_gradient: Float[torch.Tensor, "weight_dim_0 weight_dim_1"]
41 absolute_reconstruction_error: float
42 relative_reconstruction_error: float
43 weight_layout: WeightLayout
46@dataclass(frozen=True)
47class VocabularyRanking:
48 """Owned CPU copies of signed vocabulary rankings with shape ``[..., k]``.
50 ``values`` preserves the floating dtype and sign of ``logits``; ``indices``
51 has dtype ``torch.int64``. Both tensors are detached.
52 """
54 values: Float[torch.Tensor, "*leading k"]
55 indices: Int[torch.Tensor, "*leading k"]
58@dataclass(frozen=True)
59class BackwardLensMatrixResult:
60 """Factors and vocabulary readouts for one dense MLP weight matrix.
62 ``factors`` contains the full linear factorization. ``projected_factor`` says
63 whether its residual-width ``forward_inputs`` or raw-gradient
64 ``output_gradients`` were decoded. ``factor_norms`` and ``zero_norm_mask``
65 have shape ``[position]`` with float32 and bool dtypes. Largest and smallest
66 signed rankings are always retained. Full ``vocabulary_logits`` are present
67 only when explicitly requested. Normalized rankings and optional full logits
68 are present when the Normalized Logit Lens is requested. Every retained tensor
69 is a detached CPU-owned value; gradient descent subtracts raw gradients.
70 """
72 factors: LinearGradientFactors
73 projected_factor: ProjectedFactor
74 factor_norms: Float[torch.Tensor, "position"]
75 zero_norm_mask: Bool[torch.Tensor, "position"]
76 vocabulary_size: int
77 target_token_id: int
78 top_ranking: VocabularyRanking
79 bottom_ranking: VocabularyRanking
80 target_largest_ranks: Int[torch.Tensor, "position"]
81 target_smallest_ranks: Int[torch.Tensor, "position"]
82 normalized_top_ranking: VocabularyRanking | None = None
83 normalized_bottom_ranking: VocabularyRanking | None = None
84 normalized_target_largest_ranks: Int[torch.Tensor, "position"] | None = None
85 normalized_target_smallest_ranks: Int[torch.Tensor, "position"] | None = None
86 vocabulary_logits: Float[torch.Tensor, "position d_vocab"] | None = None
87 normalized_vocabulary_logits: Float[torch.Tensor, "position d_vocab"] | None = None
89 def logits(self, *, normalized: bool = False) -> Float[torch.Tensor, "position d_vocab"]:
90 """Return opted-in raw or Normalized Logit Lens full logits."""
91 if not isinstance(normalized, bool): 91 ↛ 92line 91 didn't jump to line 92 because the condition on line 91 was never true
92 raise TypeError("normalized must be a bool")
93 if normalized and self.normalized_top_ranking is None:
94 raise ValueError("normalized logits were not requested during analysis")
95 logits = self.normalized_vocabulary_logits if normalized else self.vocabulary_logits
96 if logits is None:
97 kind = "normalized " if normalized else ""
98 raise ValueError(
99 f"full {kind}logits were not retained; call "
100 "BackwardLens.analyze(..., return_full_logits=True)"
101 )
102 return logits
104 def _retained_ranking(self, *, normalized: bool, largest: bool) -> VocabularyRanking:
105 if not isinstance(normalized, bool): 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true
106 raise TypeError("normalized must be a bool")
107 if normalized:
108 ranking = self.normalized_top_ranking if largest else self.normalized_bottom_ranking
109 if ranking is None: 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true
110 raise ValueError("normalized logits were not requested during analysis")
111 return ranking
112 return self.top_ranking if largest else self.bottom_ranking
114 def top(self, *, k: int, normalized: bool = False) -> VocabularyRanking:
115 """Return up to the retained largest signed logits and token ids."""
116 return _slice_vocabulary_ranking(
117 self._retained_ranking(normalized=normalized, largest=True), k=k
118 )
120 def bottom(self, *, k: int, normalized: bool = False) -> VocabularyRanking:
121 """Return up to the retained smallest signed logits and token ids."""
122 return _slice_vocabulary_ranking(
123 self._retained_ranking(normalized=normalized, largest=False), k=k
124 )
126 def top_tokens(self, tokenizer: Any, *, k: int, normalized: bool = False) -> list[list[str]]:
127 """Decode the largest-``k`` vocabulary ids for every position."""
128 return _decode_vocabulary_ranking(self.top(k=k, normalized=normalized), tokenizer)
130 def bottom_tokens(self, tokenizer: Any, *, k: int, normalized: bool = False) -> list[list[str]]:
131 """Decode the smallest-``k`` vocabulary ids for every position."""
132 return _decode_vocabulary_ranking(self.bottom(k=k, normalized=normalized), tokenizer)
134 def target_ranks(
135 self,
136 target_token_id: int,
137 *,
138 largest: bool,
139 normalized: bool = False,
140 ) -> Int[torch.Tensor, "position"]:
141 """Return zero-based target ranks per position in the requested ordering.
143 ``largest=True`` gives rank zero to the largest logit. ``largest=False``
144 gives rank zero to the smallest, which is the useful raw-gradient
145 convention for the second MLP matrix because gradient descent subtracts it.
146 Ties receive the same competition rank. The analyzed target's ranks are
147 always retained; other token ids require opted-in full logits.
148 """
149 if isinstance(target_token_id, bool) or not isinstance(target_token_id, int): 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true
150 raise TypeError("target_token_id must be an integer")
151 if not 0 <= target_token_id < self.vocabulary_size:
152 raise ValueError(
153 f"target_token_id must be in [0, {self.vocabulary_size - 1}]; "
154 f"got {target_token_id}"
155 )
156 if not isinstance(largest, bool): 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true
157 raise TypeError("largest must be a bool")
158 if not isinstance(normalized, bool): 158 ↛ 159line 158 didn't jump to line 159 because the condition on line 158 was never true
159 raise TypeError("normalized must be a bool")
160 if normalized:
161 ranks = (
162 self.normalized_target_largest_ranks
163 if largest
164 else self.normalized_target_smallest_ranks
165 )
166 if ranks is None: 166 ↛ 167line 166 didn't jump to line 167 because the condition on line 166 was never true
167 raise ValueError("normalized logits were not requested during analysis")
168 else:
169 ranks = self.target_largest_ranks if largest else self.target_smallest_ranks
170 if target_token_id == self.target_token_id:
171 return ranks.clone()
172 return _target_vocabulary_ranks(
173 self.logits(normalized=normalized),
174 target_token_id=target_token_id,
175 largest=largest,
176 )
178 def gradient_descent_target_ranks(
179 self, target_token_id: int, *, normalized: bool = False
180 ) -> Int[torch.Tensor, "position"]:
181 """Return ascending raw-gradient target ranks (rank zero is smallest)."""
182 return self.target_ranks(target_token_id, largest=False, normalized=normalized)
185@dataclass(frozen=True)
186class BackwardLensLayerResult:
187 """Vocabulary-facing input/output MLP matrix results for one indexed layer."""
189 layer: int
190 input_projection: BackwardLensMatrixResult
191 output_projection: BackwardLensMatrixResult
194@dataclass(frozen=True)
195class BackwardLensResult:
196 """Detached result of one :meth:`BackwardLens.analyze` call.
198 ``prompt`` and ``target_token`` echo the analyzed inputs; ``target_token_id``
199 is the single vocabulary id the target text encodes to. ``loss`` is the raw
200 scalar cross-entropy of the final-position next-token prediction against the
201 target; it preserves the ``d(loss)/d(...)`` sign convention and is not negated.
202 ``prompt_token_ids`` is an owned CPU int64 tensor with shape ``[position]``;
203 every residual-width factor in ``layers`` is aligned to these same positions.
204 Position zero is a prepended BOS only when the model and tokenizer configuration
205 requests one. ``layers`` preserves requested order. Maximum errors summarize
206 both matrices over every requested layer.
207 ``includes_normalized_logits`` records whether the Normalized Logit Lens was
208 computed. ``includes_full_logits`` records whether full vocabulary tensors
209 were retained in addition to bounded rankings. No model or tokenizer reference
210 is retained.
211 """
213 prompt: str
214 prompt_token_ids: Int[torch.Tensor, "position"]
215 target_token: str
216 target_token_id: int
217 loss: float
218 layers: tuple[BackwardLensLayerResult, ...]
219 max_absolute_reconstruction_error: float
220 max_relative_reconstruction_error: float
221 includes_normalized_logits: bool
222 includes_full_logits: bool
224 def layer(self, layer: int) -> BackwardLensLayerResult:
225 """Return one requested layer result or raise ``KeyError``."""
226 for result in self.layers:
227 if result.layer == layer:
228 return result
229 raise KeyError(f"layer {layer} was not analyzed")
232@dataclass(frozen=True)
233class _MLPLayerGradientFactors:
234 """Detached gradient factors for both MLP projections in one layer."""
236 layer: int
237 input_projection: LinearGradientFactors
238 output_projection: LinearGradientFactors
241@dataclass(frozen=True)
242class _DenseMLPGradientCapture:
243 """Private capture result for one dense-MLP next-token loss.
245 Tensor fields are detached, owned CPU copies.
246 """
248 prompt_token_ids: Int[torch.Tensor, "1 position"]
249 target_token_id: int
250 loss: float
251 layers: tuple[_MLPLayerGradientFactors, ...]
254def _validate_floating_matrix(name: str, tensor: Any) -> Float[torch.Tensor, "rows columns"]:
255 if not isinstance(tensor, torch.Tensor):
256 raise TypeError(f"{name} must be a torch.Tensor")
257 if tensor.ndim != 2:
258 raise ValueError(f"{name} must be rank 2; got shape {tuple(tensor.shape)}")
259 if 0 in tensor.shape:
260 raise ValueError(f"{name} must have no empty dimensions; got shape {tuple(tensor.shape)}")
261 if not tensor.is_floating_point():
262 raise TypeError(f"{name} must have a floating dtype; got {tensor.dtype}")
263 if not bool(torch.isfinite(tensor).all()):
264 raise ValueError(f"{name} must contain only finite values")
265 return cast(Float[torch.Tensor, "rows columns"], tensor)
268def _reconstruction_errors(
269 reference: Float[torch.Tensor, "rows columns"],
270 reconstruction: Float[torch.Tensor, "rows columns"],
271) -> tuple[float, float]:
272 """Return max absolute and symmetric scale-aware relative errors.
274 The relative error is ``||reference - reconstruction||_F`` divided by the
275 maximum of the two input Frobenius norms and float32 epsilon. This remains
276 finite when one or both gradients are zero.
277 """
278 difference = reference - reconstruction
279 absolute = float(difference.abs().max())
280 scale = torch.maximum(reference.norm(), reconstruction.norm()).clamp_min(
281 torch.finfo(reference.dtype).eps
282 )
283 relative = float(difference.norm() / scale)
284 return absolute, relative
287def _to_detached_float32(
288 name: str, tensor: Float[torch.Tensor, "rows columns"]
289) -> Float[torch.Tensor, "rows columns"]:
290 """Detach and convert a validated tensor, rejecting float32 overflow."""
291 converted = tensor.detach().float()
292 if not bool(torch.isfinite(converted).all()):
293 raise ValueError(f"{name} must remain finite when converted to float32")
294 return converted
297def _build_linear_gradient_factors(
298 forward_inputs: Any,
299 output_gradients: Any,
300 weight_gradient: Any,
301 *,
302 weight_layout: Any,
303) -> LinearGradientFactors:
304 """Reconstruct a weight gradient from aligned token-position factors.
306 Args:
307 forward_inputs: Linear inputs with shape ``[position, in_features]``.
308 output_gradients: Loss gradients with respect to linear outputs, shape
309 ``[position, out_features]``.
310 weight_gradient: Independently computed gradient in ``weight_layout``.
311 weight_layout: ``"in_out"`` for GPT-2 ``Conv1D`` storage or
312 ``"out_in"`` for ``torch.nn.Linear`` storage.
314 Returns:
315 Detached factors, the independent gradient, its reconstruction, and
316 reconstruction errors, all on CPU with float32 tensor values.
317 """
318 validated_inputs = _validate_floating_matrix("forward_inputs", forward_inputs)
319 validated_gradients = _validate_floating_matrix("output_gradients", output_gradients)
320 validated_weight = _validate_floating_matrix("weight_gradient", weight_gradient)
321 if weight_layout not in ("in_out", "out_in"):
322 raise ValueError("weight_layout must be 'in_out' or 'out_in'")
323 validated_layout = cast(WeightLayout, weight_layout)
324 if validated_inputs.shape[0] != validated_gradients.shape[0]:
325 raise ValueError(
326 "forward_inputs and output_gradients must have the same number of positions; "
327 f"got {validated_inputs.shape[0]} and {validated_gradients.shape[0]}"
328 )
329 devices = {validated_inputs.device, validated_gradients.device, validated_weight.device}
330 if len(devices) != 1: 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true
331 raise ValueError(
332 "forward_inputs, output_gradients, and weight_gradient must share a device"
333 )
335 inputs = _to_detached_float32("forward_inputs", validated_inputs)
336 gradients = _to_detached_float32("output_gradients", validated_gradients)
337 canonical = inputs.T @ gradients
338 reconstruction = canonical if validated_layout == "in_out" else canonical.T
339 reference = _to_detached_float32("weight_gradient", validated_weight)
340 if not bool(torch.isfinite(reconstruction).all()):
341 raise ValueError("the float32 outer-product reconstruction must contain only finite values")
342 if reference.shape != reconstruction.shape:
343 raise ValueError(
344 f"weight_gradient shape {tuple(reference.shape)} does not match the "
345 f"{validated_layout} reconstruction shape {tuple(reconstruction.shape)}"
346 )
347 absolute, relative = _reconstruction_errors(reference, reconstruction)
348 return LinearGradientFactors(
349 forward_inputs=inputs.cpu().clone(),
350 output_gradients=gradients.cpu().clone(),
351 weight_gradient=reference.cpu().clone(),
352 reconstructed_gradient=reconstruction.cpu().clone(),
353 absolute_reconstruction_error=absolute,
354 relative_reconstruction_error=relative,
355 weight_layout=validated_layout,
356 )
359def _rank_vocabulary_logits(logits: Any, *, k: Any, largest: Any) -> VocabularyRanking:
360 """Return largest or smallest vocabulary logits and token ids per row.
362 Ordering among exactly tied logits is intentionally unspecified and follows
363 :func:`torch.topk`.
364 """
365 if not isinstance(logits, torch.Tensor):
366 raise TypeError("logits must be a torch.Tensor")
367 if logits.ndim not in (1, 2) or logits.shape[-1] == 0:
368 raise ValueError(
369 f"logits must have shape [vocab] or [position, vocab]; got {tuple(logits.shape)}"
370 )
371 if not logits.is_floating_point():
372 raise TypeError(f"logits must have a floating dtype; got {logits.dtype}")
373 if not bool(torch.isfinite(logits).all()):
374 raise ValueError("logits must contain only finite values")
375 if not isinstance(largest, bool):
376 raise TypeError(f"largest must be a bool; got {type(largest).__name__}")
377 if isinstance(k, bool) or not isinstance(k, int) or not 1 <= k <= logits.shape[-1]:
378 raise ValueError(f"k must be in [1, {logits.shape[-1]}]; got {k!r}")
379 validated_logits = cast(Float[torch.Tensor, "*leading d_vocab"], logits)
380 ranked = torch.topk(validated_logits.detach(), k=k, dim=-1, largest=largest, sorted=True)
381 return VocabularyRanking(
382 values=ranked.values.cpu().clone(), indices=ranked.indices.cpu().clone()
383 )
386def _slice_vocabulary_ranking(ranking: VocabularyRanking, *, k: int) -> VocabularyRanking:
387 """Return an owned prefix of an already sorted vocabulary ranking."""
388 retained = ranking.indices.shape[-1]
389 if isinstance(k, bool) or not isinstance(k, int) or not 1 <= k <= retained:
390 raise ValueError(f"k must be in [1, retained top_k={retained}]; got {k!r}")
391 return VocabularyRanking(
392 values=ranking.values[..., :k].clone(),
393 indices=ranking.indices[..., :k].clone(),
394 )
397def _target_vocabulary_ranks(
398 logits: Float[torch.Tensor, "position d_vocab"], *, target_token_id: int, largest: bool
399) -> Int[torch.Tensor, "position"]:
400 """Return zero-based competition ranks for one vocabulary id per row."""
401 target = logits[:, target_token_id].unsqueeze(-1)
402 comparisons = logits > target if largest else logits < target
403 return comparisons.sum(dim=-1, dtype=torch.int64).cpu().clone()
406def _decode_vocabulary_ranking(ranking: VocabularyRanking, tokenizer: Any) -> list[list[str]]:
407 """Decode a two-dimensional vocabulary ranking without retaining a tokenizer."""
408 decode = getattr(tokenizer, "decode", None)
409 if not callable(decode):
410 raise TypeError("tokenizer must provide a callable decode method")
411 if ranking.indices.ndim != 2: 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true
412 raise ValueError("decoded matrix rankings must have shape [position, k]")
413 return [[str(decode([token_id])) for token_id in row.tolist()] for row in ranking.indices]
416@torch.no_grad()
417def _project_residual_factors(
418 model: Any, factors: Float[torch.Tensor, "position d_model"]
419) -> Float[torch.Tensor, "position d_vocab"]:
420 """Apply fresh final normalization and unembedding to residual-width rows."""
421 _validate_floating_matrix("factors", factors)
422 if factors.shape[-1] != int(model.cfg.d_model): 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true
423 raise ValueError(
424 f"factors must have width d_model={model.cfg.d_model}; got {factors.shape[-1]}"
425 )
426 unembed_weight = model.W_U
427 if not isinstance(unembed_weight, torch.Tensor) or unembed_weight.ndim != 2: 427 ↛ 428line 427 didn't jump to line 428 because the condition on line 427 was never true
428 raise ValueError("the GPT-2 Bridge must expose a rank-2 unembedding weight")
429 batched = (
430 factors.detach().to(device=unembed_weight.device, dtype=unembed_weight.dtype).unsqueeze(0)
431 )
432 logits = model.unembed(model.ln_final(batched)).squeeze(0)
433 if logits.ndim != 2 or logits.shape != (factors.shape[0], unembed_weight.shape[1]): 433 ↛ 434line 433 didn't jump to line 434 because the condition on line 433 was never true
434 raise RuntimeError(
435 "final normalization and unembedding must return [position, d_vocab]; "
436 f"got {tuple(logits.shape)}"
437 )
438 projected = logits.detach().float()
439 if not bool(torch.isfinite(projected).all()): 439 ↛ 440line 439 didn't jump to line 440 because the condition on line 439 was never true
440 raise ValueError("vocabulary projection must remain finite in float32")
441 return projected.clone()
444def _factor_norms_and_normalized_rows(
445 factors: Float[torch.Tensor, "position width"],
446) -> tuple[
447 Float[torch.Tensor, "position"],
448 Bool[torch.Tensor, "position"],
449 Float[torch.Tensor, "position width"],
450]:
451 """Return original L2 norms, exact-zero mask, and safely unit-normalized rows."""
452 _validate_floating_matrix("factors", factors)
453 rows = _to_detached_float32("factors", factors).cpu().clone()
454 norms = rows.norm(dim=-1)
455 zero_mask = norms == 0
456 denominators = torch.where(zero_mask, torch.ones_like(norms), norms)
457 normalized = rows / denominators.unsqueeze(-1)
458 return norms.clone(), zero_mask.clone(), normalized
461def _build_matrix_result(
462 model: Any,
463 factors: LinearGradientFactors,
464 *,
465 projected_factor: ProjectedFactor,
466 include_normalized_logits: bool,
467 target_token_id: int,
468 top_k: int,
469 return_full_logits: bool,
470) -> BackwardLensMatrixResult:
471 if projected_factor not in ("forward_inputs", "output_gradients"): 471 ↛ 472line 471 didn't jump to line 472 because the condition on line 471 was never true
472 raise ValueError("projected_factor must be 'forward_inputs' or 'output_gradients'")
473 if not isinstance(include_normalized_logits, bool): 473 ↛ 474line 473 didn't jump to line 474 because the condition on line 473 was never true
474 raise TypeError("include_normalized_logits must be a bool")
475 if not isinstance(return_full_logits, bool): 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true
476 raise TypeError("return_full_logits must be a bool")
477 rows = (
478 factors.forward_inputs if projected_factor == "forward_inputs" else factors.output_gradients
479 )
480 norms, zero_mask, normalized_rows = _factor_norms_and_normalized_rows(rows)
481 raw_logits = _project_residual_factors(model, rows)
482 normalized_logits = (
483 _project_residual_factors(model, normalized_rows) if include_normalized_logits else None
484 )
485 top_ranking = _rank_vocabulary_logits(raw_logits, k=top_k, largest=True)
486 bottom_ranking = _rank_vocabulary_logits(raw_logits, k=top_k, largest=False)
487 target_largest_ranks = _target_vocabulary_ranks(
488 raw_logits, target_token_id=target_token_id, largest=True
489 )
490 target_smallest_ranks = _target_vocabulary_ranks(
491 raw_logits, target_token_id=target_token_id, largest=False
492 )
493 normalized_top_ranking = None
494 normalized_bottom_ranking = None
495 normalized_target_largest_ranks = None
496 normalized_target_smallest_ranks = None
497 if normalized_logits is not None:
498 normalized_top_ranking = _rank_vocabulary_logits(normalized_logits, k=top_k, largest=True)
499 normalized_bottom_ranking = _rank_vocabulary_logits(
500 normalized_logits, k=top_k, largest=False
501 )
502 normalized_target_largest_ranks = _target_vocabulary_ranks(
503 normalized_logits, target_token_id=target_token_id, largest=True
504 )
505 normalized_target_smallest_ranks = _target_vocabulary_ranks(
506 normalized_logits, target_token_id=target_token_id, largest=False
507 )
508 return BackwardLensMatrixResult(
509 factors=factors,
510 projected_factor=projected_factor,
511 factor_norms=norms,
512 zero_norm_mask=zero_mask,
513 vocabulary_size=raw_logits.shape[-1],
514 target_token_id=target_token_id,
515 top_ranking=top_ranking,
516 bottom_ranking=bottom_ranking,
517 target_largest_ranks=target_largest_ranks,
518 target_smallest_ranks=target_smallest_ranks,
519 normalized_top_ranking=normalized_top_ranking,
520 normalized_bottom_ranking=normalized_bottom_ranking,
521 normalized_target_largest_ranks=normalized_target_largest_ranks,
522 normalized_target_smallest_ranks=normalized_target_smallest_ranks,
523 vocabulary_logits=raw_logits.cpu().clone() if return_full_logits else None,
524 normalized_vocabulary_logits=(
525 normalized_logits.cpu().clone()
526 if return_full_logits and normalized_logits is not None
527 else None
528 ),
529 )
532def _validate_requested_layers(model: Any, layers: Sequence[int]) -> tuple[int, ...]:
533 if isinstance(layers, (str, bytes)) or not isinstance(layers, Sequence): 533 ↛ 534line 533 didn't jump to line 534 because the condition on line 533 was never true
534 raise TypeError("layers must be a sequence of integer layer indices")
535 requested = tuple(layers)
536 if not requested:
537 raise ValueError("layers must contain at least one layer index")
538 for layer in requested:
539 if isinstance(layer, bool) or not isinstance(layer, int):
540 raise TypeError(f"each layer must be an integer; got {layer!r}")
541 if len(set(requested)) != len(requested):
542 raise ValueError("layers must not contain duplicate indices")
543 n_layers = int(model.cfg.n_layers)
544 invalid = [layer for layer in requested if not 0 <= layer < n_layers]
545 if invalid:
546 raise ValueError(f"layers must be in [0, {n_layers - 1}]; got {invalid}")
547 return requested
550def _require_raw_dense_mlp_bridge(model: Any) -> None:
551 """Require the raw dense-MLP Bridge capabilities used by gradient capture.
553 The architecture is not constrained by class; support is decided per
554 projection from the Bridge weight-layout oracle in projection discovery.
555 """
556 from transformer_lens.model_bridge import TransformerBridge
558 if not isinstance(model, TransformerBridge):
559 raise TypeError(
560 "Backward Lens supports TransformerBridge only; load the model with "
561 "TransformerBridge.boot_transformers(...)."
562 )
563 if getattr(model, "compatibility_mode", False):
564 raise ValueError(
565 "Backward Lens requires a raw TransformerBridge; compatibility mode is enabled"
566 )
567 if getattr(model, "_weights_processed", False):
568 raise ValueError(
569 "Backward Lens requires original model weights; this Bridge processed its weights"
570 )
571 if int(model.cfg.n_devices) > 1:
572 raise ValueError(
573 "Backward Lens requires a single-device TransformerBridge because the MLP "
574 "projections, final normalization, and unembed must be co-located; "
575 f"device-map dispatch with cfg.n_devices={model.cfg.n_devices} is not supported"
576 )
577 if bool(getattr(model.cfg, "gated_mlp", False)):
578 raise NotImplementedError("Backward Lens currently requires dense, non-gated MLPs")
579 if model.tokenizer is None:
580 raise ValueError("Backward Lens requires a TransformerBridge with a tokenizer")
581 for component in ("blocks", "ln_final", "unembed"):
582 if not hasattr(model, component): 582 ↛ 583line 582 didn't jump to line 583 because the condition on line 582 was never true
583 raise ValueError(f"Backward Lens requires the standard {component} component")
586@dataclass(frozen=True)
587class _MLPLinear:
588 """One validated dense-MLP linear projection with its resolved weight layout."""
590 projection: Any
591 weight_layout: WeightLayout
594def _get_dense_mlp_projections(
595 model: Any, layers: tuple[int, ...]
596) -> dict[int, tuple[_MLPLinear, _MLPLinear]]:
597 """Return validated live dense-MLP input/output projection bridges.
599 Each projection's storage orientation is resolved from the Bridge weight-layout
600 oracle, so Conv1D ``[in, out]`` and ``torch.nn.Linear`` ``[out, in]`` weights are
601 both accepted without inspecting the model class. Projections whose wrapped
602 module the oracle cannot orient are rejected.
603 """
604 from transformer_lens.hook_points import HookPoint
605 from transformer_lens.model_bridge.generalized_components import (
606 LinearBridge,
607 MLPBridge,
608 )
609 from transformer_lens.model_bridge.generalized_components.mlp import (
610 weight_layout_in_out,
611 )
613 d_model = int(model.cfg.d_model)
614 d_mlp = int(model.cfg.d_mlp)
615 # Feature counts are fixed by the MLP role; storage order follows the layout.
616 # Input maps d_model -> d_mlp, output maps d_mlp -> d_model.
617 feature_pairs = ((d_model, d_mlp), (d_mlp, d_model))
618 projections: dict[int, tuple[_MLPLinear, _MLPLinear]] = {}
619 for layer in layers:
620 mlp = model.blocks[layer].mlp
621 if not isinstance(mlp, MLPBridge) or getattr(mlp, "gate", None) is not None:
622 raise ValueError(f"layer {layer} must have a dense, non-gated MLPBridge")
623 pair = (getattr(mlp, "in", None), getattr(mlp, "out", None))
624 records: list[_MLPLinear] = []
625 for name, projection, feature_pair in zip(
626 ("input", "output"), pair, feature_pairs, strict=True
627 ):
628 if not isinstance(projection, LinearBridge): 628 ↛ 629line 628 didn't jump to line 629 because the condition on line 628 was never true
629 raise ValueError(f"layer {layer} {name} projection must be a LinearBridge")
630 layout_flag = weight_layout_in_out(projection)
631 if layout_flag is None:
632 raise ValueError(
633 f"layer {layer} {name} projection has an unknown weight layout; "
634 "Backward Lens supports Conv1D or torch.nn.Linear MLP projections"
635 )
636 weight_layout: WeightLayout = "in_out" if layout_flag else "out_in"
637 in_features, out_features = feature_pair
638 expected_shape = (
639 (in_features, out_features)
640 if weight_layout == "in_out"
641 else (out_features, in_features)
642 )
643 weight = getattr(projection.original_component, "weight", None)
644 if not isinstance(weight, torch.nn.Parameter): 644 ↛ 645line 644 didn't jump to line 645 because the condition on line 644 was never true
645 raise ValueError(
646 f"layer {layer} {name} original weight must be a trainable Parameter"
647 )
648 if not weight.is_floating_point() or tuple(weight.shape) != expected_shape:
649 raise ValueError(
650 f"layer {layer} {name} weight must have shape {expected_shape} "
651 f"and floating dtype; got {tuple(weight.shape)} and {weight.dtype}"
652 )
653 if not weight.requires_grad:
654 raise ValueError(
655 f"layer {layer} {name} original weight must be a trainable Parameter"
656 )
657 if not isinstance(projection.hook_in, HookPoint) or not isinstance( 657 ↛ 660line 657 didn't jump to line 660 because the condition on line 657 was never true
658 projection.hook_out, HookPoint
659 ):
660 raise ValueError(f"layer {layer} {name} projection is missing Bridge hook points")
661 records.append(_MLPLinear(projection=projection, weight_layout=weight_layout))
662 projections[layer] = (records[0], records[1])
663 return projections
666def _capture_once(
667 captured: dict[tuple[int, str, str], torch.Tensor], key: tuple[int, str, str]
668) -> Callable[[torch.nn.Module, tuple[Any, ...], Any], None]:
669 """Build a non-modifying PyTorch hook that records one tensor."""
671 def capture(_module: torch.nn.Module, _inputs: tuple[Any, ...], output: Any) -> None:
672 if key in captured: 672 ↛ 673line 672 didn't jump to line 673 because the condition on line 672 was never true
673 raise RuntimeError(f"Backward Lens hook {key} fired more than once")
674 if not isinstance(output, torch.Tensor): 674 ↛ 675line 674 didn't jump to line 675 because the condition on line 674 was never true
675 raise RuntimeError(f"Backward Lens hook {key} returned a non-tensor output")
676 captured[key] = output
678 return capture
681@contextmanager
682def _capture_projection_tensors(
683 projections: dict[int, tuple[_MLPLinear, _MLPLinear]],
684) -> Iterator[dict[tuple[int, str, str], torch.Tensor]]:
685 """Capture exact linear boundaries while preserving every pre-existing hook."""
686 captured: dict[tuple[int, str, str], torch.Tensor] = {}
687 handles: list[Any] = []
688 try:
689 for layer, pair in projections.items():
690 for name, record in zip(("input", "output"), pair, strict=True):
691 projection = record.projection
692 input_key = (layer, name, "forward_input")
693 output_key = (layer, name, "output")
694 # Existing hook_in edits must run first so this is the actual linear input.
695 handles.append(
696 projection.hook_in.register_forward_hook(_capture_once(captured, input_key))
697 )
698 # Capture the raw linear output before any existing hook_out edits.
699 handles.append(
700 projection.hook_out.register_forward_hook(
701 _capture_once(captured, output_key), prepend=True
702 )
703 )
704 yield captured
705 finally:
706 for handle in reversed(handles):
707 handle.remove()
710def _single_batch_matrix(name: str, tensor: Any) -> Float[torch.Tensor, "position width"]:
711 if not isinstance(tensor, torch.Tensor):
712 raise RuntimeError(f"{name} must be a torch.Tensor")
713 if tensor.ndim != 3 or tensor.shape[0] != 1:
714 raise RuntimeError(
715 f"{name} must have shape [1, position, width]; got {tuple(tensor.shape)}"
716 )
717 matrix = tensor[0]
718 if not matrix.is_floating_point():
719 raise RuntimeError(f"{name} must have a floating dtype; got {matrix.dtype}")
720 return cast(Float[torch.Tensor, "position width"], matrix)
723@contextmanager
724def _preserve_model_rng(model: Any) -> Iterator[None]:
725 """Preserve CPU and every CUDA/MPS RNG used by the wrapped model."""
726 parameter_devices = {parameter.device for parameter in model.original_model.parameters()}
727 cuda_devices = sorted(
728 {
729 device.index
730 for device in parameter_devices
731 if device.type == "cuda" and device.index is not None
732 }
733 )
734 uses_mps = any(device.type == "mps" for device in parameter_devices)
735 mps_state = torch.mps.get_rng_state() if uses_mps else None
736 try:
737 with torch.random.fork_rng(devices=cuda_devices):
738 yield
739 finally:
740 if mps_state is not None: 740 ↛ 741line 740 didn't jump to line 741 because the condition on line 740 was never true
741 torch.mps.set_rng_state(mps_state)
744def _capture_dense_mlp_gradient_factors(
745 model: Any,
746 prompt: str,
747 target_token: str,
748 layers: Sequence[int],
749) -> _DenseMLPGradientCapture:
750 """Capture exact dense-MLP weight-gradient factors for one next-token loss.
752 The analysis performs one grad-enabled forward and exactly one
753 :func:`torch.autograd.grad` call. It does not call ``backward``, touch
754 parameter ``.grad`` buffers, change training state, or remove caller hooks.
755 """
756 if torch.is_inference_mode_enabled():
757 raise ValueError(
758 "Backward Lens cannot capture gradients inside torch.inference_mode(); "
759 "exit inference_mode before running the analysis"
760 )
761 _require_raw_dense_mlp_bridge(model)
762 requested_layers = _validate_requested_layers(model, layers)
763 projections = _get_dense_mlp_projections(model, requested_layers)
764 if not isinstance(prompt, str): 764 ↛ 765line 764 didn't jump to line 765 because the condition on line 764 was never true
765 raise TypeError("prompt must be a string")
766 if prompt == "":
767 raise ValueError("prompt must not be empty")
768 if not isinstance(target_token, str): 768 ↛ 769line 768 didn't jump to line 769 because the condition on line 768 was never true
769 raise TypeError("target_token must be a string")
771 prompt_tokens = model.to_tokens(prompt, truncate=False)
772 if prompt_tokens.ndim != 2 or prompt_tokens.shape[0] != 1 or prompt_tokens.shape[1] == 0: 772 ↛ 773line 772 didn't jump to line 773 because the condition on line 772 was never true
773 raise ValueError("prompt must tokenize to one non-empty sequence")
774 prompt_token_count = int(prompt_tokens.shape[1])
775 context_size = int(model.cfg.n_ctx)
776 if prompt_token_count > context_size:
777 raise ValueError(
778 f"prompt token count {prompt_token_count} exceeds model context limit "
779 f"n_ctx={context_size}"
780 )
781 target_tokens = model.to_tokens(target_token, prepend_bos=False)
782 if target_tokens.ndim != 2 or tuple(target_tokens.shape) != (1, 1):
783 count = int(target_tokens.numel())
784 raise ValueError(
785 "target_token must encode to exactly one token without BOS; " f"got {count} tokens"
786 )
787 target_token_id = int(target_tokens.item())
788 input_device = next(model.original_model.parameters()).device
789 prompt_tokens = prompt_tokens.to(input_device)
790 weights = [
791 record.projection.original_component.weight
792 for layer in requested_layers
793 for record in projections[layer]
794 ]
795 with _preserve_model_rng(model), torch.enable_grad():
796 with _capture_projection_tensors(projections) as captured:
797 logits = model(prompt_tokens)
798 if not isinstance(logits, torch.Tensor) or logits.ndim != 3 or logits.shape[0] != 1: 798 ↛ 799line 798 didn't jump to line 799 because the condition on line 798 was never true
799 raise RuntimeError("the Bridge must return logits with shape [1, position, vocab]")
800 target = torch.tensor([target_token_id], device=logits.device)
801 loss = F.cross_entropy(logits[:, -1, :], target)
802 if not bool(torch.isfinite(loss)): 802 ↛ 803line 802 didn't jump to line 803 because the condition on line 802 was never true
803 raise ValueError("the next-token loss must be finite")
804 outputs = [
805 captured[(layer, name, "output")]
806 for layer in requested_layers
807 for name in ("input", "output")
808 ]
809 gradients = torch.autograd.grad(loss, (*outputs, *weights), allow_unused=False)
811 output_gradients = gradients[: len(outputs)]
812 weight_gradients = gradients[len(outputs) :]
813 layer_results = []
814 for index, layer in enumerate(requested_layers):
815 input_offset = 2 * index
816 input_record, output_record = projections[layer]
817 input_factors = _build_linear_gradient_factors(
818 _single_batch_matrix(
819 f"layer {layer} input projection input",
820 captured[(layer, "input", "forward_input")],
821 ),
822 _single_batch_matrix(
823 f"layer {layer} input projection gradient", output_gradients[input_offset]
824 ),
825 weight_gradients[input_offset],
826 weight_layout=input_record.weight_layout,
827 )
828 output_factors = _build_linear_gradient_factors(
829 _single_batch_matrix(
830 f"layer {layer} output projection input",
831 captured[(layer, "output", "forward_input")],
832 ),
833 _single_batch_matrix(
834 f"layer {layer} output projection gradient",
835 output_gradients[input_offset + 1],
836 ),
837 weight_gradients[input_offset + 1],
838 weight_layout=output_record.weight_layout,
839 )
840 layer_results.append(
841 _MLPLayerGradientFactors(
842 layer=layer,
843 input_projection=input_factors,
844 output_projection=output_factors,
845 )
846 )
847 return _DenseMLPGradientCapture(
848 prompt_token_ids=prompt_tokens.detach().cpu().clone(),
849 target_token_id=target_token_id,
850 loss=float(loss.detach()),
851 layers=tuple(layer_results),
852 )
855class BackwardLens:
856 """Analyze dense MLP weight gradients in the output vocabulary basis.
858 The analyzer accepts a fresh, raw dense-MLP :class:`TransformerBridge` such as
859 GPT-2 or Pythia/GPT-NeoX. Results retain no model or tokenizer reference and
860 contain detached CPU-owned tensors. Raw backward signals are loss gradients;
861 gradient descent subtracts them.
862 """
864 def __init__(self, model: Any):
865 """Validate and retain the raw dense-MLP Bridge used for analyses."""
866 _require_raw_dense_mlp_bridge(model)
867 self._model = model
869 def analyze(
870 self,
871 prompt: str,
872 target_token: str,
873 layers: Sequence[int],
874 *,
875 normalized: bool = False,
876 top_k: int = DEFAULT_TOP_K,
877 return_full_logits: bool = False,
878 ) -> BackwardLensResult:
879 """Analyze one final-position, one-token target loss.
881 Args:
882 prompt: Non-empty unbatched prompt text.
883 target_token: Text encoding to exactly one token without BOS.
884 layers: Unique layer indices in desired result order.
885 normalized: Also project unit-normalized nonzero factors using the
886 Normalized Logit Lens. Raw projections are always returned.
887 top_k: Number of largest and smallest values and token ids retained
888 per matrix and position. Defaults to 10.
889 return_full_logits: Also retain full vocabulary tensors on CPU.
890 Defaults to ``False`` to keep result size bounded.
892 Returns:
893 Detached gradient factors, bounded vocabulary rankings, norms,
894 reconstruction errors, target metadata, and optional full logits.
895 """
896 if not isinstance(normalized, bool): 896 ↛ 897line 896 didn't jump to line 897 because the condition on line 896 was never true
897 raise TypeError("normalized must be a bool")
898 if isinstance(top_k, bool) or not isinstance(top_k, int):
899 raise TypeError("top_k must be an integer")
900 vocabulary_size = int(self._model.cfg.d_vocab)
901 if not 1 <= top_k <= vocabulary_size:
902 raise ValueError(f"top_k must be in [1, {vocabulary_size}]; got {top_k!r}")
903 if not isinstance(return_full_logits, bool): 903 ↛ 904line 903 didn't jump to line 904 because the condition on line 903 was never true
904 raise TypeError("return_full_logits must be a bool")
905 capture = _capture_dense_mlp_gradient_factors(self._model, prompt, target_token, layers)
906 layer_results: list[BackwardLensLayerResult] = []
907 absolute_errors: list[float] = []
908 relative_errors: list[float] = []
909 for layer in capture.layers:
910 input_result = _build_matrix_result(
911 self._model,
912 layer.input_projection,
913 projected_factor="forward_inputs",
914 include_normalized_logits=normalized,
915 target_token_id=capture.target_token_id,
916 top_k=top_k,
917 return_full_logits=return_full_logits,
918 )
919 output_result = _build_matrix_result(
920 self._model,
921 layer.output_projection,
922 projected_factor="output_gradients",
923 include_normalized_logits=normalized,
924 target_token_id=capture.target_token_id,
925 top_k=top_k,
926 return_full_logits=return_full_logits,
927 )
928 layer_results.append(
929 BackwardLensLayerResult(
930 layer=layer.layer,
931 input_projection=input_result,
932 output_projection=output_result,
933 )
934 )
935 absolute_errors.extend(
936 (
937 layer.input_projection.absolute_reconstruction_error,
938 layer.output_projection.absolute_reconstruction_error,
939 )
940 )
941 relative_errors.extend(
942 (
943 layer.input_projection.relative_reconstruction_error,
944 layer.output_projection.relative_reconstruction_error,
945 )
946 )
947 return BackwardLensResult(
948 prompt=prompt,
949 prompt_token_ids=capture.prompt_token_ids[0].clone(),
950 target_token=target_token,
951 target_token_id=capture.target_token_id,
952 loss=capture.loss,
953 layers=tuple(layer_results),
954 max_absolute_reconstruction_error=max(absolute_errors),
955 max_relative_reconstruction_error=max(relative_errors),
956 includes_normalized_logits=normalized,
957 includes_full_logits=return_full_logits,
958 )