Skip to content

Commit 9399fcd

Browse files
azrabano23Azra Banojlarson4
authored
Add Direct Logit Attribution tool (#1263) (#1369)
* Add Direct Logit Attribution tool (#1263) Add transformer_lens/tools/analysis/direct_logit_attribution.py, a single-call DLA analysis that decomposes a logit (or logit difference) into per-component, per-layer (logit-lens), or per-head contributions. Wraps the existing ActivationCache primitives (decompose_resid / accumulated_resid / stack_head_results / logit_attrs) and works with both HookedTransformer and TransformerBridge, since they share the cache API. Returns a DirectLogitAttribution dataclass (attribution tensor + aligned labels, plus a top(k) helper). Adds integration tests asserting the exact DLA correctness invariant on both systems: the complete decomposition reconstructs the model's real logit up to the unembedding bias b_U. Closes #1263 * Resolving conflicts between 1316 and 1369 * format fixes --------- Co-authored-by: Azra Bano <azrabano23@gmail.com> Co-authored-by: Jonah Larson <jonahalarson@comcast.net>
1 parent de181e2 commit 9399fcd

5 files changed

Lines changed: 421 additions & 257 deletions

File tree

Lines changed: 158 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,167 @@
1-
"""Integration tests for the Direct Logit Attribution tool on a TransformerBridge.
1+
"""Integration tests for the Direct Logit Attribution tool.
22
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::
107
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``.
1220
"""
1321

1422
import pytest
15-
import torch
16-
17-
from transformer_lens.tools.analysis import dla
1823

1924
PROMPT = "The Eiffel Tower is in the city of"
2025
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+
94146
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)
Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,53 @@
1-
"""Unit tests for the Direct Logit Attribution guards and argument validation.
1+
"""Unit tests for Direct Logit Attribution guards and argument validation.
22
3-
These exercise the fast-failing checks (argument validation, the
3+
These exercise the fast-failing checks (argument validation, the Bridge
44
compatibility-mode requirement, and the hybrid-architecture refusal) without
5-
loading a real model, using a ``spec``-ed mock bridge so the checks are reached
6-
before any forward pass.
5+
loading a real modelusing a ``spec``-ed mock TransformerBridge so the checks
6+
fire before any forward pass.
77
"""
88

99
from unittest.mock import MagicMock
1010

1111
import pytest
12-
import torch
1312

1413
from transformer_lens.model_bridge import TransformerBridge
15-
from transformer_lens.tools.analysis import dla
14+
from transformer_lens.tools.analysis import direct_logit_attribution
1615

1716

1817
def _mock_bridge(compatibility_mode=True, layer_types=("attn+mlp",)):
19-
"""A mock TransformerBridge that satisfies isinstance/type checks."""
18+
"""A mock TransformerBridge that satisfies isinstance checks."""
2019
bridge = MagicMock(spec=TransformerBridge)
2120
bridge.compatibility_mode = compatibility_mode
2221
bridge.layer_types.return_value = list(layer_types)
2322
return bridge
2423

2524

26-
def test_prompt_answer_length_mismatch_raises():
25+
def test_invalid_unit_raises():
2726
bridge = _mock_bridge()
28-
with pytest.raises(ValueError, match="matching row"):
29-
dla(bridge, ["a", "b"], torch.tensor([[1]]))
27+
with pytest.raises(ValueError, match="unit must be one of"):
28+
direct_logit_attribution(bridge, "hi", answer_tokens=" world", unit="neuron")
3029

3130

32-
def test_invalid_answer_columns_raises():
31+
def test_missing_answer_tokens_raises():
3332
bridge = _mock_bridge()
34-
with pytest.raises(ValueError, match="columns"):
35-
dla(bridge, ["a"], torch.tensor([[1, 2, 3]]))
33+
with pytest.raises(ValueError, match="answer_tokens is required"):
34+
direct_logit_attribution(bridge, "hi")
3635

3736

3837
def test_requires_compatibility_mode():
3938
bridge = _mock_bridge(compatibility_mode=False)
4039
with pytest.raises(ValueError, match="compatibility mode"):
41-
dla(bridge, ["a"], torch.tensor([[1]]))
40+
direct_logit_attribution(bridge, "hi", answer_tokens=" world")
4241

4342

4443
def test_rejects_hybrid_architecture():
4544
bridge = _mock_bridge(layer_types=("attn+mlp", "mamba+mlp"))
4645
with pytest.raises(NotImplementedError, match="hybrid"):
47-
dla(bridge, ["a"], torch.tensor([[1]]))
46+
direct_logit_attribution(bridge, "hi", answer_tokens=" world")
4847

4948

50-
def test_dla_is_exported_from_analysis_package():
49+
def test_direct_logit_attribution_is_exported_from_analysis_package():
5150
from transformer_lens.tools import analysis
5251

53-
assert analysis.dla is dla
52+
assert analysis.direct_logit_attribution is direct_logit_attribution
53+
assert hasattr(analysis, "DirectLogitAttribution")

transformer_lens/tools/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
including the model registry for discovering compatible HuggingFace models.
55
66
Subpackages:
7-
- analysis: Interpretability analyses such as Direct Logit Attribution
7+
- analysis: High-level interpretability analyses (e.g. Direct Logit Attribution)
88
- model_registry: Tools for discovering and documenting supported models
99
"""
1010

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
1-
"""Analysis tools for the TransformerBridge system."""
1+
"""Analysis tools for TransformerLens.
22
3-
from transformer_lens.tools.analysis.direct_logit_attribution import dla
3+
This subpackage collects high-level, single-call interpretability analyses that
4+
sit on top of the hook/cache system. They work with both ``HookedTransformer``
5+
and the newer ``TransformerBridge`` (the two share the ``ActivationCache`` API).
46
5-
__all__ = ["dla"]
7+
Tools:
8+
- direct_logit_attribution: Direct Logit Attribution (DLA) over components,
9+
layers, or attention heads.
10+
"""
11+
12+
from transformer_lens.tools.analysis.direct_logit_attribution import (
13+
DirectLogitAttribution,
14+
direct_logit_attribution,
15+
)
16+
17+
__all__ = ["DirectLogitAttribution", "direct_logit_attribution"]

0 commit comments

Comments
 (0)