Coverage for transformer_lens/tools/analysis/attribution_patching.py: 92%
324 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"""Attribution patching — linearized activation patching on ``TransformerBridge``.
3Attribution patching estimates the causal effect of every model component on a
4task metric with a *gradient-based linearization* of activation patching: rather
5than one forward pass per intervention, it reads a single gradient cache. For a
6clean/corrupt prompt pair it runs a clean forward (for ``a_clean``) and a corrupt
7forward whose backward hooks capture ``g = d(metric)/d(a)`` (for ``a_corrupt`` and
8its gradient), and scores each node with the
9first-order Taylor estimate ``effect(node) = (a_clean - a_corrupt) . g``. Scores
10over a batch of clean/corrupt pairs are averaged before ranking.
12Only the ``TransformerBridge`` API is targeted; TransformerLens v4 deprecates
13``HookedTransformer``.
15Sign/direction convention (denoising form): the gradient is taken on the
16*corrupt* run and the estimate points *toward* the clean activation, so a
17positive score means patching that node from corrupt toward clean moves the
18metric in the positive direction. An oracle-parity test maps this convention
19onto a pinned reference rather than assuming the two agree.
21Memory note: gradients are retained only for hook points passing ``names_filter``.
22Retaining gradients at every hook point roughly doubles cache memory, so callers
23should filter to the hook families their analysis actually reads.
25Scope: this build ships node and edge granularity with plain attribution
26(``ig_steps=1``). The integrated-gradient path (EAP-IG, ``ig_steps>1``) and
27ablate-outside faithfulness are not implemented yet; their API is declared here,
28and ``ig_steps>1`` raises :class:`NotImplementedError`, so downstream code can pin
29against a stable surface now.
30"""
32from __future__ import annotations
34from contextlib import contextmanager
35from dataclasses import dataclass, field
36from typing import Any, Callable, Iterator, Literal, Optional, Sequence, Union
38import torch
40from transformer_lens.tools.analysis._model_state import require_eval_mode
42MetricFn = Callable[[torch.Tensor], torch.Tensor]
43NamesFilter = Union[str, Sequence[str], Callable[[str], bool], None]
45NodeKind = Literal[
46 "embed", "attn_head_out", "mlp_out", "q_input", "k_input", "v_input", "mlp_in", "logits"
47]
48Granularity = Literal["node", "edge"]
51@dataclass
52class GradientCache:
53 """Activations and their metric-gradients from one forward + backward pass.
55 Attributes:
56 activations: Detached activation tensor per cached hook name.
57 gradients: ``d(metric)/d(activation)`` per cached hook name.
58 metric: The scalar metric value at this run (detached).
59 """
61 activations: dict[str, torch.Tensor]
62 gradients: dict[str, Optional[torch.Tensor]]
63 metric: torch.Tensor
66@dataclass(frozen=True)
67class Node:
68 """A node in the residual-stream computational graph.
70 Nodes are the typed, hashable keys the attribution sweep scores. Each node
71 is identified by ``(kind, layer, position, head)``; ``kind`` selects the node
72 family and constrains which of ``layer``/``head`` apply. Three kinds are
73 *writers* -- they contribute a value into the residual stream:
75 - ``"embed"``: the token embedding write. ``layer`` and ``head`` are ``None``.
76 - ``"attn_head_out"``: one attention head's output. ``layer`` and ``head`` set.
77 - ``"mlp_out"``: one layer's MLP output. ``layer`` set, ``head`` is ``None``.
79 Five kinds are *readers* -- they consume the residual stream as an edge's
80 destination (see :func:`enumerate_edges`):
82 - ``"q_input"`` / ``"k_input"`` / ``"v_input"``: one attention head's split
83 Q/K/V input. ``layer`` and ``head`` set.
84 - ``"mlp_in"``: one layer's MLP entry. ``layer`` set, ``head`` is ``None``.
85 - ``"logits"``: the terminal readout of the final residual, read at
86 ``blocks.{n_layers-1}.hook_resid_post``. ``layer`` is that final layer and
87 ``head`` is ``None``. Every writer feeds this reader, so a writer's
88 aggregate over its outgoing edges equals its direct node score.
90 ``position`` is the sequence index the node is read at. The invariants above
91 are enforced in ``__post_init__`` so a malformed key raises rather than
92 silently producing a wrong graph.
93 """
95 kind: NodeKind
96 position: int
97 layer: Optional[int] = None
98 head: Optional[int] = None
100 def __post_init__(self) -> None:
101 if self.kind == "embed":
102 if self.layer is not None or self.head is not None:
103 raise ValueError("embed nodes take neither layer nor head")
104 elif self.kind == "attn_head_out":
105 if self.layer is None or self.head is None:
106 raise ValueError("attn_head_out nodes need both layer and head")
107 elif self.kind == "mlp_out":
108 if self.layer is None: 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true
109 raise ValueError("mlp_out nodes need a layer")
110 if self.head is not None:
111 raise ValueError("mlp_out nodes take no head")
112 elif self.kind in ("q_input", "k_input", "v_input"):
113 if self.layer is None or self.head is None:
114 raise ValueError(f"{self.kind} nodes need both layer and head")
115 elif self.kind == "mlp_in":
116 if self.layer is None: 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true
117 raise ValueError("mlp_in nodes need a layer")
118 if self.head is not None:
119 raise ValueError("mlp_in nodes take no head")
120 elif self.kind == "logits": 120 ↛ 126line 120 didn't jump to line 126 because the condition on line 120 was always true
121 if self.layer is None: 121 ↛ 122line 121 didn't jump to line 122 because the condition on line 121 was never true
122 raise ValueError("logits nodes need a layer")
123 if self.head is not None: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 raise ValueError("logits nodes take no head")
125 else:
126 raise ValueError(f"unknown node kind {self.kind!r}")
128 @property
129 def hook_name(self) -> str:
130 """The cache hook point this node reads from.
132 Uses the standard ``TransformerBridge`` alias names (``hook_embed``,
133 ``blocks.{l}.attn.hook_z``, ``blocks.{l}.hook_mlp_out``,
134 ``blocks.{l}.attn.hook_q_input``/``hook_k_input``/``hook_v_input``,
135 ``blocks.{l}.hook_mlp_in``, ``blocks.{l}.hook_resid_post``); the per-head
136 nodes (``attn_head_out``, ``q_input``, ``k_input``, ``v_input``) slice
137 head ``self.head`` out of the shared per-head tensor.
138 """
139 if self.kind == "embed":
140 return "hook_embed"
141 if self.kind == "attn_head_out":
142 return f"blocks.{self.layer}.attn.hook_z"
143 if self.kind == "mlp_out":
144 return f"blocks.{self.layer}.hook_mlp_out"
145 if self.kind in ("q_input", "k_input", "v_input"):
146 return f"blocks.{self.layer}.attn.hook_{self.kind}"
147 if self.kind == "logits":
148 return f"blocks.{self.layer}.hook_resid_post"
149 return f"blocks.{self.layer}.hook_mlp_in"
152@dataclass(frozen=True)
153class EdgeAttributionConfig:
154 """Configuration for an attribution-patching sweep.
156 The two axes are deliberately orthogonal:
158 - ``granularity`` selects what is scored: ``"node"`` scores each residual-stream
159 write, ``"edge"`` scores each ``(source, destination)`` write->read pair.
160 - ``ig_steps`` selects gradient fidelity. ``ig_steps=1`` is plain attribution
161 patching / EAP: a single first-order Taylor gradient taken at the corrupt
162 point. ``ig_steps>1`` is EAP-IG: the integrated gradient averaged over that
163 many points along the corrupt->clean path, which corrects the gradient
164 saturation that makes plain attribution unfaithful.
166 There is intentionally **no** ``method`` field. An earlier design had both a
167 ``method`` enum (``"attribution"``/``"EAP"``/``"EAP-IG"``) and ``ig_steps``,
168 which overlap: the method is fully determined by ``granularity`` and whether
169 ``ig_steps`` exceeds 1. Collapsing them removes the invalid states (e.g.
170 ``method="attribution", ig_steps=5``).
172 This build implements node and edge granularity with plain attribution.
173 ``ig_steps>1`` is accepted by the type but raises :class:`NotImplementedError`
174 at construction, so downstream code can import and reference this API now
175 while the integrated-gradient path is not implemented yet. Once EAP-IG lands,
176 the default flips to ``ig_steps=5`` (EAP-IG is the faithful default); until
177 then the default is the only executable value, ``ig_steps=1``.
179 Attributes:
180 granularity: ``"node"`` or ``"edge"``. Defaults to ``"node"``.
181 ig_steps: Integrated-gradient path steps (``>=1``). Defaults to ``1``.
182 """
184 granularity: Granularity = "node"
185 ig_steps: int = 1
187 def __post_init__(self) -> None:
188 if self.ig_steps < 1:
189 raise ValueError(f"ig_steps must be >= 1, got {self.ig_steps}")
190 if self.ig_steps > 1:
191 raise NotImplementedError(
192 "ig_steps>1 (EAP-IG integrated gradients) is not implemented yet; this "
193 "build supports ig_steps=1 (plain attribution) only."
194 )
197@dataclass
198class AttributionResult:
199 """Scored output of an attribution-patching sweep.
201 Attributes:
202 node_scores: Signed first-order effect estimate per node,
203 ``(a_clean - a_corrupt) . d(metric)/d(a)``. A positive score means
204 patching that node from corrupt toward clean moves the metric in the
205 positive direction (the denoising convention pinned in the module
206 docstring). For an edge-granularity sweep this is instead each
207 writer's aggregate over its own outgoing edge scores. Because the
208 graph has a terminal logits reader that every writer feeds, that
209 aggregate equals the writer's direct node score (see
210 :func:`attribution_patch`).
211 edge_scores: Per-edge effect estimate keyed by ``(source, destination)``.
212 Populated for an edge-granularity sweep; empty for a node sweep.
213 """
215 node_scores: dict[Node, float]
216 edge_scores: dict[tuple[Node, Node], float] = field(default_factory=dict)
218 def top_nodes(self, k: int = 10) -> list[tuple[Node, float]]:
219 """The ``k`` nodes with the largest effect magnitude, strongest first.
221 Ranking is by absolute score: a node with a large negative effect is as
222 causally important as one with a large positive effect, so magnitude — not
223 signed value — orders the circuit. Ties keep enumeration order (stable
224 sort). Requesting more than the available nodes returns all of them.
225 """
226 ranked = sorted(self.node_scores.items(), key=lambda item: abs(item[1]), reverse=True)
227 return ranked[:k]
229 def top_edges(self, k: int = 10) -> list[tuple[Node, Node, float]]:
230 """The ``k`` edges with the largest effect magnitude, strongest first.
232 Ranking is by absolute score, matching ``top_nodes``: a large negative
233 edge effect is as causally important as a large positive one. Ties keep
234 enumeration order (stable sort). Requesting more than the available
235 edges returns all of them.
236 """
237 ranked = sorted(self.edge_scores.items(), key=lambda item: abs(item[1]), reverse=True)
238 return [(writer, reader, score) for (writer, reader), score in ranked[:k]]
241def _required_hook_names(n_layers: int, granularity: Granularity = "node") -> list[str]:
242 """Hook points a sweep at ``granularity`` reads.
244 Node granularity needs the embed write plus each layer's attn-z and
245 mlp-out. Edge granularity additionally needs the per-head hook points on
246 both sides of an edge into or out of an attention head: ``attn.hook_result``
247 (writer -- a head's own contribution before the sum into the residual
248 stream) and the split ``attn.hook_q_input``/``hook_k_input``/``hook_v_input``
249 (reader -- the residual each head's Q/K/V projection reads separately).
250 """
251 names = ["hook_embed"]
252 for layer in range(n_layers):
253 names.append(f"blocks.{layer}.attn.hook_z")
254 names.append(f"blocks.{layer}.hook_mlp_out")
255 if granularity == "edge":
256 names.append(f"blocks.{layer}.attn.hook_result")
257 names.append(f"blocks.{layer}.attn.hook_q_input")
258 names.append(f"blocks.{layer}.attn.hook_k_input")
259 names.append(f"blocks.{layer}.attn.hook_v_input")
260 return names
263def _ensure_edge_hook_flags(model: Any) -> None:
264 """Turn on the Bridge flags edge granularity's hook points require.
266 ``attn.hook_result``, the split ``attn.hook_q_input``/``hook_k_input``/
267 ``hook_v_input``, and ``hook_mlp_in`` all exist on the Bridge
268 unconditionally but only fire when their owning flag
269 (``cfg.use_attn_result`` / ``cfg.use_split_qkv_input`` /
270 ``cfg.use_hook_mlp_in``) is on, so an edge sweep must enable all three
271 before caching or the writer- and reader-side hook points it needs never
272 populate.
274 Memory caveat: enabling ``use_attn_result``/``use_split_qkv_input`` makes
275 every cached per-head tensor ``[batch, seq, n_heads, d_model]`` instead of
276 the summed ``[batch, seq, d_model]`` residual. That is fine on a model the
277 size of gpt2-small; it does not scale to models with many heads or layers.
279 Args:
280 model: A ``TransformerBridge`` (or compatible) exposing ``cfg`` and
281 ``set_use_attn_result``/``set_use_split_qkv_input``/
282 ``set_use_hook_mlp_in``.
283 """
284 if not model.cfg.use_attn_result: 284 ↛ 286line 284 didn't jump to line 286 because the condition on line 284 was always true
285 model.set_use_attn_result(True)
286 if not model.cfg.use_split_qkv_input: 286 ↛ 288line 286 didn't jump to line 288 because the condition on line 286 was always true
287 model.set_use_split_qkv_input(True)
288 if not model.cfg.use_hook_mlp_in: 288 ↛ exitline 288 didn't return from function '_ensure_edge_hook_flags' because the condition on line 288 was always true
289 model.set_use_hook_mlp_in(True)
292@contextmanager
293def _edge_hook_flags(model: Any) -> Iterator[None]:
294 """Enable the Bridge flags an edge sweep needs, then restore the caller's state.
296 ``attn.hook_result``, the split ``attn.hook_q_input``/``hook_k_input``/
297 ``hook_v_input``, and ``hook_mlp_in`` all exist on the Bridge unconditionally
298 but only fire when their owning flag (``cfg.use_attn_result`` /
299 ``cfg.use_split_qkv_input`` / ``cfg.use_hook_mlp_in``) is on, so an edge sweep
300 must enable all three before caching or the writer- and reader-side hook
301 points it needs never populate.
303 ``use_split_qkv_input`` is mutually exclusive with ``use_attn_in``, so a
304 caller who arrives with ``use_attn_in`` on would otherwise trip the
305 exclusivity error. This turns ``use_attn_in`` off before enabling the split
306 input, and restores it after ``use_split_qkv_input`` has been turned back off.
308 Invariant: the caller's flag state (``use_attn_result``,
309 ``use_split_qkv_input``, ``use_hook_mlp_in``, ``use_attn_in``) is unchanged on
310 return, including when the body raises. Restoration runs in a ``finally``
311 block so a raise mid-sweep -- or in the caller's own later code -- cannot
312 leave the per-head tensors materialized on the model.
314 Memory caveat: enabling ``use_attn_result``/``use_split_qkv_input`` makes
315 every cached per-head tensor ``[batch, seq, n_heads, d_model]`` instead of
316 the summed ``[batch, seq, d_model]`` residual. That is fine on a model the
317 size of gpt2-small; it does not scale to models with many heads or layers.
319 Args:
320 model: A ``TransformerBridge`` (or compatible) exposing ``cfg`` and
321 ``set_use_attn_result``/``set_use_split_qkv_input``/
322 ``set_use_hook_mlp_in``/``set_use_attn_in``.
323 """
324 cfg = model.cfg
325 saved_attn_result = cfg.use_attn_result
326 saved_split_qkv_input = cfg.use_split_qkv_input
327 saved_hook_mlp_in = cfg.use_hook_mlp_in
328 saved_attn_in = bool(getattr(cfg, "use_attn_in", False))
329 try:
330 # use_attn_in and use_split_qkv_input are mutually exclusive; clear
331 # use_attn_in first so enabling the split input cannot raise.
332 if saved_attn_in:
333 model.set_use_attn_in(False)
334 if not cfg.use_attn_result:
335 model.set_use_attn_result(True)
336 if not cfg.use_split_qkv_input: 336 ↛ 338line 336 didn't jump to line 338 because the condition on line 336 was always true
337 model.set_use_split_qkv_input(True)
338 if not cfg.use_hook_mlp_in:
339 model.set_use_hook_mlp_in(True)
340 yield
341 finally:
342 model.set_use_attn_result(saved_attn_result)
343 model.set_use_split_qkv_input(saved_split_qkv_input)
344 model.set_use_hook_mlp_in(saved_hook_mlp_in)
345 # Re-enabling use_attn_in requires use_split_qkv_input already off; the
346 # line above restored it, and a caller with use_attn_in on cannot also
347 # have had use_split_qkv_input on, so this cannot trip the exclusivity.
348 if saved_attn_in:
349 model.set_use_attn_in(True)
352def _check_required_hooks(
353 cache: GradientCache, required_names: Sequence[str], graph: str, hint: str
354) -> None:
355 """Raise if any of ``required_names`` is absent from ``cache.activations``.
357 Shared by every granularity's graph-construction step, so a cache built
358 with too narrow a ``names_filter`` -- or one produced while a required
359 Bridge flag was off -- fails loudly instead of silently producing a
360 truncated graph.
361 """
362 missing = [name for name in required_names if name not in cache.activations]
363 if missing:
364 raise ValueError(
365 f"{graph} requires hook points missing from the cache: "
366 + ", ".join(missing)
367 + f". {hint}"
368 )
371def enumerate_nodes(model: Any, cache: GradientCache) -> list[Node]:
372 """Enumerate the full node-granularity graph from the Bridge hook graph.
374 The graph is *explicit*: for ``n_layers`` layers it always contains the embed
375 write, every attention head's output, and every layer's MLP output, at every
376 sequence position. Sequence length and head count are read from the cached
377 tensor shapes; ``n_layers`` from ``model.cfg``.
379 Args:
380 model: A ``TransformerBridge`` (or compatible) exposing ``cfg.n_layers``.
381 cache: A :class:`GradientCache` holding at least the required hook points.
383 Returns:
384 The node list, ordered embed-then-layerwise for deterministic ranking.
386 Raises:
387 ValueError: if any required hook point is absent from ``cache`` — the
388 graph is never silently truncated.
389 """
390 n_layers = int(model.cfg.n_layers)
391 _check_required_hooks(
392 cache,
393 _required_hook_names(n_layers),
394 "node graph",
395 "Cache with a names_filter that keeps hook_embed, blocks.*.attn.hook_z, "
396 "and blocks.*.hook_mlp_out.",
397 )
399 seq_len = cache.activations["hook_embed"].shape[1]
401 nodes: list[Node] = [Node(kind="embed", position=position) for position in range(seq_len)]
402 for layer in range(n_layers):
403 n_heads = cache.activations[f"blocks.{layer}.attn.hook_z"].shape[2]
404 for position in range(seq_len):
405 for head in range(n_heads):
406 nodes.append(Node(kind="attn_head_out", layer=layer, head=head, position=position))
407 nodes.append(Node(kind="mlp_out", layer=layer, position=position))
408 return nodes
411def _assert_edges_unique(edges: Sequence[tuple[Node, Node]]) -> None:
412 """Raise if any writer -> reader pair appears more than once in ``edges``.
414 A writer feeding two distinct readers (a head's output feeding both the
415 next layer's attention input and this layer's MLP input, say) is two
416 edges; this guards the enumeration against a construction bug that
417 collapses or duplicates a single ``(writer, reader)`` pair instead.
418 """
419 seen: set[tuple[Node, Node]] = set()
420 for edge in edges:
421 if edge in seen:
422 raise ValueError(f"edge {edge} enumerated more than once")
423 seen.add(edge)
426def _required_edge_reader_hook_names(n_layers: int) -> list[str]:
427 """The reader hook points edge enumeration additionally requires.
429 ``_required_hook_names(..., granularity="edge")`` covers the per-head
430 attention hooks a writer/reader pair into or out of a head needs. Edge
431 enumeration reads two reader points those miss:
433 - each layer's MLP entry, ``attn.hook_mlp_in``'s layer-level sibling
434 ``hook_mlp_in``, gated on ``cfg.use_hook_mlp_in`` the same way the per-head
435 hooks are gated on their own flags, and
436 - the terminal ``blocks.{n_layers-1}.hook_resid_post``, where the logits
437 reader takes its gradient. This final residual hook fires unconditionally,
438 so no Bridge flag gates it.
439 """
440 names = [f"blocks.{layer}.hook_mlp_in" for layer in range(n_layers)]
441 names.append(f"blocks.{n_layers - 1}.hook_resid_post")
442 return names
445def _edge_hook_names(n_layers: int) -> list[str]:
446 """Every hook point an edge-granularity sweep must cache.
448 The per-head attention hooks from ``_required_hook_names(..., granularity="edge")``
449 plus each layer's MLP-entry reader hook and the terminal logits reader hook.
450 """
451 return _required_hook_names(n_layers, granularity="edge") + _required_edge_reader_hook_names(
452 n_layers
453 )
456def enumerate_edges(model: Any, cache: GradientCache) -> list[tuple[Node, Node]]:
457 """Enumerate every writer -> reader edge in the residual-stream graph.
459 At a fixed sequence position, the residual stream is a running sum: a
460 reader (a head's split Q/K/V input, a layer's MLP entry, or the terminal
461 logits readout) is fed by every writer (the embed write, every attention
462 head's output, every layer's MLP output) that precedes it. Building the
463 graph position-by-position tracks which writers are "available" so far and
464 connects each new reader to all of them, then adds that layer's writers to
465 the available set before moving on -- so a writer never edges to a reader
466 upstream of it, and a writer feeding both a direct edge and a through-MLP
467 edge produces two distinct ``(u, v)`` pairs rather than one summed together.
469 A terminal ``logits`` reader (read at the final ``hook_resid_post``) closes
470 the graph: after the per-layer loop every remaining writer -- including the
471 final layer's MLP output, which no per-layer reader sees -- edges to it. That
472 edge carries the writer's direct skip-connection contribution to the metric,
473 so a writer's aggregate over its outgoing edges equals its direct node score.
475 On ``cfg.parallel_attn_mlp`` models (Pythia, GPT-J, Falcon, Phi) the MLP
476 reads the layer input, not the post-attention residual, so a layer's own
477 heads are not writers into that layer's MLP; those same-layer head->mlp_in
478 edges are dropped while the heads still feed later readers and the logits
479 reader.
481 Args:
482 model: A ``TransformerBridge`` (or compatible) exposing ``cfg.n_layers``
483 and, optionally, ``cfg.parallel_attn_mlp``.
484 cache: A :class:`GradientCache` holding at least the required hook points
485 for edge granularity.
487 Returns:
488 The edge list as ``(writer, reader)`` node pairs; no pair repeats.
490 Raises:
491 ValueError: if any required hook point is absent from ``cache`` -- the
492 graph is never silently truncated.
493 """
494 n_layers = int(model.cfg.n_layers)
495 _check_required_hooks(
496 cache,
497 _edge_hook_names(n_layers),
498 "edge graph",
499 "Cache with a names_filter that keeps the edge-granularity hook set: "
500 "hook_embed, blocks.*.attn.hook_z, blocks.*.hook_mlp_out, "
501 "blocks.*.attn.hook_result, blocks.*.attn.hook_q_input, "
502 "blocks.*.attn.hook_k_input, blocks.*.attn.hook_v_input, "
503 "blocks.*.hook_mlp_in, and blocks.{n_layers-1}.hook_resid_post.",
504 )
506 parallel_attn_mlp = bool(getattr(model.cfg, "parallel_attn_mlp", False))
508 seq_len = cache.activations["hook_embed"].shape[1]
509 edges: list[tuple[Node, Node]] = []
511 for position in range(seq_len):
512 available: list[Node] = [Node(kind="embed", position=position)]
513 for layer in range(n_layers):
514 n_heads = cache.activations[f"blocks.{layer}.attn.hook_z"].shape[2]
516 attn_reader_kinds: tuple[NodeKind, NodeKind, NodeKind] = (
517 "q_input",
518 "k_input",
519 "v_input",
520 )
521 attn_readers = [
522 Node(kind=kind, layer=layer, head=head, position=position)
523 for kind in attn_reader_kinds
524 for head in range(n_heads)
525 ]
526 for reader in attn_readers:
527 edges.extend((writer, reader) for writer in available)
529 layer_heads = [
530 Node(kind="attn_head_out", layer=layer, head=head, position=position)
531 for head in range(n_heads)
532 ]
534 mlp_reader = Node(kind="mlp_in", layer=layer, position=position)
535 if parallel_attn_mlp:
536 # The MLP reads the layer input, not the post-attention residual,
537 # so this layer's heads are not writers into its MLP. They still
538 # become available to later readers and the logits reader.
539 edges.extend((writer, mlp_reader) for writer in available)
540 available = available + layer_heads
541 else:
542 available = available + layer_heads
543 edges.extend((writer, mlp_reader) for writer in available)
545 available = available + [Node(kind="mlp_out", layer=layer, position=position)]
547 logits_reader = Node(kind="logits", layer=n_layers - 1, position=position)
548 edges.extend((writer, logits_reader) for writer in available)
550 _assert_edges_unique(edges)
551 return edges
554def _as_predicate(names_filter: NamesFilter) -> Callable[[str], bool]:
555 if names_filter is None: 555 ↛ 556line 555 didn't jump to line 556 because the condition on line 555 was never true
556 return lambda name: True
557 if isinstance(names_filter, str): 557 ↛ 558line 557 didn't jump to line 558 because the condition on line 557 was never true
558 target = names_filter
559 return lambda name: name == target
560 if callable(names_filter): 560 ↛ 561line 560 didn't jump to line 561 because the condition on line 560 was never true
561 return names_filter
562 wanted = set(names_filter)
563 return lambda name: name in wanted
566def cache_activation_and_gradient(
567 model: Any,
568 tokens: torch.Tensor,
569 metric_fn: MetricFn,
570 names_filter: NamesFilter = None,
571 compute_gradient: bool = True,
572) -> GradientCache:
573 """Run one forward and capture activations plus (optionally) their metric-gradients.
575 Registers a forward hook and a backward hook at each cached point, runs one
576 grad-enabled forward, then drives a single backward with
577 :func:`torch.autograd.grad` to fire the backward hooks.
578 ``run_with_cache(..., incl_bwd=True)`` only backpropagates the model's own
579 scalar output, so a custom (non-scalar-output) metric such as a logit-diff
580 needs the backward driven here.
582 Gradients come from the backward hooks, not from ``.grad`` on the cached
583 tensors. ``TransformerBridge`` reshapes the tensor handed to a forward hook at
584 a converted point (``attn.hook_z``, ``hook_q/k/v``, ``hook_attn_out``), so that
585 tensor is a view the model's forward never consumes: ``retain_grad()`` on it is
586 inert and its ``.grad`` stays ``None``. A backward hook goes through the same
587 conversion and delivers the real gradient in canonical shape. Driving the
588 backward with :func:`torch.autograd.grad` instead of ``metric.backward()``
589 keeps it off every parameter's ``.grad`` buffer — no caller grads clobbered, no
590 model-sized buffer allocated.
592 With ``compute_gradient=False`` the backward is skipped entirely: only forward
593 hooks are registered, no backward is driven, and every cached point's gradient
594 is ``None``. Callers that read activations only (the clean pass of
595 :func:`attribution_patch`, which pairs these with the *corrupt* run's gradients)
596 use this to run a plain forward instead of a needless forward + backward.
598 Args:
599 model: A ``TransformerBridge`` (or compatible) exposing ``cfg.n_layers``,
600 ``hook_dict``, and the ``hooks()`` context manager.
601 tokens: Input token ids for a single forward pass.
602 metric_fn: Maps the model logits to a scalar to differentiate.
603 names_filter: Restricts which hook points are cached (and have gradients
604 retained). ``None`` (the default) caches the node-granularity hook set —
605 ``hook_embed`` plus each layer's ``attn.hook_z`` and ``hook_mlp_out``;
606 pass an explicit filter to cache any hook point outside that set. On a
607 real Bridge, ``None`` cannot mean "every hook point": the gated points
608 (``hook_mlp_in``, ``attn.hook_result``, the split-QKV inputs) that
609 ``hook_dict`` exposes raise in ``add_hook`` unless their ``set_use_*``
610 flag is on.
611 compute_gradient: When ``True`` (default) capture gradients via backward
612 hooks. When ``False`` run an activation-only forward and leave every
613 gradient ``None``.
615 Returns:
616 A :class:`GradientCache` with per-hook activations, and gradients when
617 ``compute_gradient`` is ``True`` (all ``None`` otherwise).
618 """
619 if compute_gradient and not torch.is_grad_enabled(): 619 ↛ 620line 619 didn't jump to line 620 because the condition on line 619 was never true
620 raise ValueError(
621 "cache_activation_and_gradient needs autograd, but gradient tracking "
622 "is off (torch.no_grad(), set_grad_enabled(False), or inference mode)."
623 )
625 if names_filter is None:
626 # Default to the node-granularity hook set rather than every hook point: a
627 # real Bridge's hook_dict holds gated points (hook_mlp_in, attn.hook_result,
628 # the split-QKV inputs) that add_hook rejects unless their set_use_* flag is
629 # on, so caching "everything" raises. Pass an explicit filter to reach them.
630 names_filter = _required_hook_names(int(model.cfg.n_layers))
631 predicate = _as_predicate(names_filter)
632 names = [name for name in model.hook_dict if predicate(name)]
633 if not names: 633 ↛ 634line 633 didn't jump to line 634 because the condition on line 633 was never true
634 raise ValueError("names_filter matched no hook points")
636 live: dict[str, torch.Tensor] = {}
637 activations: dict[str, torch.Tensor] = {}
638 gradients: dict[str, Optional[torch.Tensor]] = {}
640 def make_fwd_hook(name: str) -> Callable[..., None]:
641 def hook(tensor: torch.Tensor, *, hook: Any) -> None:
642 del hook
643 if isinstance(tensor, torch.Tensor): 643 ↛ 646line 643 didn't jump to line 646 because the condition on line 643 was always true
644 live[name] = tensor
645 activations[name] = tensor.detach().clone()
646 return None
648 return hook
650 def make_bwd_hook(name: str) -> Callable[..., None]:
651 def hook(grad: torch.Tensor, *, hook: Any) -> None:
652 del hook
653 if isinstance(grad, torch.Tensor): 653 ↛ 655line 653 didn't jump to line 655 because the condition on line 653 was always true
654 gradients[name] = grad.detach().clone()
655 return None
657 return hook
659 fwd_hooks = [(name, make_fwd_hook(name)) for name in names]
661 if not compute_gradient:
662 with model.hooks(fwd_hooks=fwd_hooks):
663 logits = model(tokens)
664 metric = metric_fn(logits)
665 if metric.dim() != 0: 665 ↛ 666line 665 didn't jump to line 666 because the condition on line 665 was never true
666 raise ValueError(
667 f"metric_fn must return a scalar tensor, got shape {tuple(metric.shape)}"
668 )
669 return GradientCache(
670 activations=activations,
671 gradients={name: None for name in activations},
672 metric=metric.detach(),
673 )
675 bwd_hooks = [(name, make_bwd_hook(name)) for name in names]
677 with model.hooks(fwd_hooks=fwd_hooks, bwd_hooks=bwd_hooks):
678 logits = model(tokens)
679 metric = metric_fn(logits)
680 if metric.dim() != 0: 680 ↛ 681line 680 didn't jump to line 681 because the condition on line 680 was never true
681 raise ValueError(
682 f"metric_fn must return a scalar tensor, got shape {tuple(metric.shape)}"
683 )
684 captured_names = [name for name in names if name in live]
685 # torch.autograd.grad only *drives* the backward; the gradients we keep are
686 # the converted-shape ones the backward hooks write into `gradients`. Unlike
687 # metric.backward() it touches no parameter `.grad` buffer.
688 returned = (
689 torch.autograd.grad(
690 metric,
691 inputs=[live[name] for name in captured_names],
692 allow_unused=True,
693 retain_graph=False,
694 )
695 if captured_names
696 else ()
697 )
699 # The most-upstream cached point's own backward hook never fires — nothing
700 # requested is upstream of it, so the backward stops at it — but its cached
701 # tensor is on-path and unconverted, so torch.autograd.grad returns that
702 # gradient directly. Backfill any point the hooks missed from that return.
703 for name, grad in zip(captured_names, returned):
704 if gradients.get(name) is None:
705 gradients[name] = None if grad is None else grad.detach().clone()
707 return GradientCache(activations=activations, gradients=gradients, metric=metric.detach())
710def _node_effects(
711 clean_cache: GradientCache,
712 corrupt_cache: GradientCache,
713 nodes: Sequence[Node],
714) -> dict[Node, float]:
715 """Score every node with the first-order attribution ``(a_clean - a_corrupt) . g``.
717 The gradient ``g`` is taken from the corrupt cache (denoising convention). For
718 each node the feature dimension (``d_model``, or ``d_head`` for an attention
719 head) is contracted at the node's position/head, giving one signed scalar.
720 """
721 scores: dict[Node, float] = {}
722 for node in nodes:
723 name = node.hook_name
724 grad = corrupt_cache.gradients.get(name)
725 if grad is None: 725 ↛ 726line 725 didn't jump to line 726 because the condition on line 725 was never true
726 raise ValueError(
727 f"node {node} reads {name!r}, but the corrupt cache holds no gradient "
728 "there; cache with a names_filter that retains this hook point."
729 )
730 delta = clean_cache.activations[name] - corrupt_cache.activations[name]
731 contribution = delta * grad
732 if node.kind == "attn_head_out":
733 value = contribution[0, node.position, node.head].sum()
734 else:
735 value = contribution[0, node.position].sum()
736 scores[node] = float(value)
737 return scores
740def _writer_hook_name(node: Node) -> str:
741 """The residual-stream hook point holding a writer node's own contribution.
743 Distinct from ``Node.hook_name``: an ``attn_head_out`` node's ``hook_name``
744 resolves to ``attn.hook_z``, the pre-``hook_result`` value node granularity
745 scores. An edge's writer contribution must instead be measured in the same
746 ``d_model`` space a reader's gradient lives in, which is ``attn.hook_result``
747 -- the per-head decomposition of the head's contribution after it is
748 projected into the residual stream.
749 """
750 if node.kind == "embed":
751 return "hook_embed"
752 if node.kind == "attn_head_out":
753 return f"blocks.{node.layer}.attn.hook_result"
754 if node.kind == "mlp_out": 754 ↛ 756line 754 didn't jump to line 756 because the condition on line 754 was always true
755 return f"blocks.{node.layer}.hook_mlp_out"
756 raise ValueError(f"{node.kind} is a reader kind and has no writer contribution")
759def _edge_effects(
760 clean_cache: GradientCache,
761 corrupt_cache: GradientCache,
762 edges: Sequence[tuple[Node, Node]],
763) -> dict[tuple[Node, Node], float]:
764 """Score every edge with ``(a_clean[u] - a_corrupt[u]) . d(metric)/d(input of v)``.
766 Mirrors ``_node_effects``: the delta is the writer's own residual
767 contribution (clean minus corrupt cache), dotted with the reader's
768 corrupt-run gradient -- the same denoising convention ``_node_effects``
769 uses. Unlike a node score, the delta and the gradient are read from two
770 different hook points (the writer's and the reader's), since an edge
771 measures how much of one component's output reaches another component's
772 input. A ``logits`` reader takes its gradient at the final
773 ``hook_resid_post`` and contracts over ``d_model`` at its position, the same
774 shape path as an ``mlp_in`` reader.
776 Two structural facts keep this off a per-edge recompute: a writer hook's
777 clean-minus-corrupt delta is the same tensor for every edge that writer
778 feeds, and every edge into one reader shares that reader's gradient vector.
779 Each writer delta is therefore computed once, and each reader's incoming
780 edges are scored with a single batched matrix-vector product over the
781 stacked writer deltas -- reducing to the same per-edge scalars a
782 ``float((delta_vec * grad_vec).sum())`` per edge would, reader gradient
783 reused across all of that reader's edges.
784 """
785 scores: dict[tuple[Node, Node], float] = {}
786 writer_deltas: dict[str, torch.Tensor] = {}
788 def writer_delta_vec(writer: Node) -> torch.Tensor:
789 name = _writer_hook_name(writer)
790 delta = writer_deltas.get(name)
791 if delta is None:
792 delta = clean_cache.activations[name] - corrupt_cache.activations[name]
793 writer_deltas[name] = delta
794 if writer.kind == "attn_head_out":
795 return delta[0, writer.position, writer.head]
796 return delta[0, writer.position]
798 writers_by_reader: dict[Node, list[Node]] = {}
799 reader_order: list[Node] = []
800 for writer, reader in edges:
801 bucket = writers_by_reader.get(reader)
802 if bucket is None:
803 writers_by_reader[reader] = bucket = []
804 reader_order.append(reader)
805 bucket.append(writer)
807 for reader in reader_order:
808 reader_name = reader.hook_name
809 grad = corrupt_cache.gradients.get(reader_name)
810 writers = writers_by_reader[reader]
811 if grad is None: 811 ↛ 812line 811 didn't jump to line 812 because the condition on line 811 was never true
812 raise ValueError(
813 f"edge {(writers[0], reader)} reads its gradient at {reader_name!r}, but "
814 "the corrupt cache holds none there; cache with a names_filter that "
815 "retains this hook point."
816 )
817 if reader.kind in ("q_input", "k_input", "v_input"):
818 grad_vec = grad[0, reader.position, reader.head]
819 else:
820 grad_vec = grad[0, reader.position]
821 delta_matrix = torch.stack([writer_delta_vec(writer) for writer in writers])
822 edge_values = delta_matrix @ grad_vec
823 for writer, value in zip(writers, edge_values.tolist()):
824 scores[(writer, reader)] = value
825 return scores
828def _aggregate_edge_scores_to_writer_nodes(
829 edge_scores: dict[tuple[Node, Node], float],
830) -> dict[Node, float]:
831 """Sum each writer's outgoing edge scores into that writer's aggregate node score.
833 A writer's aggregate is the sum of its effects along every edge it feeds.
834 enumerate_edges gives every writer an edge to the terminal logits reader, so
835 the aggregate includes the writer's direct skip-connection contribution to
836 the metric -- the part of its residual-stream write that no intermediate
837 component reads, only the final readout does -- and therefore equals the
838 quantity a node-granularity sweep measures directly at the writer's own hook
839 point. Every writer has at least that one outgoing edge, including the final
840 layer's MLP output, whose only reader is the logits terminal, so no writer is
841 missing from the aggregate.
842 """
843 totals: dict[Node, float] = {}
844 for (writer, _reader), score in edge_scores.items():
845 totals[writer] = totals.get(writer, 0.0) + score
846 return totals
849def attribution_patch(
850 model: Any,
851 clean: torch.Tensor,
852 corrupt: torch.Tensor,
853 metric_fn: MetricFn,
854 config: EdgeAttributionConfig = EdgeAttributionConfig(),
855) -> AttributionResult:
856 """Estimate every component's causal effect on ``metric_fn`` in two forwards + one backward.
858 For each clean/corrupt pair this runs a clean forward (for ``a_clean``) and a
859 corrupt forward whose backward hooks capture ``g = d(metric)/d(a)`` (for
860 ``a_corrupt`` and its gradient). At node granularity (``config.granularity ==
861 "node"``) each node is scored with the first-order Taylor estimate
862 ``effect(node) = (a_clean - a_corrupt) . g``. At edge granularity
863 (``config.granularity == "edge"``) each writer -> reader edge is scored with
864 ``effect(edge) = (a_clean[writer] - a_corrupt[writer]) . d(metric)/d(input of
865 reader)``, and ``node_scores`` holds each writer's aggregate effect (the sum
866 of its outgoing edge scores).
868 Sign/direction convention (denoising form): gradients are taken on the *corrupt*
869 run and the estimate points *toward* the clean activation, so a positive score
870 means patching that node from corrupt toward clean moves the metric in the
871 positive direction. An oracle-parity test maps this convention onto a pinned
872 reference rather than assuming the two agree.
874 Dataset averaging: ``clean``/``corrupt`` may hold a batch of prompt pairs. Each
875 pair is scored independently (per-example forward/backward, so its own
876 reconstruction identity holds) and per-node (or per-edge) scores are averaged
877 across the batch before ranking.
879 The model and every submodule must be in evaluation mode. Separate clean and
880 corrupt forwards cannot produce meaningful activation differences if stochastic
881 training layers such as dropout remain active.
883 Args:
884 model: A ``TransformerBridge`` (or compatible) exposing ``cfg.n_layers``,
885 ``hook_dict``, and ``hooks()``.
886 clean: Clean token ids, shape ``[batch, seq]``.
887 corrupt: Corrupt token ids, shape ``[batch, seq]``, paired row-by-row with
888 ``clean``.
889 metric_fn: Maps single-example logits to a scalar to differentiate.
890 config: Sweep configuration. Node and edge granularity are both
891 supported with plain attribution (``ig_steps=1``); ``ig_steps>1``
892 raises at construction.
894 Returns:
895 An :class:`AttributionResult` whose ``node_scores`` are averaged over the
896 batch. For an edge-granularity sweep, ``edge_scores`` is populated too and
897 ``node_scores`` is the per-writer aggregate of those edge scores. The
898 graph's terminal logits reader gives every writer an edge carrying its
899 direct skip-connection contribution to the metric, so this aggregate
900 equals the quantity a node-granularity sweep on the same model returns
901 (including for the final layer's MLP output, whose only outgoing edge is
902 the one to the logits reader).
904 Raises:
905 ValueError: if ``clean``/``corrupt`` are not 2D, hold a different number of
906 pairs, a pair tokenizes to different lengths (activations must align
907 position-by-position), or the model or one of its submodules is in
908 training mode.
909 """
910 if clean.ndim != 2 or corrupt.ndim != 2: 910 ↛ 911line 910 didn't jump to line 911 because the condition on line 910 was never true
911 raise ValueError(
912 "attribution_patch expects 2D [batch, seq] token tensors, got clean "
913 f"{tuple(clean.shape)} and corrupt {tuple(corrupt.shape)}"
914 )
915 if clean.shape[0] != corrupt.shape[0]:
916 raise ValueError(
917 "clean and corrupt must hold the same number of prompt pairs, got "
918 f"{clean.shape[0]} and {corrupt.shape[0]}"
919 )
920 if clean.shape[1] != corrupt.shape[1]:
921 raise ValueError(
922 "each clean/corrupt pair must tokenize to the same length; got clean "
923 f"length {clean.shape[1]} and corrupt length {corrupt.shape[1]}. "
924 "Attribution patching aligns activations position-by-position."
925 )
927 require_eval_mode(model, operation="attribution_patch()")
929 batch = int(clean.shape[0])
930 n_layers = int(model.cfg.n_layers)
932 if config.granularity == "edge":
933 hook_names = _edge_hook_names(n_layers)
934 edge_totals: dict[tuple[Node, Node], float] = {}
936 # Scope the per-head hook-flag mutation to the caching loop so the
937 # caller's flag state is restored on both the normal and the error path.
938 with _edge_hook_flags(model):
939 for index in range(batch):
940 clean_cache = cache_activation_and_gradient(
941 model,
942 clean[index : index + 1],
943 metric_fn,
944 names_filter=hook_names,
945 compute_gradient=False,
946 )
947 corrupt_cache = cache_activation_and_gradient(
948 model, corrupt[index : index + 1], metric_fn, names_filter=hook_names
949 )
950 edges = enumerate_edges(model, corrupt_cache)
951 for edge, score in _edge_effects(clean_cache, corrupt_cache, edges).items():
952 edge_totals[edge] = edge_totals.get(edge, 0.0) + score
954 edge_scores = {edge: total / batch for edge, total in edge_totals.items()}
955 node_scores = _aggregate_edge_scores_to_writer_nodes(edge_scores)
956 return AttributionResult(node_scores=node_scores, edge_scores=edge_scores)
958 node_hook_names = _required_hook_names(n_layers)
959 totals: dict[Node, float] = {}
961 for index in range(batch):
962 clean_cache = cache_activation_and_gradient(
963 model,
964 clean[index : index + 1],
965 metric_fn,
966 names_filter=node_hook_names,
967 compute_gradient=False,
968 )
969 corrupt_cache = cache_activation_and_gradient(
970 model, corrupt[index : index + 1], metric_fn, names_filter=node_hook_names
971 )
972 nodes = enumerate_nodes(model, corrupt_cache)
973 for node, score in _node_effects(clean_cache, corrupt_cache, nodes).items():
974 totals[node] = totals.get(node, 0.0) + score
976 node_scores = {node: total / batch for node, total in totals.items()}
977 return AttributionResult(node_scores=node_scores)