Skip to content

Commit 6a9728e

Browse files
committed
feat: V2 analysis — head patching, redundancy test, seed robustness, error bars
1 parent 33f7112 commit 6a9728e

16 files changed

Lines changed: 884 additions & 348 deletions

.github/workflows/ci.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@ jobs:
1313
- name: Install dependencies
1414
run: |
1515
pip install torch --index-url https://download.pytorch.org/whl/cpu
16-
pip install numpy matplotlib pytest
16+
pip install numpy matplotlib pytest nbconvert jupyter
1717
pip install -e .
1818
- name: Run tests
19-
run: pytest tests/ -v
19+
run: pytest tests/ -v
20+
- name: Smoke-execute the analysis notebooks
21+
# These load the committed checkpoint (no training) and exercise the full
22+
# analysis pipeline, so they guard the notebooks against code drift. The
23+
# progression notebooks 01-03 train and are excluded for runtime.
24+
run: |
25+
jupyter nbconvert --to notebook --execute --inplace \
26+
notebooks/progression/04_full_gpt.ipynb \
27+
notebooks/05_attention_analysis.ipynb

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Diego Magana
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 103 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,24 @@ The short version of what I found: in this 4-layer, 4-head character model, almo
99
all of the predictive work runs through a single attention head — layer 1, head 1 —
1010
which copies the previous character into the position that predicts the next one.
1111
Two heads *look* like they do that job; only one of them actually matters, and it
12-
took intervening on the network to tell them apart.
12+
took intervening on the network to tell them apart. Retrain from a different seed
13+
and the same kind of head takes over — just not always at the same index.
1314

1415
This is the third in a series after
1516
[micrograd](https://github.com/diego-magana/micrograd) (a scalar autograd engine)
1617
and [makemore](https://github.com/diego-magana/makemore) (n-gram to WaveNet
17-
character models). The activation-patching analysis here is the one I pointed
18-
forward to at the end of makemore.
18+
character models). The patching analysis here is the one I pointed forward to at the
19+
end of makemore.
1920

2021
---
2122

2223
## What I found
2324

24-
I trained the full model to convergence and ran four analyses on it — two that just
25-
look at the network (attention patterns, the residual stream) and two that
26-
intervene on it (head ablation, activation patching). The interesting part is that
27-
looking and intervening disagree, and the disagreement is where the real result is.
25+
I trained the full model to convergence and ran six analyses on it — two that just
26+
look at the network (attention patterns, the residual stream) and four that
27+
intervene on it (head ablation, a pairwise-ablation redundancy test, residual
28+
patching, and head patching). The interesting part is that looking and intervening
29+
disagree, and the disagreement is where the real result is.
2830

2931
**The model has two heads that look like previous-token heads.** Averaging attention
3032
over 512 held-out sequences, layer 0 is mostly diffuse, layer 1 leans entirely on
@@ -36,44 +38,75 @@ attention maps they read as the same kind of head.
3638
![Mean attention maps](assets/attention_grid.png)
3739

3840
**Only one of them matters.** When I zero each head and remeasure validation loss,
39-
L1 H1 costs ≈ 0.89 nats — seven times more than any other head. L0 H3, the *other*
40-
sharp previous-token head, costs ≈ 0.04. Nearly identical attention patterns, an
41-
order of magnitude apart in how much the model actually needs them. My read is
42-
redundancy: L1 H1 carries a stronger version of the same signal one layer later, so
43-
removing L0 H3 on its own barely registers. This is the part I'd point a reviewer
44-
at first — the attention maps alone would have told me these two heads were
41+
L1 H1 costs ≈ 0.89 ± 0.02 nats — seven times more than any other head and far
42+
outside the error bar. L0 H3, the *other* sharp previous-token head, costs
43+
≈ 0.04 ± 0.00. Nearly identical attention patterns, an order of magnitude apart in
44+
how much the model actually needs them. This is the part I'd point a reviewer at
45+
first: the attention maps alone would have told me these two heads were
4546
interchangeable, and they aren't close.
4647

47-
**Activation patching shows where L1 H1's information goes.** I take a clean context,
48-
corrupt the character two positions before the end, and then patch the clean
49-
residual-stream activations back in one site at a time, measuring how much of the
50-
original prediction comes back. Through the embedding and block 0 the corrupted
51-
information sits at its own position. Then block 1 moves it forward: recovery jumps
52-
from ≈ 0.13 at the corrupted position to ≈ 0.75 at the prediction position, and the
53-
later blocks carry it the rest of the way. Block 1 is where L1 H1 lives, so this is
54-
the head's copy operation caught in the act.
48+
![Head ablation](assets/head_ablation.png)
49+
50+
**The reason L0 H3 is free is redundancy — and I tested it instead of asserting it.**
51+
If L0 H3 is cheap to remove only because L1 H1 carries a stronger copy of the same
52+
signal downstream, then it should get expensive once L1 H1 is gone. It does: ablating
53+
L0 H3 on its own costs ≈ 0.04, but ablating it once L1 H1 is *already* ablated costs
54+
≈ 0.23 — almost six times as much. L0 H3 isn't dead weight; it's a backup
55+
previous-token head whose contribution is masked by the stronger one.
5556

