feat(attribution_patching): edge attribution (EAP) scoring on TransformerBridge - #1781
Conversation
koriyoshi2041
left a comment
There was a problem hiding this comment.
Reviewed exact head 7a436b73586455681c46705c4a7b056453d45ecc. I traced the graph in residual-stream order and checked the three core invariants: writers are only connected to downstream readers, attention writer deltas are taken in hook_result d_model space while Q/K/V reader gradients use the matching head/position index, and incoming edge scores reconstruct each reader input-delta dot gradient. The final-readout omission means edge-mode node_scores is intentionally an outgoing-edge aggregate rather than node-granularity attribution; the code and docs state that boundary explicitly.
Local focused result: 34/34 attribution-patching tests pass at this exact head. The hosted compatibility, type, format, full-coverage, benchmark, and notebook matrix is green. I did not find a correctness blocker for the scoped EAP implementation.
Edges into and out of attention heads read attn.hook_result (writer, a head's contribution before the sum into resid) and the split attn.hook_q_input/hook_k_input/hook_v_input (reader, each head's separate Q/K/V input). Both hook families exist on the Bridge unconditionally but only fire once cfg.use_attn_result and cfg.use_split_qkv_input are on. Add an edge-granularity variant of the required hook set, a helper that turns on both flags before caching, and generalize the missing-hook ValueError enumerate_nodes already raises into a shared check so any granularity's cache can be validated against it instead of duplicating the check per graph.
Add enumerate_edges(model, cache), producing every writer -> reader pair in the residual-stream graph. Extend NodeKind with the reader kinds q_input/k_input/v_input/mlp_in and extend Node's invariants and hook_name accordingly. At a fixed sequence position the residual stream is a running sum, so a reader is fed by every writer enumerated ahead of it; the graph is built position-by-position, tracking that "available" writer set. A writer feeding both a direct edge and a through-MLP edge yields two distinct writer-reader pairs rather than a summed one; a dedicated uniqueness guard enforces that invariant. Edges into the MLP additionally need the MLP-entry hook point (hook_mlp_in per layer), so edge enumeration validates its presence alongside the per-head hook set already required for edge granularity.
Wire granularity="edge" through attribution_patch: enumerate the writer -> reader edges from the cached activations and gradients, score each with (a_clean[writer] - a_corrupt[writer]) . d(metric)/d(input of reader), and populate AttributionResult.edge_scores. node_scores for an edge sweep becomes each writer's aggregate over its own outgoing edge scores rather than a direct node measurement. Remove the granularity == "edge" raise from EdgeAttributionConfig; the ig_steps > 1 raise is untouched. top_edges() ranks by absolute score and returns the ranked edge list instead of raising. Enable use_hook_mlp_in alongside the existing per-head flags so MLP-input edges have a populated reader hook.
…econstruction Add the two Risk-1 guards for edge scoring: a genuine single-edge activation patch (adding one writer's clean-minus-corrupt delta into a reader's cached input, everything else held at corrupt) matches _edge_effects' sign and, on this fully linear toy Bridge, its magnitude too; and perturbing one writer's captured contribution changes only the edges that writer feeds, leaving every other edge's score (and sibling heads/positions in the same cached tensor) untouched. Also add a sum-to-total check for the edge form: a reader's incoming edge scores sum to the same value as scoring its own cached input-delta against its own gradient directly, an identity that holds unconditionally from the residual stream's additive construction. generic_activation_patch is not used for the exact-patch check: its model: HookedTransformer parameter is enforced at runtime by this repo's jaxtyping/beartype pytest configuration, which rejects any argument that is not actually a HookedTransformer instance, including a real TransformerBridge. The patch is driven directly through the Bridge's own hooks(), the same mechanism generic_activation_patch uses internally.
…estore Add _edge_hook_flags, a context manager that enables the Bridge flags an edge sweep needs (use_attn_result, use_split_qkv_input, use_hook_mlp_in) and restores the caller's prior flag state on exit, including on error. The prior one-way helper only turned flags on, so every forward after an edge sweep kept materializing the per-head tensors, and a caller who arrived with use_attn_in set hit the use_split_qkv_input exclusivity error and was still left mutated. The context manager snapshots all four flags, clears use_attn_in before enabling the split input, and restores each flag in a finally block so a raise mid-sweep cannot leave the model mutated. Extend the edge-hook toy Bridge with use_attn_in and its setter (mutually exclusive with use_split_qkv_input) and cover enable-in-scope, restore on normal exit and on raise, already-enabled flags, and the use_attn_in caller path.
…ttn_mlp handling to edge enumeration
…hook-flag mutation
…dout position and cover multi-pair averaging and head-to-logits ranking
7a436b7 to
9fb101b
Compare
koriyoshi2041
left a comment
There was a problem hiding this comment.
Exact-head review receipt for 9fb101b42b2acf9223ac2c7425dd098e320971e5 (the approval immediately above was submitted without its intended body):
- terminal
hook_resid_postreader closes the graph, including the final MLP writer, and outgoing-edge aggregates reconstruct direct node scores; parallel_attn_mlpexcludes same-layer head → MLP edges while preserving later-reader and terminal edges;- reader-grouped batched matrix-vector scoring matches the per-edge reference;
- edge-only hook flags restore across normal and exceptional exits, including
use_attn_in.
Local exact-head result: uv run pytest tests/unit/tools/test_attribution_patching.py -q → 50 passed. Hosted compatibility, coverage, type, format, benchmark, and notebook checks are green. I found no remaining correctness blocker for the scoped EAP implementation.
|
@janmenjayap All requests resolved expertly, thank you for taking care of those. Approved and merging now |
Summary
Extends
attribution_patchingwith edge-level attribution (EAP): every residual-stream writer -> reader edge is scored from the same single gradient cache that node-level attribution already produces, with no extra forward/backward passes.granularity="edge"no longer raisesNotImplementedError;ig_steps>1(EAP-IG) still does and lands in a laterPR.
An edge
u -> vcarries writeru's contribution into readerv's input. Its score is(a_clean[u] - a_corrupt[u]) . d(metric)/d(input of v), evaluated atv's read point. Sweeping every(u, v)pair from the clean and corrupt caches yields all edge scores in one pass.What's included
hook_result(writer) and splithook_q_input/hook_k_input/hook_v_input(reader), which only fire oncecfg.use_attn_resultandcfg.use_split_qkv_inputare on. These are now turned on before caching, and the missing-hook check is generalized so any granularity's cache is validated the same way rather than duplicating it per graph.enumerate_edges(model, cache)builds every writer -> reader pair position-by-position over the residual stream's running sum, sharing(layer, position, head)keys with the activation and gradient caches.NodeKindgains reader kinds (q_input/k_input/v_input/mlp_in); a writer feeding both a direct edge and a through-MLP edge yields two distinct pairs, enforced by a uniqueness guard rather than silently summed.attribution_patch.granularity="edge"populatesAttributionResult.edge_scores;node_scoresbecomes each writer's aggregate over its own outgoing edges.top_edges()ranks by absolute score instead of raising._edge_effects' sign and (on this linear toy) magnitude; perturbing one writer's captured contribution changes only the edges it feeds and nothing else; a reader's incoming edge scores sum to the same value as scoring its cached input-delta directly.generic_activation_patchitself isn't used for the parity check — itsmodel: HookedTransformerparameter is enforced at runtime by this repo's jaxtyping/beartype test configuration, which rejects aTransformerBridgeinstance outright, so the check patches directly through the Bridge's ownhooks(), the same mechanismgeneric_activation_patchuses internally.[batch, seq, n_heads, d_model]tensors are fine ongpt2-smalland do not scale to large models.Files
transformer_lens/tools/analysis/attribution_patching.py(extended)tests/unit/tools/test_attribution_patching.py(extended)Scope / follow-ups
Edge-level EAP only,
ig_stepsstays1. The following land in follow-on PRs: ablate-outside faithfulness, integrated gradients (EAP-IG,ig_steps>1), oracle parity, and the demo notebook.Addresses part of #1742 — this PR covers edge-granularity attribution (EAP); faithfulness, EAP-IG, oracle parity, and the demo are tracked as follow-on work above.
Checklist
make unit-testgreenuv run mypy .cleanmake formatappliedgranularity="edge"producesedge_scores/top_edges;ig_steps>1still raisesNotImplementedErrorHookedTransformerreference added; all model interaction via the Bridge