@@ -9,22 +9,24 @@ The short version of what I found: in this 4-layer, 4-head character model, almo
99all of the predictive work runs through a single attention head — layer 1, head 1 —
1010which copies the previous character into the position that predicts the next one.
1111Two 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
1415This is the third in a series after
1516[ micrograd] ( https://github.com/diego-magana/micrograd ) (a scalar autograd engine)
1617and [ 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
3032over 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
4546interchangeable, 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
5981through the final unembedding (the logit lens), top-1 next-character accuracy climbs
60820.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
79112stages at a fixed 5,000-step budget:
@@ -106,15 +139,17 @@ reference run to within ≈ 0.01 nats.
106139``` bash
107140pip install -e . # editable install; pulls torch, numpy, matplotlib
108141python 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
109143jupyter 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
120155I 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:
191238The architecture and training recipe follow Andrej Karpathy's * "Let's build GPT:
192239from 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.
0 commit comments