56-
![Activation patching recovery](assets/activation_patching.png)
57+
**Residual patching shows where the information flows.** I take a clean context,
58+
corrupt the character two positions before the end, and patch the clean
59+
residual-stream activations back in one site at a time, measuring how much of the
60+
original prediction comes back (as a contrastive logit difference, on the 189 of 256
61+
examples where the corruption actually flipped the prediction). Through the embedding
62+
and block 0 the corrupted information sits at its own position. Then block 1 moves it
63+
forward: recovery jumps from ≈ 0.24 at the corrupted position to ≈ 0.74 at the
64+
prediction position, and the later blocks carry it the rest of the way.
65+
66+
![Residual patching recovery](assets/activation_patching.png)
67+
68+
**Head patching pins it on L1 H1 directly.** Residual patching localizes the move to
69+
block 1, but block 1 has four heads and an FFN — it can't tell me *which* head does
70+
the copying. So I patch one level down: splice a single head's clean output into the
71+
corrupted run and leave everything else corrupted. Restoring L1 H1 alone recovers
72+
≈ 0.51 ± 0.03 of the prediction — five times the next head. One head out of sixteen
73+
accounts for roughly half the effect by itself. It isn't all of it, and it shouldn't
74+
be, since the previous-token signal also leaks through other paths; but I'm no longer
75+
inferring L1 H1 from "block 1 matters" plus "L1 H1 ablates hardest," I'm routing its
76+
clean output into a broken run and watching the prediction come back.
77+
78+
![Head patching](assets/head_patching.png)
5779

5880
**The prediction sharpens steadily with depth.** Reading each layer's residual stream
5981
through the final unembedding (the logit lens), top-1 next-character accuracy climbs
6082
0.06 → 0.12 → 0.21 → 0.34 → 0.51 across the four blocks, with the last block doing
61-
the most. One wrinkle I didn't expect: the embedding-level logit-lens cross-entropy
62-
is *worse* in this converged model than it was in an under-trained one, even though
63-
embedding-level accuracy is unchanged. The embeddings specialize to feed the deeper
64-
layers rather than to be read out directly, so the lens — which uses the final
65-
unembedding — reads them less faithfully. A reminder that the logit lens is a lower
66-
bound on what a layer knows, not a decoder of it.
67-
68-
Three alternative readings of the result — and the limits of each method — are at
69-
the end of the notebook.
83+
the most. The logit lens is a lower bound, not a decoder: it reads intermediate
84+
streams through the *final* unembedding, so it under-reads any feature a layer has
85+
computed but not yet rotated into the output basis. The embedding-level readout
86+
already lands at 6%, a weak unigram prior baked into the static embeddings before any
87+
attention runs.
88+
89+
**Is "L1 H1" an accident of initialization?** Partly. I retrained the same
90+
architecture from four more seeds (at a reduced 3k-step budget — the specialization
91+
is unambiguous well before convergence) and recorded, for each, the single most
92+
causally important head. Across all five seeds it is *always* a sharp previous-token
93+
head (prev 0.90–0.97) sitting in layer 0 or 1 — but which head it lands in moves
94+
around (L1 H1 here, L0 H0 / L0 H1 / L0 H3 elsewhere). So the mechanism replicates and
95+
the head index doesn't. "L1 H1 carries the prediction" is true of *this* model; the
96+
durable statement is "training reliably builds a previous-token head, and the model
97+
leans on it."
98+
99+
What this doesn't show, and the three things I'd want before calling it a circuit
100+
(path patching, the QK/OV weight-space read, and anything non-local — `block_size=32`
101+
and 0.21M parameters leave no room for induction heads, so this is the one mechanism
102+
a tiny local model *can* have) are spelled out at the end of
70103
[`notebooks/05_attention_analysis.ipynb`](notebooks/05_attention_analysis.ipynb).
71104

72105
---
73106

74107
## What it builds
75108

