|
1 | | -"""Integration tests for the Direct Logit Attribution tool on a TransformerBridge. |
| 1 | +"""Integration tests for the Direct Logit Attribution tool. |
2 | 2 |
|
3 | | -DLA decomposes the residual-stream part of a logit (or logit difference) via the |
4 | | -unembedding direction ``W_U[:, token]``. The unembedding bias ``b_U`` is a |
5 | | -per-token constant that no component produces, so a complete decomposition |
6 | | -reconstructs ``logit - b_U`` (and, for a difference, ``logit_diff - (b_U[c] - |
7 | | -b_U[w])``), not the raw logit. We assert that invariant against the bridge's own |
8 | | -forward-pass logits — the point of issue #1263 is that DLA must be correct on a |
9 | | -``TransformerBridge`` (compatibility mode), not only ``HookedTransformer``. |
| 3 | +DLA decomposes the part of a logit that comes from the residual stream via the |
| 4 | +unembedding *direction* ``W_U[:, token]``. The unembedding *bias* ``b_U`` is a |
| 5 | +per-token constant that no component produces, so the exact correctness |
| 6 | +invariant is:: |
10 | 7 |
|
11 | | -Uses distilgpt2 (CI-cached), matching test_analysis_methods.py. |
| 8 | + sum(component DLA for token) + b_U[token] == logit[token] |
| 9 | +
|
| 10 | +and, for a difference of two tokens, the two bias terms do **not** generally |
| 11 | +cancel (gpt2's folded ``ln_final`` bias makes them differ), so:: |
| 12 | +
|
| 13 | + sum(component DLA, correct vs incorrect) == logit_diff - (b_U[c] - b_U[i]) |
| 14 | +
|
| 15 | +We assert these for ``HookedTransformer`` and for ``TransformerBridge`` |
| 16 | +(compatibility mode) — the latter is the reason issue #1263 exists. |
| 17 | +
|
| 18 | +These tests load gpt2 (cached), so they live in ``integration/`` per |
| 19 | +``tests/AGENTS.md``. |
12 | 20 | """ |
13 | 21 |
|
14 | 22 | import pytest |
15 | | -import torch |
16 | | - |
17 | | -from transformer_lens.tools.analysis import dla |
18 | 23 |
|
19 | 24 | PROMPT = "The Eiffel Tower is in the city of" |
20 | 25 | CORRECT = " Paris" |
21 | | -WRONG = " London" |
22 | | - |
23 | | - |
24 | | -def _last_token_logits(bridge, prompt): |
25 | | - """Final-position logits from the bridge's own forward pass.""" |
26 | | - logits, _ = bridge.run_with_cache(prompt) |
27 | | - if logits.ndim == 3: # [batch, pos, vocab] |
28 | | - logits = logits[0] |
29 | | - return logits[-1] # [vocab] |
30 | | - |
31 | | - |
32 | | -class TestDirectLogitAttributionCorrectness: |
33 | | - """The component contributions must reconstruct the model's real logits.""" |
34 | | - |
35 | | - def test_decompose_reconstructs_logit_difference(self, distilgpt2_bridge_compat): |
36 | | - bridge = distilgpt2_bridge_compat |
37 | | - c, w = bridge.to_single_token(CORRECT), bridge.to_single_token(WRONG) |
38 | | - scores, labels = dla(bridge, [PROMPT], torch.tensor([[c, w]])) |
39 | | - |
40 | | - logits = _last_token_logits(bridge, PROMPT) |
41 | | - expected = (logits[c] - logits[w]) - (bridge.b_U[c] - bridge.b_U[w]) |
42 | | - assert scores.sum().item() == pytest.approx(expected.item(), abs=1e-2) |
43 | | - assert len(scores) == len(labels) |
44 | | - |
45 | | - def test_decompose_reconstructs_single_token_logit(self, distilgpt2_bridge_compat): |
46 | | - bridge = distilgpt2_bridge_compat |
47 | | - c = bridge.to_single_token(CORRECT) |
48 | | - scores, _ = dla(bridge, [PROMPT], torch.tensor([[c]])) |
49 | | - |
50 | | - logits = _last_token_logits(bridge, PROMPT) |
51 | | - expected = logits[c] - bridge.b_U[c] |
52 | | - assert scores.sum().item() == pytest.approx(expected.item(), abs=1e-2) |
53 | | - |
54 | | - def test_accumulated_last_entry_reconstructs(self, distilgpt2_bridge_compat): |
55 | | - # accumulated_resid is cumulative -> reconstruction is the LAST entry, not the sum |
56 | | - bridge = distilgpt2_bridge_compat |
57 | | - c, w = bridge.to_single_token(CORRECT), bridge.to_single_token(WRONG) |
58 | | - scores, labels = dla(bridge, [PROMPT], torch.tensor([[c, w]]), accumulated=True) |
59 | | - |
60 | | - logits = _last_token_logits(bridge, PROMPT) |
61 | | - expected = (logits[c] - logits[w]) - (bridge.b_U[c] - bridge.b_U[w]) |
62 | | - assert scores[-1].item() == pytest.approx(expected.item(), abs=1e-2) |
63 | | - assert len(scores) == len(labels) |
64 | | - |
65 | | - |
66 | | -class TestDirectLogitAttributionShape: |
67 | | - def test_decompose_labels_and_shape(self, distilgpt2_bridge_compat): |
68 | | - bridge = distilgpt2_bridge_compat |
69 | | - c = bridge.to_single_token(CORRECT) |
70 | | - scores, labels = dla(bridge, [PROMPT], torch.tensor([[c]])) |
71 | | - |
72 | | - n_layers = bridge.cfg.n_layers |
73 | | - assert scores.ndim == 1 |
74 | | - assert len(scores) == len(labels) |
75 | | - assert "embed" in labels |
76 | | - assert sum(label.endswith("attn_out") for label in labels) == n_layers |
77 | | - assert sum(label.endswith("mlp_out") for label in labels) == n_layers |
78 | | - |
79 | | - def test_batch_of_prompts_is_averaged(self, distilgpt2_bridge_compat): |
80 | | - # two identical prompts -> the batch-mean equals the single-prompt result |
81 | | - bridge = distilgpt2_bridge_compat |
82 | | - c, w = bridge.to_single_token(CORRECT), bridge.to_single_token(WRONG) |
83 | | - single, _ = dla(bridge, [PROMPT], torch.tensor([[c, w]])) |
84 | | - doubled, _ = dla(bridge, [PROMPT, PROMPT], torch.tensor([[c, w], [c, w]])) |
85 | | - assert torch.allclose(single, doubled, atol=1e-4) |
86 | | - |
87 | | - |
88 | | -class TestDirectLogitAttributionGuardsOnRealBridge: |
89 | | - """The guards must also fire on a genuine bridge, not just a mock.""" |
90 | | - |
91 | | - def test_non_compat_bridge_raises(self, distilgpt2_bridge): |
92 | | - bridge = distilgpt2_bridge # compatibility mode NOT enabled |
93 | | - c = bridge.to_single_token(CORRECT) |
| 26 | +INCORRECT = " London" |
| 27 | + |
| 28 | + |
| 29 | +def _refs(model): |
| 30 | + """Reference values: (logit_correct, logit_incorrect, b_U[c], b_U[i]).""" |
| 31 | + logits = model(PROMPT) |
| 32 | + if logits.ndim == 2: # some Bridge configs may drop the batch dim |
| 33 | + logits = logits[None] |
| 34 | + c = model.to_single_token(CORRECT) |
| 35 | + i = model.to_single_token(INCORRECT) |
| 36 | + return ( |
| 37 | + logits[0, -1, c].item(), |
| 38 | + logits[0, -1, i].item(), |
| 39 | + model.b_U[c].item(), |
| 40 | + model.b_U[i].item(), |
| 41 | + ) |
| 42 | + |
| 43 | + |
| 44 | +def _assert_complete_decomposition(model, unit): |
| 45 | + """sum(DLA) reconstructs the logit / logit-diff up to the b_U constant.""" |
| 46 | + from transformer_lens.tools.analysis import direct_logit_attribution |
| 47 | + |
| 48 | + logit_c, logit_i, bu_c, bu_i = _refs(model) |
| 49 | + |
| 50 | + diff = direct_logit_attribution( |
| 51 | + model, PROMPT, answer_tokens=CORRECT, incorrect_tokens=INCORRECT, unit=unit |
| 52 | + ) |
| 53 | + single = direct_logit_attribution(model, PROMPT, answer_tokens=CORRECT, unit=unit) |
| 54 | + |
| 55 | + # accumulated_resid ("layer") is cumulative: the last entry is the full |
| 56 | + # residual stream, so it (not the column sum) is the reconstruction. |
| 57 | + diff_total = diff.attribution[-1].sum() if unit == "layer" else diff.attribution.sum() |
| 58 | + single_total = single.attribution[-1].sum() if unit == "layer" else single.attribution.sum() |
| 59 | + |
| 60 | + assert diff_total.item() == pytest.approx((logit_c - logit_i) - (bu_c - bu_i), abs=1e-2) |
| 61 | + assert single_total.item() == pytest.approx(logit_c - bu_c, abs=1e-2) |
| 62 | + |
| 63 | + |
| 64 | +@pytest.fixture(scope="module") |
| 65 | +def gpt2_ht(): |
| 66 | + from transformer_lens import HookedTransformer |
| 67 | + |
| 68 | + return HookedTransformer.from_pretrained("gpt2", device="cpu") |
| 69 | + |
| 70 | + |
| 71 | +class TestDirectLogitAttributionHooked: |
| 72 | + """Correctness on HookedTransformer (the reference numerics).""" |
| 73 | + |
| 74 | + @pytest.mark.parametrize("unit", ["component", "layer", "head"]) |
| 75 | + def test_decomposition_reconstructs_logit(self, gpt2_ht, unit): |
| 76 | + _assert_complete_decomposition(gpt2_ht, unit) |
| 77 | + |
| 78 | + def test_component_labels_and_shape(self, gpt2_ht): |
| 79 | + from transformer_lens.tools.analysis import direct_logit_attribution |
| 80 | + |
| 81 | + res = direct_logit_attribution( |
| 82 | + gpt2_ht, PROMPT, answer_tokens=CORRECT, incorrect_tokens=INCORRECT, unit="component" |
| 83 | + ) |
| 84 | + assert res.unit == "component" |
| 85 | + assert res.attribution.shape[0] == len(res.labels) |
| 86 | + # Embedding term(s) plus each layer's attn_out and mlp_out. |
| 87 | + assert "embed" in res.labels |
| 88 | + assert sum(label.endswith("_attn_out") for label in res.labels) == gpt2_ht.cfg.n_layers |
| 89 | + assert sum(label.endswith("_mlp_out") for label in res.labels) == gpt2_ht.cfg.n_layers |
| 90 | + |
| 91 | + def test_head_labels_include_remainder(self, gpt2_ht): |
| 92 | + from transformer_lens.tools.analysis import direct_logit_attribution |
| 93 | + |
| 94 | + res = direct_logit_attribution(gpt2_ht, PROMPT, answer_tokens=CORRECT, unit="head") |
| 95 | + assert len(res.labels) == gpt2_ht.cfg.n_layers * gpt2_ht.cfg.n_heads + 1 |
| 96 | + assert res.labels[-1] == "remainder" |
| 97 | + |
| 98 | + def test_reuses_precomputed_cache(self, gpt2_ht): |
| 99 | + from transformer_lens.tools.analysis import direct_logit_attribution |
| 100 | + |
| 101 | + logit_c, _, bu_c, _ = _refs(gpt2_ht) |
| 102 | + _, cache = gpt2_ht.run_with_cache(PROMPT) |
| 103 | + res = direct_logit_attribution(gpt2_ht, answer_tokens=CORRECT, cache=cache) |
| 104 | + assert res.attribution.sum().item() == pytest.approx(logit_c - bu_c, abs=1e-2) |
| 105 | + |
| 106 | + def test_pos_none_keeps_position_axis(self, gpt2_ht): |
| 107 | + from transformer_lens.tools.analysis import direct_logit_attribution |
| 108 | + |
| 109 | + n_tokens = gpt2_ht.to_tokens(PROMPT).shape[1] |
| 110 | + res = direct_logit_attribution( |
| 111 | + gpt2_ht, PROMPT, answer_tokens=CORRECT, unit="component", pos=None |
| 112 | + ) |
| 113 | + assert res.attribution.ndim == 3 # [component, batch, pos] |
| 114 | + assert res.attribution.shape[-1] == n_tokens |
| 115 | + |
| 116 | + def test_top_returns_sorted_pairs(self, gpt2_ht): |
| 117 | + from transformer_lens.tools.analysis import direct_logit_attribution |
| 118 | + |
| 119 | + res = direct_logit_attribution( |
| 120 | + gpt2_ht, PROMPT, answer_tokens=CORRECT, incorrect_tokens=INCORRECT, unit="head" |
| 121 | + ) |
| 122 | + top = res.top(3) |
| 123 | + assert len(top) == 3 |
| 124 | + values = [v for _, v in top] |
| 125 | + assert values == sorted(values, reverse=True) |
| 126 | + |
| 127 | + |
| 128 | +class TestDirectLogitAttributionBridge: |
| 129 | + """The point of #1263: DLA must work on TransformerBridge.""" |
| 130 | + |
| 131 | + @pytest.mark.parametrize("unit", ["component", "head"]) |
| 132 | + def test_decomposition_reconstructs_logit(self, gpt2_bridge_compat, unit): |
| 133 | + _assert_complete_decomposition(gpt2_bridge_compat, unit) |
| 134 | + |
| 135 | + |
| 136 | +class TestDirectLogitAttributionBridgeGuards: |
| 137 | + """Guards required for correctness on TransformerBridge (from PR #1316). |
| 138 | +
|
| 139 | + Without compatibility mode the projection direction is wrong on a Bridge and |
| 140 | + DLA would silently return incorrect numbers — verify the explicit refusal. |
| 141 | + """ |
| 142 | + |
| 143 | + def test_non_compat_bridge_raises(self, gpt2_bridge): |
| 144 | + from transformer_lens.tools.analysis import direct_logit_attribution |
| 145 | + |
94 | 146 | with pytest.raises(ValueError, match="compatibility mode"): |
95 | | - dla(bridge, [PROMPT], torch.tensor([[c]])) |
96 | | - |
97 | | - def test_hybrid_layer_types_raises(self, distilgpt2_bridge_compat, monkeypatch): |
98 | | - bridge = distilgpt2_bridge_compat |
99 | | - monkeypatch.setattr(bridge, "layer_types", lambda: ["attn+mlp", "mamba+mlp"]) |
100 | | - c = bridge.to_single_token(CORRECT) |
101 | | - with pytest.raises(NotImplementedError, match="hybrid"): |
102 | | - dla(bridge, [PROMPT], torch.tensor([[c]])) |
| 147 | + direct_logit_attribution(gpt2_bridge, PROMPT, answer_tokens=CORRECT) |
| 148 | + |
| 149 | + |
| 150 | +class TestDirectLogitAttributionValidation: |
| 151 | + def test_invalid_unit_raises(self, gpt2_ht): |
| 152 | + from transformer_lens.tools.analysis import direct_logit_attribution |
| 153 | + |
| 154 | + with pytest.raises(ValueError, match="unit must be one of"): |
| 155 | + direct_logit_attribution(gpt2_ht, PROMPT, answer_tokens=CORRECT, unit="neuron") |
| 156 | + |
| 157 | + def test_missing_answer_tokens_raises(self, gpt2_ht): |
| 158 | + from transformer_lens.tools.analysis import direct_logit_attribution |
| 159 | + |
| 160 | + with pytest.raises(ValueError, match="answer_tokens is required"): |
| 161 | + direct_logit_attribution(gpt2_ht, PROMPT) |
| 162 | + |
| 163 | + def test_missing_input_and_cache_raises(self, gpt2_ht): |
| 164 | + from transformer_lens.tools.analysis import direct_logit_attribution |
| 165 | + |
| 166 | + with pytest.raises(ValueError, match="either `input`"): |
| 167 | + direct_logit_attribution(gpt2_ht, answer_tokens=CORRECT) |
0 commit comments