76-
The four analyses run on a model I build up one ingredient at a time in
109+
The analyses run on a model I build up one ingredient at a time in
77110
[`notebooks/progression/`](notebooks/progression). To keep the comparison about the
78111
*architecture* rather than about training length, I hold the three transformer
79112
stages at a fixed 5,000-step budget:
@@ -106,15 +139,17 @@ reference run to within ≈ 0.01 nats.
106139
```bash
107140
pip install -e . # editable install; pulls torch, numpy, matplotlib
108141
python train_gpt.py # reproduce assets/gpt.pth (~15 min on CPU, 30k steps)
142+
python seed_sweep.py --seed 0 # reproduce one row of the seed-robustness check
109143
jupyter lab notebooks/ # run the progression, then 05_attention_analysis
110144
```
111145

112-
The tests cover the package's correctness invariants — including the one the whole
113-
patching analysis rests on, that splicing a run's own clean activations back in
114-
changes nothing:
146+
The tests cover the package's correctness invariants — including the two the patching
147+
analyses rest on, that splicing a run's own clean activations (residual *or* a single
148+
head's output) back in changes nothing:
115149

116150
```bash
117-
pytest # 13 tests, runnable from any directory
151+
pytest # 16 tests, runnable from any directory
152+
pytest -m "not slow" # 10 fast tests; skips the training-based ones
118153
```
119154

120155
I commit `assets/gpt.pth` (the trained 30k model) so the analysis notebook runs on
@@ -132,17 +167,18 @@ gpt/
132167
│ ├── data.py char tokenizer, train/val split, reproducible batching
133168
│ ├── layers.py Head, MultiHeadAttention, FeedForward, Block + ActivationCache
134169
│ ├── train.py training loop, isolated eval, generation, seeding
135-
│ └── analysis.py attention stats, ablation, activation patching, logit lens
170+
│ └── analysis.py attention stats, ablation + redundancy, residual & head patching, logit lens
136171
├── models/
137172
│ ├── bigram.py the baseline
138173
│ └── gpt.py GPT + GPTConfig, instrumented forward, checkpointing
139174
├── notebooks/
140175
│ ├── 05_attention_analysis.ipynb ← the analysis (start here)
141176
│ └── progression/ 01 bigram → 02 single head → 03 multihead+FFN → 04 full GPT
142-
├── assets/ gpt.pth, loss history, generated figures
177+
├── assets/ gpt.pth, loss history, analysis_summary + seed_robustness JSON, figures
143178
├── data/ input.txt (Tiny Shakespeare)
144-
├── tests/ test_smoke.py, test_analysis.py
145-
└── train_gpt.py
179+
├── tests/ test_smoke.py (fast invariants), test_analysis.py (slow integration)
180+
├── train_gpt.py reproduce the 30k analysis checkpoint
181+
└── seed_sweep.py reproduce the seed-robustness check
146182
```
147183

148184
---
@@ -153,9 +189,29 @@ A few places where the decision that mattered wasn't the obvious one:
153189

154190
- **I loop over a `ModuleList` instead of `nn.Sequential` for the blocks.** The
155191
source stacks them in `Sequential`. I loop explicitly so the forward pass can
156-
capture each layer's attention and residual stream, route head-ablation flags to
157-
the right layer, and overwrite activations for patching. `Sequential` hides the
158-
loop and forbids exactly the per-layer access the whole analysis needs.
192+
capture each layer's attention, residual stream, and per-head outputs, route
193+
head-ablation and head-patching to the right layer, and overwrite activations for
194+
patching. `Sequential` hides the loop and forbids exactly the per-layer access the
195+
whole analysis needs.
196+
- **Two patching primitives, because they answer different questions.** Residual
197+
patching overwrites the residual *sum* at a (layer, position) — good for localizing
198+
*where* information is, blind to *which head* put it there. Head patching overwrites
199+
a single head's output, which is what isolates L1 H1 rather than just block 1. Both
200+
write out of place (a patch goes into a cloned tensor so a corrupted run can't
201+
clobber the cached clean activations), and both no-op invariants — splicing a run's
202+
own clean residual, or its own clean head output, back in — are unit tests. Without
203+
those invariants every recovery number is suspect, so I pinned them down rather than
204+
trusting them.
205+
- **Patching uses a contrastive logit-difference metric and a flip-based selection.**
206+
Recovery is measured as the clean-vs-corrupt logit gap rather than one token's
207+
probability, which is less sensitive to overall distribution shifts, and I keep only
208+
the examples where the corruption actually flipped the top-1 prediction (189 of 256)
209+
— a selection I can state plainly instead of a hand-tuned threshold.
210+
- **Every intervention reports an error bar.** Ablation deltas use the standard error
211+
of the *paired* per-batch difference (ablated minus intact on the same batch, which
212+
cancels the base-loss noise); patching recoveries report the standard error across
213+
examples. It's what lets me say the 7× ablation gap and the 5× head-patching gap are
214+
real and not sampling.
159215
- **Evaluation draws from its own isolated RNG.** In the source, training and
160216
evaluation sample from the same global generator, so the trained weights quietly
161217
depend on how often you evaluate. I re-seed a separate generator inside
@@ -170,15 +226,6 @@ A few places where the decision that mattered wasn't the obvious one:
170226
of the query·key dot product grows with the *head* dimension, so that's what the
171227
scaling has to cancel. Get it wrong and nothing crashes — softmax just goes peaky
172228
at initialization and training stalls, which is the worst kind of bug.
173-
- **Patching writes out of place, and the no-op case is tested.** A patch writes
174-
into a cloned tensor so a corrupted run can't overwrite the cached clean
175-
activations, and a test asserts that patching a run's own clean activations back
176-
in changes the output by nothing. Without that invariant every recovery number is
177-
suspect, so I pinned it down with a test rather than trusting it.
178-
- **Recovery ratios are gated on a usable denominator.** Patching divides by the
179-
clean-minus-corrupt metric gap; I drop the examples where the corruption barely
180-
moved the prediction (about 150 of 192 survive) so the ratio doesn't blow up on
181-
noise.
182229
- **The blocks are pre-norm.** LayerNorm sits inside each residual branch and leaves
183230
the skip path clean, so gradients reach the early layers — which is why notebook 03
184231
(multi-head + FFN with no residuals) is the harder model to optimize despite being
@@ -191,6 +238,7 @@ A few places where the decision that mattered wasn't the obvious one:
191238
The architecture and training recipe follow Andrej Karpathy's *"Let's build GPT:
192239
from scratch, in code, spelled out"* and
193240
[nanoGPT](https://github.com/karpathy/nanoGPT); the corpus is Tiny Shakespeare. What
194-
I added is the interpretability instrumentation (activation cache, head-ablation and
195-
activation-patching APIs), the reproducibility engineering (isolated evaluation,
196-
self-describing checkpoints), and the four-part analysis this README leads with.
241+
I added is the interpretability instrumentation (activation cache, head-ablation,
242+
residual- and head-patching APIs), the reproducibility engineering (isolated
243+
evaluation, self-describing checkpoints, seeded interventions with error bars), and
244+
the six-part analysis this README leads with.

assets/activation_patching.png

4.73 KB
Loading

assets/head_ablation.png

7.51 KB
Loading

assets/head_patching.png

37.5 KB
Loading

assets/seed_robustness.json

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
[
2+
{
3+
"seed": 0,
4+
"steps": 3000,
5+
"base_loss": 1.893,
6+
"top_head": [
7+
0,
8+
1
9+
],
10+
"top_delta": 0.4933,
11+
"top_prev": 0.973,
12+
"top_entropy": 0.137,
13+
"top_label": "previous-token",
14+
"top_is_prev_token_L01": true
15+
},
16+
{
17+
"seed": 1,
18+
"steps": 3000,
19+
"base_loss": 1.8948,
20+
"top_head": [
21+
0,
22+
3
23+
],
24+
"top_delta": 0.5447,
25+
"top_prev": 0.967,
26+
"top_entropy": 0.159,
27+
"top_label": "previous-token",
28+
"top_is_prev_token_L01": true
29+
},
30+
{
31+
"seed": 2,
32+
"steps": 3000,
33+
"base_loss": 1.9034,
34+
"top_head": [
35+
0,
36+
0
37+
],
38+
"top_delta": 0.1232,
39+
"top_prev": 0.896,
40+
"top_entropy": 0.46,
41+
"top_label": "previous-token",
42+
"top_is_prev_token_L01": true
43+
},
44+
{
45+
"seed": 3,
46+
"steps": 3000,
47+
"base_loss": 1.8821,
48+
"top_head": [
49+
0,
50+
1
51+
],
52+
"top_delta": 0.2665,
53+
"top_prev": 0.943,
54+
"top_entropy": 0.272,
55+
"top_label": "previous-token",
56+
"top_is_prev_token_L01": true
57+
},
58+
{
59+
"seed": 1337,
60+
"steps": 3000,
61+
"base_loss": 1.8903,
62+
"top_head": [
63+
1,
64+
1
65+
],
66+
"top_delta": 0.43,
67+
"top_prev": 0.941,
68+
"top_entropy": 0.23,
69+
"top_label": "previous-token",
70+
"top_is_prev_token_L01": true
71+
}
72+
]

0 commit comments

Comments
 (0)