Hands-on experiments with a real language model — activations, attention, and the residual stream under the microscope.
This lesson tries the interpretability methods of Lesson 5 and Lesson 7 on GPT-2, an open-weights model. Tabs 2–4 read the prediction and where it comes from; tabs 5–9 look at how sentences and their properties are represented; tabs 10–13 find the heads and circuits behind a behaviour; tabs 14–16 change what the model does and split its residual stream into readable features. Each result comes with a MATLAB script that reproduces it, using a MATLAB implementation of GPT-2's standard forward pass (model_forward). Most of the results can be computed in seconds to minutes on a regular CPU, without a GPU. A GPU would be useful for Model Editing (tab 15; on a CPU it takes about 2 hours to prepare and 40 minutes per edit with GPT-2 XL) and Sparse Autoencoders (tab 16). Training the SAE is the one step that is not in MATLAB: it needs Python with PyTorch and takes hours even on a GPU. The analysis of the trained SAE is again a MATLAB script that runs on a CPU.
| Tab | Name | Question | Method · script | Discussed in |
|---|---|---|---|---|
| 2 | Model Output | What does one forward pass return? | Final layer norm → WU → softmax · run_model, logit_lens_fromfile | Lesson 1 · tab 9; Lesson 2 · tab 6; Lesson 3 · tabs 2, 4, 5 |
| 3 | Logit Lens | When does the prediction appear? | Read each layer's residual stream with the output layer · run_model step 5, logit_lens | Lesson 7 · tab 2 |
| 4 | Direct Logit Attribution | Which components wrote the answer? | Split the final logit into one share per head and MLP; where the top heads look · run_model step 6, direct_logit_attrib | Lesson 7 · tabs 3, 5 |
| 5 | Cosine Similarity | Do two vectors point the same way? | cos θ between embeddings, states and output directions · cosine_sim, run_cosine_tokenembed, run_cosine_resid | Lesson 5 · tab 4 |
| 6 | RSA | Does the arrangement of sentences follow their topics, and from which layer? | RDM (1 − r), Spearman ρ with a topic model RDM, layer × layer · run_RSA | Lesson 5 · tab 4 |
| 7 | PCA | What do the largest directions of variation show, without labels? | One PCA over all sentences and layers (paths), between/within distance ratio · run_PCA | Lesson 8 · tab 3 |
| 8 | Difference in Means | Is a property stored as a direction, and where? | Class-mean difference, held-out projection, SI = 2·AUC − 1 · run_DIM | Lesson 5 · tabs 2, 4 |
| 9 | Linear Probe | Can a learned direction read what difference in means misses? | Logistic regression (ridge), held-out projection, shuffled-label control · run_probe | Lesson 5 · tabs 2, 4; Lesson 7 · tab 2 |
| 10 | Induction Heads | How does the model copy from earlier in the context? | Attention patterns of a repeated sequence; prefix-matching and previous-token scores of every head; one head step by step · run_model, run_induction, run_attention_head | Lesson 7 · tab 5 |
| 11 | Ablation | What breaks when a component is removed? | Mean ablation of layers and heads; total vs direct effect; induction heads on repeated tokens · ablate, run_ablation | Lesson 7 · tabs 1, 3, 4, 5 |
| 12 | Activation Patching | Where is the information that decides the answer, and what moves it? | Copy one activation from the clean run into a counterfactual run (France → Italy) and back; every layer, position and head · run_patching, patch_activations | Lesson 7 · tab 4 |
| 13 | IOI Circuit | Which heads, in what order, let GPT-2 pick the indirect object? | Direct logit attribution per head, attention from the last token, and one head patched at one position (at the last token and at the repeated name) · run_ioi | Lesson 7 · tab 5 |
| 14 | Steering | Does writing along a readable direction change what the model says? | Add α × a difference-in-means direction (sentiment; France → Italy) to the residual stream; shuffled copies of the direction as a control; every layer · run_steering | Lesson 7 · tab 6 |
| 15 | Model Editing | Can one fact be rewritten in the weights, and only that fact? | ROME: a rank-one change to one MLP's output matrix (GPT-2 XL, Eiffel Tower → Rome); tested on paraphrases, neighbouring facts and continuations, and at every 4th layer against causal tracing · run_rome | Lesson 7 · tab 7 |
| 16 | Sparse Autoencoders | Can the residual stream be rewritten as a few readable features, and what do they do? | An SAE with 24,576 features on the stream leaving layer 5, trained with Anthropic's 2024 recipe at three sparsity penalties (λ = 1, 5, 20); each feature's top contexts and the tokens it promotes, the model with the SAE's reconstruction spliced in, comparison with the sentiment direction of tab 8, and feature steering · train_sae.py, run_sae | Lesson 7 · tab 8 |
run_ scripts (run_DIM, run_RSA, run_PCA) also run Pythia (model = 'pythia-160m'). If you try them there, note that in Pythia-160M the final "." is an attention sink with massive, nearly input-independent activations, as Sun et al. (2024) found for delimiter tokens in other models,
so it carries almost no sentence information.
Read the last word instead (position = 'last word' in the run_ scripts).
Before looking inside the model, look at what it returns. GPT-2 small, prompt "The capital of France is the city of", run with run_model (our MATLAB forward pass, model_forward).
The text is split into tokens and the model only ever sees their IDs (0-based; <|endoftext|> is put first):
| Position | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| Token | <|endoftext|> | The | ␣capital | ␣of | ␣France | ␣is | ␣the | ␣city | ␣of |
| ID | 50256 | 464 | 3139 | 286 | 4881 | 318 | 262 | 1748 | 286 |
␣ = a leading space, which is part of the token: " of" and "of" are different tokens.
One forward pass returns a logit for every vocabulary token at every position: a 50,257 × 9 matrix here. A softmax turns each column into probabilities that sum to 1. The last column is the prediction for the token after the prompt; every earlier column is the model's guess at the token that comes next at that point, which is exactly what training scores.
At each position the last layer leaves a vector of 768 numbers, x(11) (the residual stream). Three steps turn it into probabilities:
| Step | Formula | Size | Here (last position, " of") |
|---|---|---|---|
| 1 · Final layer norm | x̂ = γ ⊙ (x(11) − μ) / σ + β μ, σ: mean and standard deviation of the 768 entries; γ, β: learned scale and shift (768 each); ⊙: element-by-element product (MATLAB .*) | 768 | σ = 15.0 |
| 2 · Unembed: logits | z = WU x̂ i.e. zt = E[t] · x̂ WU: 50,257 × 768; row E[t] is token t's output direction; GPT-2 uses each token's input embedding for it (tied weights). Vectors are columns here, as in our MATLAB code. | 50,257 | z(" Paris") = 14.94 z(" Marse") = 14.86 |
| 3 · Softmax: probabilities | pt = ezt / Σj ezj positive, sum to 1; only differences between logits matter | 50,257 | p(" Paris") = 9.7% p(" Marse") = 8.9% |
A logit difference is a log probability ratio: z(" Paris") − z(" Marse") = 0.09 (from the unrounded logits), so p(" Paris") / p(" Marse") = e0.09 ≈ 1.09 (9.7 / 8.9). Adding the same number to every logit changes nothing, which is why a single logit has no meaning on its own. Sampling at temperature τ uses pt ∝ ezt/τ: τ < 1 sharpens the distribution, τ → 0 is greedy. The entropy H = −Σt pt ln pt measures how spread out it is: 5.69 nats here, against ln 50,257 = 10.8 for a uniform guess. Training lowers the loss, Loss = −ln p(actual next token), averaged over positions.
In our exported weights γ is folded into WU and β into a bias bU = WUβ, so the code computes z = WU (x(11) − μ) / σ + bU. The export also centres WU: each of its columns has its mean over the vocabulary subtracted. That shifts every logit at a position by the same amount, so the probabilities are unchanged, but the logits differ slightly from other GPT-2 implementations. The logit lens (tab 3) applies the same three steps to x(l) of an earlier layer; direct logit attribution (tab 4) splits step 2 into one share per component.
" Paris" wins, but with only 9.7%: " Marse" (the start of " Marseille") is close at 8.9%, then other French cities. The model is not certain; it spreads probability over plausible continuations. Greedy decoding takes the top token; sampling draws from the distribution (the temperature sets how spread out it is).
Generation repeats the forward pass: append the chosen token, run again, choose again. Ten greedy steps (nNew = 10):
The capital of France is the city of Paris, and the capital of the French Republic.
Probabilities of the chosen tokens: 9.7%, 48%, 18%, 15%, 26%, 54%, 26%, 17%, 26%, 44%.
run_modelSet the %% Settings block at the top of run_model as below, then run it. Settings not listed don't matter here: topK and seed apply only when sampling; analysisPositions and the DLA_ settings only when the logit lens or DLA is on; saveActivations stays false.
model = 'gpt2-small';
checkpoint = []; % the trained model
prompt = 'The capital of France is the city of';
nNew = 1; % one next token: a single forward pass
temperature = 0; % greedy: always the most likely token
showLogitLens = false; % steps 5 and 6 are the next tabs
showDLA = false;
It prints the predicted token (" Paris", 9.7%) with its ID and the runner-up (" Marse"). The logits of the forward pass are in g.out.logits [50,257 × 9], one column per prompt position: the matrix shown in Fig. 1. For the sentence in card 5, set nNew = 10. Fig. 1 uses one pass over the prompt alone, out = model_forward(ids, W). The next tabs open up the same forward pass: the logit lens reads the prediction from intermediate layers.
Following the math step by step: logit_lens_fromfile. This short script is useful for following card 3 from the final layer norm to the probability of each token. It takes the saved residual stream of one prompt and does each step in one line: center and scale (layer norm), multiply by WU and add bU (logits), softmax (probabilities), then prints the top 5. First run run_model with saveActivations = true (saves Activations\the_capital_of_france_is_the_city_of), then set at the top of logit_lens_fromfile:
activations_folder = 'The capital of France is the city of';
L = 11; % layer, 0-based; 11 = the model's output
With L = 11 it prints the model's own prediction (" Paris" 9.72%, " Marse" 8.91%); a smaller L gives the logit lens of tab 3.
Tab 2 read the prediction at the end of the model. The logit lens reads it from the middle: take the residual stream after layer l and pass it straight to the output, as if the remaining layers were skipped (nostalgebraist 2020; Lesson 7, tab 2). Same model and prompt as tab 2: GPT-2 small, "The capital of France is the city of".
p(l) = softmax(WU · LayerNorm(x(l)) + bU)
x(l) is the residual stream leaving layer l (768 numbers per position); LayerNorm, WU and bU are the model's own final layer norm and unembedding. At l = 11, the last layer, this is exactly the model's output from tab 2.
Up to layer 7, " Paris" is not in the top 250 (rank 4,969 at layer 0, 259 at layer 7); the top guess is the generic " the". City names arrive at layer 8 (" Paris" rank 5). At layers 9–10 the readout is confident, but in " Marse" (61–62%), with " Paris" at 28–32%. The last layer reverses the order and spreads the probability: " Paris" 9.7%, " Marse" 8.9%.
The top token changes from layer to layer and confidence does not grow steadily: it can be highest in the middle and fall at the end (here 62% at layer 10, 10% at layer 11). Across positions (panel c), early layers often predict a common follower of the current word (" of" → " the") or the word itself (" capital" → " capital" up to layer 4).
run_modelThe same settings as tab 2, with the logit lens switched on:
model = 'gpt2-small';
checkpoint = []; % the trained model
prompt = 'The capital of France is the city of';
nNew = 1; % one next token: a single forward pass
temperature = 0; % greedy
analysisPositions = 'first'; % first predicted token = the last prompt position (" of")
showLogitLens = true; % step 5: logit lens
showDLA = false;
Step 5 prints the top 5 at the last layer, plots their probability and rank by layer (like panels a and b) for the analysed position, and plots the top token at every layer and position (panel c). The numbers behind panel c: P = logit_lens(x, W, 0:11); gives p(l) as a [50,257 × 9 × 12] array (token × position × layer), and [pTop, iTop] = max(P, [], 1); the top token and its probability.
The logit lens (tab 3) asks when the prediction appears. Direct logit attribution (DLA) asks who wrote it: it splits the final logit of a token into one share per component (Lesson 7, tab 3). Same model and prompt: GPT-2 small, "The capital of France is the city of", where " Paris" (9.7%) beat " Marse" (8.9%).
The residual stream at the end is a sum of what every component wrote:
x(11) = x(emb) + Σl Δxattn,l + Σl Δxmlp,l
The logit of token t is E[t] · LayerNorm(x(11)) + bU(t), with E[t] the token's row of WU. Holding LayerNorm's scale at its actual value makes this linear, so each term gets a share and the shares add up exactly to the logit.
logit(" Paris"): who raised " Paris" at all? Many components push all plausible cities up together.
logit(" Paris") − logit(" Marse"): who made " Paris" win? Only the difference matters for the choice, and it removes everything the two cities share.
"Direct" means only what a component writes straight to the output; effects passed on through later layers are not counted.
<|endoftext|>, where heads often park when they have little to do.Its logit, 14.94, is attention +7.72, MLPs +5.65, bU +1.59 and the embedding −0.01. Most of it comes late: layers 8–11 give 80% of the attention and MLP shares. The largest single share is head 9.8 (+3.10), which puts 76% of its attention on " France": it carries information from the country's position to the last position. MLP 10 adds +2.15.
Almost no one. The difference is only +0.09, and the attention (−1.06) and MLP (−1.15) shares both favour " Marse"; head 9.8 too (−0.37), so it raises " Marse" even more than " Paris". " Paris" wins on bU (+2.33): a fixed term, the same for every prompt, that comes from the final layer norm's bias (folded into bU when the weights are exported).
run_modelThe same settings as tab 2, with DLA switched on. Panels a and c are DLA_compare = 'none' (logit(" Paris") alone); panels b, d, e and f are DLA_compare = 'runner-up' (" Paris" − " Marse"):
model = 'gpt2-small';
checkpoint = []; % the trained model
prompt = 'The capital of France is the city of';
nNew = 1; % one next token: a single forward pass
temperature = 0; % greedy
analysisPositions = 'first'; % first predicted token = the last prompt position (" of")
showLogitLens = false;
showDLA = true; % step 6: direct logit attribution
DLA_compare = 'runner-up'; % panels b, d, e, f: " Paris" - " Marse"; 'none' for panels a, c
Step 6 plots the shares by layer, by head, and where the top heads look; the numbers are in d (d.attn, d.mlp, d.heads [head × layer], d.b_U, d.top_heads, d.top_attn). Panel a can also be computed directly: direct_logit_attrib(g.out, W, 6342) (6342 = the ID of " Paris").
Tabs 2–4 read vectors through the output layer. Cosine similarity compares two vectors directly: do they point the same way? It works for any pair of vectors in the model: token embeddings, residual-stream states, or a state and a token's output direction (Lesson 5, tab 4). Same model and prompt: GPT-2 small, "The capital of France is the city of".
cos θ = (u · v) / (‖u‖ ‖v‖)
1 = same direction, 0 = orthogonal, −1 = opposite. The lengths are divided out, so only the direction counts. In 768 dimensions two random directions have a cosine near 0 (typically within ±0.04), so values like 0.2 are already far from chance.
Before any layer has run, words of the same category already share direction: 0.51 on average within a category, 0.26 between (Fig. 4; colours 0.64, cities 0.51). Every cosine is positive, though: the embeddings share a common component, so "unrelated" is around 0.2–0.3 here, not 0.
At the last position the France and Italy prompts are nearly identical (cosine 1.00 to two decimals) up to layer 7: the token there is " of" in both, and the country reaches it only at layers 8–9 (lowest 0.94; Japan 0.86). An unrelated sentence still scores 0.56–0.90, and 0.90 at the output (layer 11). Raw cosines between residual-stream states are compressed by a large direction they all share, so compare them with each other, not with 0.
Step 2 of tab 2, zt = E[t] · x̂, written with the angle (γ and β folded into WU and bU, as in our exported weights):
zt = √768 · ‖E[t]‖ · cos(x − μ, E[t]) + bU(t)
LayerNorm fixes the length of x − μ (‖x − μ‖ / σ = √768 = 27.7), so a token's logit depends only on its angle with the residual stream, its own length ‖E[t]‖ and its bias. The cosine is the only part that changes with the stream: for one token, the logit is the cosine rescaled and shifted. Between tokens the scale and shift differ, so a larger cosine does not mean a larger logit. Here ‖E[" Paris"]‖ = 4.11, ‖E[" Marse"]‖ = 4.90, ‖E[" the"]‖ = 2.62.
Fig. 4: run run_cosine_tokenembed with its default settings (the same 16 words). Fig. 5: run_cosine_resid with example = 3 compares your own sentences; with three or more, it also plots the first sentence against each of the others, one line each (step 7):
model = 'gpt2-small';
readout = 'last'; % one vector per sentence: its last token
center = false; % raw cosines
example = 3; % your own sentences
mySentences = {'The capital of France is the city of', 'The capital of Italy is the city of', ...
'The capital of Japan is the city of', 'I went to the shop to buy some'};
Sentences with the same number of tokens share their position embeddings; the script warns if the counts differ. example = 1 and 2 are the built-in sets (Greece vs Italy; 16 sentences in 4 topics).
Fig. 6: run run_model with the tab 3 settings (showLogitLens = true); the bottom panel of its logit-lens figure plots this cosine for the five final top tokens. The same numbers, including " the" and the embedding (emb), after run_model:
x = double(cat(2, g.out.resid_pre(:, end, 1), squeeze(g.out.resid_post(:, end, :)))); % last position: emb, then the output of layers 0-11
x = x - mean(x, 1); % centered, as layer norm does
c = cosine_sim(double(W.W_U([6342 45996 262] + 1, :))', x); % rows: " Paris", " Marse", " the"
plot(-1:11, c'); legend('" Paris"', '" Marse"', '" the"'); xticks(-1:11); xticklabels(layer_points(12)); xlabel('Layer');
Tab 5 compared two vectors at a time. Representational similarity analysis (RSA) looks at the arrangement of a whole set: for a set of sentences, which ones are close to which, and does that arrangement follow a category (Lesson 5, tab 4)? GPT-2 small, residual stream at the last word of each sentence (the word before the final "."), for 16 sentences, 4 in each of 4 topics.
At layer l, for every pair of sentences, the dissimilarity 1 − r, where r is the correlation between their two vectors (here the same as their cosine: our exported weights that write into the residual stream are centred, so every state has mean zero across its 768 units). For 16 sentences this is a 16 × 16 matrix, the representational dissimilarity matrix (RDM), with 120 distinct pairs. 0 = the same pattern; larger = more different.
A model RDM says what topic alone predicts: 0 for two sentences of the same topic, 1 for different topics. The layer's RDM is compared with it by the Spearman correlation ρ over the 120 pairs. Only ranks count, so the shared direction that squeezes raw cosines (tab 5) does not matter. With a two-valued model, ρ is proportional to the gap between the mean ranks of between- and within-topic pairs; even perfect separation gives only 0.69 (24 within vs 96 between pairs), the ceiling in Fig. 7e.
| Topic | Examples (4 per topic, all 7 tokens) |
|---|---|
| Food | "I ate pasta for dinner tonight.", "We ordered pizza with extra cheese." |
| Sport | "The team won the match easily.", "He scored two goals last night." |
| Weather | "Heavy rain fell all afternoon today.", "The sky cleared after the storm." |
| Politics | "Our mayor signed the new law.", "The judge delayed the trial again." |
The last word often does not name the topic ("tonight", "easily", "today", "again"), so at the last word the topic has to be gathered from earlier words. Equal lengths mean every sentence has the same position embeddings.
There is one RDM per point (emb and layers 0–11): 13 matrices like panel a. Each other panel condenses them differently.
a shows one of them in full: layer 10, all 16 × 16 pairs.
b reduces every RDM to two numbers: the mean of its within-topic cells (inside the diagonal blocks of a, without the diagonal) and of its between-topic cells (outside the blocks). The layer-10 points of b are the two numbers in a's title, 0.19 and 0.23.
c compares whole RDMs with each other: cell (i, j) is the Spearman correlation between the 120 pairs of layer i's RDM and those of layer j's. Panel a is one of the 13 matrices compared; the diagonal of c is 1 (each RDM with itself).
e does what c does, but against the hand-made RDM in d instead of another layer: it is the row c would have if d were added as a 14th "layer". b and e both ask whether the arrangement follows topic; b in units of 1 − r (sensitive to the overall level), e in ranks (only the order of the pairs counts).
Comparing each layer's RDM with the topic model (panel d) gives panel e. At the embedding the arrangement has almost nothing to do with topic (ρ = 0.05). Topic structure then grows steadily with depth, faster after layer 5, to 0.38 at layers 10–11, a bit over half the ceiling. Panel b shows why it stays modest: within- and between-topic pairs rise and fall together (the sentences spread apart over the first layers and converge again toward the output), and only the gap between them is topic. It grows from 0 at the embedding to about 0.05 at layer 9; at layer 10, 1 − r is 0.19 within vs 0.23 between (panel a).
Neighbouring layers arrange the sentences alike (panel c), and layers 2–8 form a block with similar arrangements. Further apart, the arrangement changes: layer 0's RDM has ρ 0.91 with layer 1's but 0.56 with layer 11's. The geometry is rebuilt gradually rather than kept (tab 8 finds the same for a single direction), and the last two layers rearrange the sentences most, as they turn to predicting the next token.
run_RSAmodels = {'gpt2-small'}; % one or more models
checkpointRange = []; % the trained model
showL = 10; % layer for the RDM heatmap (panel a)
position = 'last word'; % the word before the final "."
It prints, for every layer, the mean 1 − r within and between topics and ρ, and plots the RDM at showL (a), the within and between pairs as values and as ranks (a2, a3; a3 shows how ρ is computed and its ceiling), within vs between across layers (b, as Fig. 7b) and the layer × layer comparison (c). ρ by layer (Fig. 7e) is printed in its table. The sentences are in prompts_topic.
RSA (tab 6) compared the arrangement with a hypothesis, the topic model. Principal component analysis (PCA) uses no hypothesis and no labels: it finds the directions along which the states vary most, and the labels only colour the plot afterwards. As in Fernando & Guitchounts (2025; Lesson 8, tab 3), one PCA covers every sentence at every layer, so all layers share the same axes and each sentence becomes a path through layers 0–11. GPT-2 small, the residual stream at the last word of each sentence, three sets of sentences (card 1).
| Set | Sentences | Groups (used only to colour the plot) |
|---|---|---|
| Topic | The 16 sentences of tab 6, 4 per topic, all 7 tokens: "I ate pasta for dinner tonight.", "The team won the match easily." | food, sport, weather, politics |
| Sentiment | 10 matched pairs, the same sentence with opposite sentiment: "I love this movie." / "I hate this movie.", "The food was delicious." / "The food was disgusting." | positive, negative; single words give the label away (love, delicious) |
| Passive role (who acts) | 48 sentences, 12 sets × 4, each set with one predator, one prey and one verb. Predator acts: "The cat watched the mouse." / "The mouse was watched by the cat."; prey acts: "The mouse watched the cat." / "The cat was watched by the mouse." | predator acts, prey acts; no single word gives it away: both groups use the same words, and each ends once with the predator and once with the prey, so who acts depends on word order and "was … by" |
Sentiment is the easy contrast, passive role the hard one; tabs 8 and 9 test both with labelled readouts.
Stack the states of all sentences at layers 0–11 (16–48 sentences × 12 layers), subtract their overall mean, and find the principal components (PCs): the orthogonal directions of largest variance. Two preparations: each layer is first scaled to the same average length (the stream grows about 100-fold with depth, and the last layers would otherwise dominate), and the embedding is left out (it differs from every later state, and PC1 would just be the step from emb to layer 0).
A PC can capture depth (every sentence moves along it together, layer by layer) or differences between sentences at the same layer. For each PC, the share of its variance that lies between sentences at the same layer tells which; run_PCA prints it for every PC. The PCs are found without labels, so a PC with differences between sentences need not follow any of our groups.
At each layer, in the full (scaled) residual stream, not just the plotted PCs: the mean distance between the group means, divided by the mean distance of a sentence from its own group mean. About 1: the groups differ as much as single sentences vary; well below 1: they overlap. This is the one step that uses the labels.
In all three sets, PC1 (20–25% of the variance) is the path every sentence shares: its scores follow the layer (correlation 0.94–0.97 with depth) and hardly differ between sentences. Most of the next PCs hold differences between sentences. For topic these differences follow the labels without being told to: already on PCs 1–3 the four topic paths take different routes, weather the most distinct (panel a), and the groups move apart with depth, from a ratio of 0.86 at layer 0 to 1.15 at layer 11 (panel c), as RSA found in tab 6.
The two sentiment groups follow almost the same path (panel d), and their means are only about 0.2 apart against a spread of about 0.5 within each group (ratio 0.34–0.42, panel f), at every layer. Yet difference in means reads sentiment at 0.96 (tab 8). The difference is real but lies along one narrow direction, small next to everything else in which sentences differ; PCA's largest directions and the full-space distance are dominated by the rest. Who acts is near zero by this measure too (0.01–0.10, panel i): no method found it in GPT-2 small.
run_PCARun once per set ('topic', 'sentiment', 'passive-role'):
model = 'gpt2-small';
contrast = 'topic'; % or 'sentiment', 'passive-role'
nPC = 10; % leading PCs to report
pcs3 = [1 2 3]; % the PCs for the 3D plot, as in Fig. 8 ([] = PC1 + the two most sentence-specific PCs)
position = 'last word'; % the word before the final "."
It prints, for each PC, the variance explained, its correlation with depth and its share of variance between sentences, then the distances and ratio by layer, and plots the paths in 3D (a), the variance explained (b) and the distances with their ratio (c).
Tabs 6–7 looked at how a whole set of sentences is arranged, without asking about one property. To find where a property such as sentiment is stored, we need many sentences that differ in that property. Difference in means (DIM) builds a direction from two groups of sentences and asks whether new sentences fall on the right side of it (Lesson 5, tabs 2 and 4). Two contrasts: one its words give away (sentiment), and one no single word gives away (who acts, in active and passive sentences). GPT-2 small, residual stream at the last word of each sentence (the word before the final ".").
At layer l, average the residual-stream vectors of each group and subtract:
f = mean(x | group A) − mean(x | group B)
What all sentences share (the large common direction that keeps raw cosines high, tab 5) is in both means and cancels, so f keeps only what separates the groups. A new sentence's score is its projection on f: x · f / ‖f‖.
Each sentence is scored on a direction built without it (and without the other sentences made from the same words), so the test is on new sentences. Separation is the selectivity index SI = 2·AUC − 1, where AUC is the fraction of (A, B) pairs in which the A sentence scores higher: 0 = chance, +1 = every A above every B. Only the order of the scores counts, not their level.
| Contrast | Sentences | What gives the label away |
|---|---|---|
| Sentiment | 10 matched pairs: "I love this movie." / "I hate this movie.", "The food was delicious." / "The food was disgusting." | single words (love, delicious) |
| Passive role (who acts) | 48 sentences, 12 sets × 4. Predator acts: "The cat watched the mouse." / "The mouse was watched by the cat."; prey acts: "The mouse watched the cat." / "The cat was watched by the mouse." | nothing on its own: both groups use the same words, and each ends once with the predator and once with the prey; who acts depends on word order and "was … by" |
| Control: last word | the same 48, grouped by the animal that comes last (prey vs predator) | the last word itself |
Sentiment rises from 0.58 at the embedding to 0.96 at layers 9–10 (panel b; with only 10 pairs, values up to about 0.6 can occur by chance). Who acts barely separates: SI 0.00–0.23 at every layer (panel e; with 24 sentences per group, chance alone gives about ±0.3), and at layer 10 the two groups overlap almost completely (panel d). The control shows the stream is not empty: which animal comes last is readable at 0.93 from the embedding and stays above 0.7 to layer 10. GPT-2 small keeps the words at the last position, but no single direction there says who did what.
The sentiment direction at layer 10 has cosine 0.92 with layer 9's and 0.93 with layer 11's, but 0.62 with layer 4's and 0.40 with the embedding's (panel c): each layer re-reads sentiment along a somewhat rotated axis. Panel f shows a band along the diagonal too, although role is not readable: neighbouring layers share most of the stream (each only adds to it), so any difference between the group means, noise included, carries over. A smooth cosine matrix says a direction persists, not that it means anything; check SI first. At the last layer (11) the readouts drop (sentiment 0.60, control 0.34), probably because that layer rewrites the stream to predict the next token, the same "." for every sentence.
run_DIMRun once per contrast ('sentiment', 'passive-role', 'passive-lastword'):
model = 'gpt2-small';
checkpointRange = []; % the trained model (the default runs training checkpoints)
contrast = 'passive-role'; % or 'sentiment', 'passive-lastword'
position = 'last word'; % the word before the final "."
target = ''; % first class (positive; predator acts) for panels a and c
showL = 10; % layer for panel a
It prints SI by layer for each class and plots the held-out scores at showL (a), SI by layer (b) and the layer × layer cosine of the direction (c). The sentence sets are in prompts_sentiment and prompts_passive; other contrasts in the same script: 'topic', 'strong-weak-praise', 'negation-adjective', 'negation-sentiment'.
Difference in means (tab 8) takes the line between the two group means. A linear probe learns the line instead: it is trained to put one group on one side and the other group on the other. Same model, sentences, position and held-out design as tab 8: GPT-2 small, sentiment and passive role (who acts), residual stream at the last word.
Logistic regression on the residual stream at layer l:
P(group A | x) = sigmoid(w · x + b)
w and b are fitted to the training sentences, with a penalty λ‖w‖² that keeps w small. A new sentence's score is its projection on w, x · w / ‖w‖, exactly as on f in tab 8, and separation is again SI = 2·AUC − 1.
DIM uses f = mean(x | A) − mean(x | B). A probe comes closer to Σ−1f, where Σ is the spread of the vectors within each group: it turns the line away from directions in which sentences of the same group differ a lot, since those blur the separation. The penalty is a dial: a large λ pulls w back to f, a small one lets it turn freely. Here λ = 10−5 (each layer's vectors scaled to average length 1).
With 768 dimensions and 20–48 sentences, a probe can separate the training sentences under almost any labelling, even a random one. Two safeguards: every score is held out (the sentence and those made from the same words are left out of the fit), and the whole analysis is repeated with the labels shuffled (20 times). The shuffled runs show what SI a probe reaches when there is nothing to find; a result inside that range is no evidence of a readout.
The probe matches or beats difference in means at every layer and reaches 1.00 at layer 10 (panel a: no overlap), far above the shuffled range (up to about 0.5). The gain is largest where the two directions disagree: at layer 0 (0.88 vs 0.76), where the probe's line is turned well away from f (cosine 0.44, panel c), and at layer 11 (0.80 vs 0.60). From layer 3 on the two lines converge (cosine 0.92 at layer 10): once sentiment is a large part of what separates the groups, the line between the means is close to the best line.
The probe reaches 0.03–0.30, a little above difference in means, but inside the shuffled range at every layer (its 95th percentile is 0.25–0.49; panel e). Its direction stays close to f (cosine 0.84–0.94, panel f): with no separation to improve on, there is little for the probe to correct. So in GPT-2 small, neither method finds a straight-line readout of who acts at the last word.
The four models: small (124M parameters, 12 layers, 768 dimensions), medium (355M, 24, 1,024), large (774M, 36, 1,280) and XL (1.5B, 48, 1,600). In all four the probe reads nothing in the first ~30% of depth, then rises. Small, medium and large differ little: they peak at 0.30, 0.36 and 0.39, late in the network (75–92% of depth), around the range chance reached in small. XL differs in kind: it climbs steeply between 30% and 42% of depth and holds 0.50–0.58 to its last layer, peaking at mid-depth (0.58 at layer 25 of 48). But Fig. 11 has no shuffled-label control for XL, and the score a probe reaches by chance rises with the number of dimensions (1,600 in XL against 768 in small, with the same 48 sentences), so part of XL's lead may be its greater capacity to fit rather than a readout.
run_probeRun once per contrast ('sentiment', 'passive-role'):
model = 'gpt2-small';
checkpointRange = []; % the trained model (the default runs training checkpoints)
contrast = 'passive-role'; % or 'sentiment'
lambda = 1e-5; % ridge penalty: larger = closer to the DIM direction
nShuffles = 20; % shuffled-label control (grey band); 1 = a single shuffle, faster
seed = 1; % for the shuffles
target = ''; % first class (positive; predator acts) for panel a
showL = 10; % layer for panel a
position = 'last word'; % the word before the final "."
It prints SI by layer for the probe, difference in means and the shuffled labels, and plots the held-out probe scores at showL (a), SI by layer (b) and the cosine between the probe and difference-in-means directions (c). With 20 shuffles it took about 2 min for sentiment and 9 min for passive role on a desktop computer's CPU; most of the time is the shuffles.
Fig. 11: the same settings with contrast = 'passive-role', nShuffles = 0 and model = 'gpt2-small', 'gpt2-medium', 'gpt2-large', 'gpt2-xl' in turn (1–6 min each); after each run:
depth = 100 * (1:nL) / nL; % output of layer L of nL at (L + 1)/nL × 100%
plot(depth, SIp(2:end, 1)); hold on % probe SI, emb left out (no direction for role)
Tab 4 showed where a few heads look on one prompt. Some heads do one job so reliably that it has a name: induction heads copy what came next the last time the current token appeared (Olsson et al. 2022; Lesson 7, tab 5). GPT-2 small throughout.
Write a short list of unrelated words twice, "Code: blue seven turtle elbow spoon granite.", and give run_model the second copy cut off at different points. Nothing in English predicts these words, so the first time they appear GPT-2 small cannot guess them. The second time it can: the words came in this order earlier in the prompt, and it copies them. Generating on from the second "Code", it writes the rest of the sentence word for word: ": blue seven turtle elbow spoon granite. Code".
| Prompt | Next token | p, first time | p, second time | Top guesses, second time |
|---|---|---|---|---|
| "Code: blue seven turtle elbow spoon granite. Code" | ":" | 72% | 96% | ":" 96%, " :" 0.2%, " is" 0.2% |
| "Code: blue seven turtle elbow spoon granite. Code:" | " blue" | 0.0% (rank 2,380) | 29% | " blue" 29%, " red" 11%, " green" 4% |
| "Code: blue seven turtle elbow spoon granite. Code: blue" | " seven" | 0.0% (rank 4,287) | 36% | " seven" 36%, " eight" 14%, " six" 4% |
"First time" is the same point in the first copy (the prompt "Code", "Code:" or "Code: blue" alone). ":" after "Code" is common English, so it is likely even the first time, and copying raises it further; " blue" and " seven" are unpredictable until they can be copied. The runners-up are of the same kind (other colours, other numbers): the model also knows what sort of word fits, and copying picks out the exact one.
prompt = 'Code: blue seven turtle elbow spoon granite. Code'; % or end the prompt with 'Code:' or 'Code: blue'
nNew = 1; % 9 to generate on: ": blue seven turtle elbow spoon granite. Code"
showLogitLens = false;
showDLA = false;
Copying "[A][B] … [A] → [B]" takes two heads in different layers. A previous-token head writes into every position which token came just before it, so the position of [B] now also carries "after [A]". Later, at the second [A], an induction head looks for a position marked "after [A]", finds [B], and moves [B] into the prediction. The first head must sit in an earlier layer than the second.
On the first copy nothing can be predicted (except ":" after "Code", 72%); on the second, the model gives the right word 29–96% (" turtle" after " seven": 80%). Head 4.11 attends to the previous token at every position (1.00 on average; Fig. 13e): the first step. Head 5.5 has nothing to do on the first copy and parks its attention on <|endoftext|>; on the second copy each token attends to the token right after its earlier copy (0.91 on average), the stripe one step off the diagonal (Fig. 13d): the second step. Fig. 13a shows the step from the second " seven": 5.5 puts 89% of its attention on the first " turtle" yet adds only +0.30 directly to the " turtle" logit (17th of the 144 heads). The heads with the largest direct effect sit in layers 9–11 and also look at " turtle" (10.0: +1.79), and two of them push it down (11.10, 10.7). So the induction head finds the token to copy; much of the writing into the prediction is done by later heads (DLA: tab 4).
Fig. 13a and the attention patterns (Fig. 13d, e) show where heads look. To see how a head decides and what it brings back, run_attention_head recomputes one head at one token by hand, step by step, from the weights, and checks every step against the model's own run. Here: head 5.5 at the second " seven" of card 1's sentence (the outlined row of Fig. 13d), in the steps of attention (Lesson 2, tab 3), numbered as in the script and the figure:
run_attention_head), one bar per earlier position: b. scores qi · kj / 8 (steps 1–3); c. attention weights, the softmax of b and the outlined row of d (step 4). d, e. Attention of the induction head (5.5) and the previous-token head (4.11) at every position: row = the current token (query), column = the token attended to (key); <eot> = <|endoftext|>; outlined in d: the second " seven". f, g. Head 5.5 at the second " seven", continued: f. what each position's value would add directly to the " turtle" logit if the head attended only there (its value through WO, read along " turtle"'s unembedding direction, divided by the final LayerNorm's scale, as in DLA, tab 4; steps 5, 7a–7d); g. attention × value: each position's share of the head's direct effect on " turtle"; the bars sum to its DLA, +0.295 (steps 6, 7e).Head 5.5 puts 89% of its attention on the first " turtle", the token after the earlier " seven": its query at " seven" matches the key at " turtle", which carries "the token before me was seven" (K-composition: a later head's keys read what an earlier head wrote; Elhage et al. 2021). That information was written there by earlier heads: silencing the previous-token head 4.11 lowers the weight on " turtle" to 73% and moves the rest to <eot> (run_attention_head, step 8: head 4.11's output set to zero). So 4.11 supplies part of the match, not all of it; other earlier heads also write the previous token (card 7: weaker previous-token heads in layers 2 and 3).
Almost all of the head's direct effect on " turtle" comes from the position it attends to (Fig. 13g): 0.288 of the total +0.295. But the value at " turtle" is not specific to turtle (Fig. 13f): the values at " elbow", " spoon" and the second " seven" would push " turtle" just as much (0.29–0.34). So head 5.5 finds the right position, and its own write pushes the answer only weakly and generically; the later heads in layers 9–11 do most of the writing (Fig. 13a). What 5.5 carries may still be read by those later heads rather than by the output directly, which DLA cannot see. Cards 5 and 6 are the two halves of every attention head: where it looks is set by its query and key matrices (the QK circuit), what it brings back by its value and output matrices (the OV circuit).
On random tokens the effect is extreme: 12.9 nats per token on the first copy, 0.44 on the second (Fig. 14a). Five heads in layers 5–7 do the matching: 5.5 (0.94), 6.9 (0.92), 5.1 (0.91), 7.10 (0.91) and 7.2 (0.84); no other head reaches 0.55 (Fig. 14b). The clearest previous-token head is 4.11 (0.99), one layer below the first induction heads, as the circuit requires; a few weaker ones sit in layers 2, 3 and 6 (0.4–0.55, Fig. 14c).
Induction heads form abruptly, early in training: at roughly 2.5–5 billion tokens in models of every size with more than one layer (Olsson et al. 2022). Tab 11 removes these heads to test whether the model needs them for copying.
Card 1: run_model with the settings above. Fig. 12: one run of the full repeated sentence, then the probability of each token that actually came next:
ids = model_tokenize('Code: blue seven turtle elbow spoon granite. Code: blue seven turtle elbow spoon granite.', W);
out = model_forward(ids, W);
Z = double(out.logits); P = exp(Z - max(Z, [], 1)); P = P ./ sum(P, 1); % softmax at every position
pNext = P(sub2ind(size(P), ids(2:end) + 1, 1:numel(ids) - 1)); % p(the token that actually came next)
bar(0:numel(ids) - 2, 100 * pNext)
Fig. 13d, e, the attention of any head, after out = model_forward(ids, W) (layer L, head h, 0-based):
A = out.attn_pattern(:, :, h + 1, L + 1); % [query × key]
imagesc(A); axis square; xlabel('Key'); ylabel('Query')
Fig. 13a: the DLA plot of run_model with DLA_extra_heads = [5 5; 4 11], at the second " seven": run_model analyses the last position, so end the prompt there (prompt = 'Code: blue seven turtle elbow spoon granite. Code: blue seven').
Fig. 13b, c, f, g: run_attention_head with its default settings (and silenceHead = [], no ablation, for the figure); it prints each step as a table and checks it against the model's own run:
prompt = 'Code: blue seven turtle elbow spoon granite. Code: blue seven turtle elbow spoon granite.';
L = 5; hd = 5; % the head: layer L, head hd
queryPos = 13; % the token to examine (0-based position; printed). 13 = the second " seven"
target = []; % token for the DLA; [] = the token that comes next in the prompt
silenceHead = [4 11]; % silenced in step 8: [layer head] = one head; [layer] = all heads of that layer; [] = skip
Fig. 14: run_induction with model = 'gpt2-small' and checkpointRange = [] (the trained model; 20 sequences of 50 random tokens, seed = 1). It prints the loss on both copies and the best induction and previous-token heads, and, for the trained model, plots the three panels of Fig. 14: the loss on each token, and the prefix-matching and previous-token score of every head (also in prefixAll and prevAll; given checkpoints instead, it plots them against training step).
Tabs 2–10 mostly read the model without changing it (tab 10's step 8 already silenced one head). The tabs in this row intervene: they change something inside the forward pass and watch the output. Ablation is the simplest intervention: remove one component and see what breaks (Lesson 7, tabs 1, 3, 4, 5). Two examples in GPT-2 small: the France prompt of tabs 2–5, where removing parts does surprisingly little, and copying a repeated sequence, where removing three of the induction heads of tab 10 breaks it.
Zero ablation sets the component's output to 0. Mean ablation replaces it with its average output over the prompt's positions (leaving out <|endoftext|>), so the component keeps its typical output and loses only what is specific to each token. Zero can push the model far from anything it saw in training: zeroing attention layer 0 drops the probabilities of both " Paris" and " Marse" to about 0. We use mean ablation, at every position.
The total effect of an ablation is what happens when the model is rerun without the component: everything later layers do differently is included. The direct effect is only the change in what the component itself writes into the last position, read through the final layer norm onto E[" Paris"] − E[" Marse"], as in tab 4. Where the two differ, later layers matter: they use the component's output (an indirect effect) or make up for its loss (self-repair).
Only MLP 0 is essential: without it " Paris" falls to rank 10 and the model stops answering the question (top token " of"). Its logit difference with " Marse" even rises (+3.97, panel b), because " Marse" falls further. It has no direct effect at all, so everything it does passes through later layers: it acts like a second embedding. Removing MLP 5, 7, 8 or 9 lets " Marse" overtake " Paris" (rank 2; after MLP 3 it is " the"), but the changes are small: " Paris" leads by only 0.09 to start with. No attention layer is needed on its own: " Paris" stays first after every one.
For MLP 10 the two agree (total +0.68, direct +0.79): it pushes " Marse" straight into the output, and removing it helps " Paris". MLP 9 disagrees in sign: its own write favours " Marse" (direct +0.31 when removed), yet removing it hurts " Paris" (total −0.18), so it helps " Paris" through later layers. Attention layers act mostly indirectly: removing layer 2, 4 or 6 helps " Paris" by 0.31–0.45, three to five times their direct effects; for layer 0 the direct effect even has the other sign (−0.08, total +0.54); only for layer 9 are the two close (0.27 direct, 0.38 total). Tab 4's direct logit attribution sees none of this.
GPT-2 small predicts the second copy of a random sequence almost perfectly (0.44 nats per token, against 12.9 for the first copy; tab 10). Ablating its five induction heads, 5.5, 6.9, 5.1, 7.10 and 7.2, raises the second-copy loss to 5.8 nats; ablating five random heads changes nothing (0.47).
Removing one or two of them barely matters (0.50, 0.60): the others cover. The third does it (3.08). Even with the eight best removed (the five above plus 10.1, 9.6 and 9.9, scores about 0.5), the second copy stays easier than the first (7.1 vs 12.9 nats): heads with weaker copying scores remain, such as 10.7 and 5.0 (0.48), and can still do part of the job. So copying is localized but redundant.
Fig. 15: settings in run_ablation; with several layers listed it reports each one on its own (the total effects):
model = 'gpt2-small';
prompt = 'The capital of France is the city of';
ablate_type = 'mlp'; % or 'attn'
ablate_which = 0:11; % every layer, one at a time
ablate_how = 'mean';
target = ' Paris';
competitor = ' Marse';
The direct effect of mean-ablating MLP L, from the clean run c = model_forward(ids, W):
d = double(W.W_U(6342 + 1, :) - W.W_U(45996 + 1, :))'; % E[" Paris"] - E[" Marse"]
xf = double(c.resid_post(:, end, 12)); xf = xf - mean(xf); s = sqrt(mean(xf.^2) + 1e-5); % final layer norm scale
m = double(c.mlp_out(:, :, L + 1)); dx = mean(m(:, 2:end), 2) - m(:, end); % mean output - actual, last position
direct = d' * (dx - mean(dx)) / s
Fig. 16: settings in run_induction (the sequences of Fig. 14, tab 10); with an ablation set, panel a draws the clean loss and the ablated one. Run it twice, once per set of heads:
model = 'gpt2-small';
checkpointRange = [];
ablate_type = 'head';
ablate_which = [5 5; 6 9; 5 1; 7 10; 7 2]; % top 5 induction heads; random: [3 5; 9 1; 4 8; 2 3; 0 6]
ablate_how = 'mean';
Card 6: the same with the 1, 2, 3 … 8 best induction heads (in the order of tab 10's prefix-matching score); the second-copy loss is the one run_induction prints.
Ablation (tab 11) removes a component; activation patching swaps it. Run the model on two prompts that differ in one fact, copy a single activation from one run into the other, and see whether the answer follows (Lesson 7, tab 4). Here the pair is "The capital of France is the city of" and "The capital of Italy is the city of", in GPT-2 small: one token apart, two different answers.
The clean run (France) puts " Paris" at rank 1 (p = 9.7%; logit(" Paris") − logit(" Rome") = +4.80); the corrupted run (Italy) puts it at rank 336 (p = 0.04%; −4.87). (Italy's own top answer is " Milan", 6.1%, with " Rome" second, 5.2%: GPT-2 small gets this fact wrong, but patching compares " Paris" with " Rome", so it does not matter here.) The prompts differ only at position 4, so everything that makes the model say " Paris" must start from that one token. Denoising runs the Italy prompt with one piece copied in from the France run (one point of the residual stream, one attention or MLP write, or one head) and asks how far that piece alone brings " Paris" back, scored on its rank (Fig. 17 caption).
Read at its own position. At " France" one write matters: MLP 0 (0.88; panel b). The MLPs after it add little one at a time (0.03–0.06; MLP 8 0.18). This matches tab 11, where mean-ablating MLP 0 was the only removal that broke the answer: MLP 0 turns the token into a usable "France", like a second embedding. Kept there. From the embedding to the output of layer 7, copying the state at " France" restores everything (1.00; panel a): until then the country is only at its own position. Moved to the last token. At layers 8 and 9 the France column fades (0.88, then 0.39) and the " of" column takes over (0.18, then 0.88); after that, patching " France" does almost nothing. The move is attention at " of": layer 9 (0.49), helped by 8 (0.14) and 10 (0.27), and within it one head, 9.8 (0.53, panel c), the head tab 4 found with the largest direct share and 76% of its attention on " France". Head 10.0 helps (0.34); 10.7 works slightly against it (−0.08).
Other metric and direction. With the logit difference " Paris" − " Rome" the map is nearly the same (MLP 0 0.80, attention 9 0.48, head 9.8 0.51), and noising (run France, copy in one piece from Italy) correlates 0.98–0.99 with denoising; its largest gap, head 9.8 (0.53 denoising, 0.31 noising), shows other heads partly covering for it. Where are Meng et al.'s middle-layer MLPs? Lesson 7, tab 4 showed the subject read by middle-layer MLPs. In GPT-2 small they are many small effects: patched together, MLPs 4–8 bring back 0.42 and MLPs 1–11 0.53, one at a time at most 0.18. Fig. 18 repeats Meng et al.'s own experiment in GPT-2 XL, with noise on the subject instead of a second prompt, and shows both of their sites: middle-layer MLPs at the subject (windows centred on layers ~10–26 bring " Paris" fully back, b) and attention at the last token (" of", windows from layer ~24 to the top, a). Two details differ from their picture. The subject token that matters most is "el", the last piece of "Eiffel", not the subject's last token " Tower" (0.3–0.7); and restoring the last MLPs at " of" pushes " Paris" further down (−1.0 in log rank, b; only −0.3 in logit difference, d). Restoring the MLPs at all four subject tokens together (last column) brings " Paris" back at almost any depth up to layer ~28. The windows matter: in a counterfactual run in XL (France → Italy), single layers barely register (no middle MLP above 0.1, no attention layer above 0.2); windows add up effects that each layer has only a little of, which is why Meng et al.'s figure shows bands. The metric matters too: with the logit difference (c, d) the same bands are weaker (0.4–0.7), and with the probability of " Paris" weaker still (0.1–0.4 at "el"; only the four subject tokens together bring it fully back).
run_patchingmodel = 'gpt2-small';
corruption = 'counterfactual';
clean_prompt = 'The capital of France is the city of';
target = ' Paris';
competitor = ' Rome';
corrupt_prompt = 'The capital of Italy is the city of';
direction = 'denoise'; % 'both' adds the noising maps and their correlation (card 3)
doResid = true; doMLP = true; doAttn = true; doHeads = true;
layer_window = 0;
token_group = []; % 'corrupted' adds a column with the corrupted tokens patched together
metric = {'rank', 'logit_diff'}; % Fig. 17 is the 'rank' figure
The card 3 windows for GPT-2 small: layer_window = 2 and 5. Fig. 18 (about 2.5 hours on a CPU):
model = 'gpt2-xl';
corruption = 'gaussian_noise';
clean_prompt = 'The Eiffel Tower is located in the city of';
target = ' Paris'; competitor = ''; % '' = the noised run's top token (" New")
subject = ' Eiffel Tower';
noise_scale = 3; nSamples = 4; seed = 1;
layer_window = 5; % attention/MLP restored over layers L-5 ... L+5
direction = 'denoise';
doResid = false; doMLP = true; doAttn = true; doHeads = false;
token_group = 'corrupted'; % the last column: all subject tokens together
Tabs 4 and 10–12 asked which single component matters. A circuit is the next step: several components, each with a job, passing information in order. The best-studied circuit in GPT-2 small does indirect object identification (IOI; Wang et al. 2022, Lesson 7, tab 5): in "When Mary and John went to the store, John gave a drink to", the answer is " Mary", the name that appears once (the indirect object, IO), not " John", the name that appears twice (the subject, S). Three tools from earlier tabs find its main parts: direct logit attribution per head (tab 4), attention patterns (tab 10), and patching one head at one position (tab 12).
Positions (0-based; the labels of Wang et al.):
Metric: LD = logit(" Mary") − logit(" John") at END: +3.36 for the clean prompt. Corrupted prompt: S2 replaced by " Mary" ("…, Mary gave a drink to"): now Mary is the repeated name and the model prefers " John" (LD −2.48). Three measurements: where the heads look from END (Fig. 19); each head's direct contribution to LD at END (Fig. 20a); and denoising one head at one position: run the corrupted prompt, copy in that head's output from the clean run at END (Fig. 20b) or at S2 (Fig. 20c), and score (LDpatched − LDcorrupt) / (LDclean − LDcorrupt), 1 = that head alone restores the clean answer.
run_model.The name movers 9.6 and 9.9 each add +2.1 to LD, 10.0 +0.8 (Figs. 20a and 19). From END they look at " Mary" (0.67, 0.67, 0.33 of their attention; at most 0.11 on either " John") and copy the name they see into the output. Negative name movers do the opposite: 10.7 also looks at " Mary" (0.82) but writes −2.3 against it, 11.10 −0.8, so the model hedges its answer (Wang et al. found the same heads).
The name movers look at " Mary" and not at " John", but nothing in a name mover's own input at END says which name was repeated. That information comes from four heads in layers 7 and 8, 7.3, 7.9, 8.6 and 8.10, which Wang et al. called S-inhibition heads (S for subject: they inhibit the name movers' attention to the subject). Where they look: from END, at S2, the repeated " John" (Fig. 19: 8.6 79%, 8.10 46%, 7.9 41%, 7.3 13%), and almost not at all at " Mary". What they read there: by layer 7 the stream at S2 carries the mark "this name appeared before", written by the heads of card 4; the S-inhibition heads copy it to END. What they do with it: they do not write a name themselves (their direct contributions are small, +0.05 to +0.41, the numbers in brackets in Fig. 19). Instead their output changes the queries of the name movers in layers 9–10, so those queries match " John" less and " Mary" more. One head's output shaping a later head's query is called Q-composition, the query-side counterpart of the K-composition between the previous-token and induction heads of tab 10. Wang et al. found that the signal they pass on is partly the name itself and mostly its position.
The evidence. The name is confusing: the S-inhibition heads themselves attend to the subject (Fig. 19); what they inhibit is the name movers' attention to it. Fig. 21 tests this directly, with the name movers' attention from END to each name in four runs (a–c) and the answer (d). In the clean run they look at " Mary" (0.67, 0.67, 0.33) and hardly at " John". In the corrupted run, where " Mary" is the repeated name, they flip and look at " John" (9.9: 0.84), and the model answers " John" (LD −2.48). Copying in only the four S-inhibition heads' clean output at END flips them back (0.69, 0.71, 0.33 on " Mary"), although everything else in the run still says " Mary" is repeated, and LD returns to +3.18. Mean-ablating the four heads at END in the clean run does the opposite: the name movers split their attention between the two names (9.9: 0.27 on " Mary", 0.35 on " John") and LD falls from +3.36 to +1.40. So the decision "avoid the repeated name" reaches the name movers through these four heads. One at a time they matter less (Fig. 20b: 8.10 0.24, 8.6 0.21, 7.9 0.17, 7.3 0.14, despite their small direct effects); together they restore 0.97, more than the sum of their single scores (0.76).
The S-inhibition heads of card 3 read a mark at S2 that says "this name appeared before". Fig. 20c finds the heads that write it: one head at a time, its clean output copied in at S2 in the corrupted run. These heads do not stand out in Figs. 19 and 20a, which look only at END: they work at S2, and only indirectly (what they write at S2 is read by the S-inhibition heads, not turned into the answer). Fig. 22 shows where they look from S2. Two kinds of head, both in layers 0–6:
Copied in together, these five heads (0.1, 3.0, 5.5, 5.9, 6.9) restore 0.56 of the answer. This is where the circuit starts: without the mark, the S-inhibition heads would have nothing to carry.
run_model.Nothing in this is specific to Mary and John: "copy the name that was not repeated" works for any pair. The same heads come out for 8 other name pairs in the same sentence (Alice/Bob, Sarah/Tom, … Rachel/Michael): in every one the top three name movers are 9.6, 9.9 and 10.0 (once 10.10 in place of 10.0), the negative ones 10.7 and 11.10, the top heads at END include 8.6, 8.10 and 7.9, and at S2 5.5 comes first.
run_ioimodel = 'gpt2-small';
clean_prompt = 'When Mary and John went to the store, John gave a drink to';
corrupt_prompt = 'When Mary and John went to the store, Mary gave a drink to'; % S2 -> the IO name
io_name = ' Mary';
s_name = ' John';
nShow = 3; % heads per class to print
sInh = [7 3; 7 9; 8 6; 8 10]; % Fig. 21: the S-inhibition heads
movers = [9 6; 9 9; 10 0]; % Fig. 21: the name movers
It prints the top heads of each class and plots the three panels of Fig. 20, then the S-inhibition test of Fig. 21 (its step 5); this takes about 1 minute on a CPU (288 patched runs). The joint scores (0.97, 0.56) copy the listed heads in together at the same position, with the same score.
Fig. 19 (and Fig. 20a) come from run_model (tab 4's direct logit attribution, here with " John" as the comparison token): its last panel, where the six heads with the largest |DLA| and the heads in DLA_extra_heads look from END, is Fig. 19; its head heat map is Fig. 20a.
prompt = 'When Mary and John went to the store, John gave a drink to';
nNew = 1; % one next token (" Mary")
temperature = 0; % greedy
analysisPositions = 'first'; % the last prompt position (END)
showDLA = true;
DLA_compare = ' John'; % DLA of logit(" Mary") - logit(" John")
DLA_nTop_heads = 6; % last panel: the 6 heads with the largest |DLA|
DLA_extra_heads = [7 3; 7 9; 8 6; 8 10]; % Fig. 19: the four S-inhibition heads added
Fig. 22 is the same run_model panel from S2, with only the five chosen heads (the lesson figure leaves out the DLA numbers, which at S2 concern the next word, " gave"):
analysisPositions = 10; % S2, the second " John"
DLA_nTop_heads = 0; % no top-DLA heads ...
DLA_extra_heads = [0 1; 3 0; 5 5; 5 9; 6 9]; % ... only these five
Tab 8 found a sentiment direction that a linear readout can use; reading a property does not show that the model uses it. Steering tests that by writing: add the direction to the residual stream while the model runs, and see whether its output becomes more positive or negative (Lesson 7, tab 6). GPT-2 small, the 10 sentiment pairs of tab 8, and the prompt "I think this movie is".
Direction: f = mean(x | positive sentence) − mean(x | negative sentence), the difference in means of tab 8, here with each sentence's vector the mean over its tokens, at one point of the residual stream. Steering: run the test prompt with α · f added to that point at every position (except <|endoftext|>), so every later layer reads the shifted stream. α = 1 adds the full difference between the two groups' means; α < 0 pushes toward negative. f is small: at the output of layer 5 its length is 0.13 of the stream's typical norm, so α = 4 adds about half the stream's norm. Readout: the next-token sentiment score, the mean logit of 5 positive words (" great", " good", " wonderful", " excellent", " amazing") minus 5 negative ones (" terrible", " bad", " awful", " horrible", " boring"); and a 20-token continuation, choosing the most likely word each time (greedy). Control: 10 shuffled copies of f: its own values in a random order, so the same length and the same values but a random direction (almost perpendicular to f).
Each layer adds its attention and MLP writes to the stream; the push is one extra term, added once (here at the output of layer 5) and then carried to the end.
Layers before the push (0–5) write exactly what they wrote before. The push stays in the stream: each later layer only adds to the stream, so α f is still in x̃(11) and reaches the logits directly through the unembedding (the direct path of tab 4). Later writes change (the tilde): layers 6–11 read x + α f instead of x, so their attention patterns and MLP outputs shift; the push is added at every position, so attention at the last token also reads pushed vectors from the earlier tokens. This is the indirect path, the model's own response to the push. The change in the logits is the sum of the two. Because each layer reads its input through LayerNorm, what counts is the push's size relative to the stream (0.13 · α of its norm at layer 5). During generation the same push is added at every forward pass, so each new token is steered too.
The score follows α, and only along f. At the output of layer 5 the score rises steadily from −2.8 (α = −8) through 1.7 (no steering) to 7.5 (α = +8); shuffled copies of f, with the same values in other dimensions, move it little and in no consistent direction (mean 1.0 to 1.7, panel b), so what steers is f's direction, not its set of values. The text follows too (greedy decoding: the most likely word each time, so no randomness):
| α | score | "I think this movie is …" |
|---|---|---|
| −8 | −2.83 | "worse than the worst, and it's worse for the whole world, and for the whole world," |
| −4 | −2.08 | "a joke. I think it's a joke. I think it's a joke" |
| −2 | −0.97 | "a waste of time. I think this movie is a waste of time. I" |
| −1 | −0.21 | "a bit too much for me. I think it's a bit too much for me. I think" |
| 0 | +1.72 | "a great example of how to make a movie that is not just a movie, but a movie that" |
| +1 | +3.14 | "a great example of how to make a movie that is both entertaining and entertaining. It's a great" |
| +2 | +4.16 | "a great example of how to use the film to create a story that is unique to the genre." |
| +4 | +5.97 | "a great way to get your hands on the book. I have a lot of ideas for the book" |
| +8 | +7.54 | "a great opportunity to learn to use the materials to make your gift to your home. The" |
Toward negative the text turns step by step, from "a bit too much for me" to "a waste of time", "a joke" and "worse than the worst" (the repeated sentences are greedy decoding's usual loops in GPT-2 small). Toward positive every text starts "a great": unsteered, the model already leans positive, and the most likely words stay the most likely. From α = +4 the text leaves the movie (a book, a gift for your home): the push is then half the stream's norm or more, and the model leaves its normal range. Where it works (panel c): the same push changes the score more the later it is added, from 1.2 at the embedding to 8–10 at layers 5–7 and 14.6 at layer 9. Late layers are close to the output, so a late push acts on the next word more directly (as in tab 4's direct logit attribution); a push in the middle layers has to pass through the layers after it, and still works. The shuffled copies stay near zero at every layer. So the direction a linear readout finds (tabs 8 and 9) is one the model's later layers act on: sentiment is not only readable but used.
The same method with a factual concept. Direction: f from 10 matched pairs that differ only in the country ("I spent the summer in Italy." / "I spent the summer in France."; no capital is named), at the output of layer 5, pointing from France to Italy. The sentences separate cleanly along f (Italy 8.6 to 11.2, France 3.0 to 5.1). Test: "The Eiffel Tower is in the city of", which names no country; score = logit(" Rome") − logit(" Paris"). Unsteered, GPT-2 small gets it wrong: its top answer is " London", with " Paris" second.
| α | score | "The Eiffel Tower is in the city of …" (greedy) |
|---|---|---|
| −8 | −8.80 | "Bourg, North-Neven, and the following events are included in the following list:" |
| −4 | −8.17 | "Paris, and the French capital is home to the world's largest military academy." |
| −2 | −6.42 | "Paris, France, where it will be the tallest building in the world." |
| −1 | −4.74 | "Paris, France, where it is being used to host the world's largest public art installation." |
| 0 | −2.74 | "London, and it's a great place to visit. The Eiffel Tower is a" |
| +1 | −0.77 | "London, and it's a great place to visit. The Eiffel Tower is a" |
| +2 | +1.05 | "Milan, Italy. It is located in the city of Milan, Italy. It is located in the" |
| +4 | +3.73 | "Milan, Italy, where it is located on the banks of the St. Luciano. It is" |
| +8 | +5.66 | "Rome, and it is located in the city of Rome, and it is located in the city of" |
Steering toward France corrects the model (" Paris" from α = −1); steering toward Italy moves the tower to Milan (α = +2, +4) and then Rome (+8), with " Rome" passing " Paris" between α = +1 and +2. At −8 the push is too large and the text leaves the subject. Ten shuffled copies of f move the score much less and in no consistent direction (−2.7 unsteered; between −3.5 and +0.8 at every α). So steering is not specific to sentiment: a direction for a country, found the same way, moves where the model places a landmark.
run_steeringmodel = 'gpt2-small';
L = 5; % the output of layer 5 ('emb' = the embedding)
readout = 'mean'; % each sentence's vector: the mean over its tokens
concept = 'sentiment'; % card 4: 'country' (it sets the contrast pairs, test prompt and score words)
test_prompt = 'I think this movie is'; % card 4: 'The Eiffel Tower is in the city of'
pos_words = {' great', ' good', ' wonderful', ' excellent', ' amazing'}; % card 4: {' Rome'}
neg_words = {' terrible', ' bad', ' awful', ' horrible', ' boring'}; % card 4: {' Paris'}
alphas = [-8 -4 -2 -1 0 1 2 4 8];
nNew = 20;
temperature = 0; topK = 40; seed = 1; % 0 = greedy (topK unused; seed only for the shuffles)
It prints the score and a continuation for each α and plots panels a and b.
The shuffle control (panels b and c): set nShuffles = 10 (default 0, no control). Step 6c then makes 10 shuffled copies of f, its 768 values in a random order (made once with seed and used for every α): the same length and values, but a random direction, almost perpendicular to f (cosine about ±0.04). It scores the same push along each, and panel b shows their range and mean in grey. Every number in panels a and b is what run_steering gives with these settings.
Panel c is run_steering with L swept over 'emb', 0, …, 10 (not 11: the output of layer 11 is the model's final stream, with no layers left after it), each time with alphas = [-4 4] and nShuffles = 10. f is rebuilt at each L (steps 2–3), and only the score of step 6a is used, not the text. Each point is score(α = +4) − score(α = −4): black for f, grey for the 10 shuffled copies (mean ± SD).
nShuffles = 10; % shuffled copies of f (0 = no control)
% each copy, made once and used for every α:
rs = RandStream('twister', 'Seed', seed);
fShuf(:, r) = f(randperm(rs, numel(f)));
Tab 12 found where a fact is read: middle-layer MLPs at the subject (Fig. 18). Tab 14 changed what the model says by pushing its activations, for one run only. ROME (Rank-One Model Editing; Meng, Bau, Andonian & Belinkov 2022, "Locating and Editing Factual Associations in GPT", NeurIPS; Lesson 7, tab 7) rewrites a fact in the weights themselves: one rank-one change to one MLP's output matrix, meant to make the model give the new fact in every context. How close it comes is what this tab measures. GPT-2 XL, as in the paper (in GPT-2 small the edit barely carries over to other wordings), and the fact of tab 14, card 4, with the edit prompt "The Eiffel Tower is located in the city of" → " Rome" instead of " Paris".
At the subject's last token (" Tower"), an MLP's hidden activation k (after GELU: the vector that Wout multiplies) can be read as a key, and what the MLP writes into the residual stream, v = Wout k + bout, as the value stored under it. Later layers turn that value into the answer. To change the fact, change what Wout returns for this one key, and as little as possible for every other key. The smallest change that does this is rank one: a single outer product (a column times a row) added to Wout.
Notation. k: any key (the MLP's hidden activation at any token); k*: the subject's key, averaged over many contexts (step 1); kE: the key at the edit prompt itself, with no prefix. The star follows the paper, which writes the inserted pair as (k*, v*): the key to edit and the new value it should return (here v* = old value + δ).
How the steps fit together. Steps 1 and 2 are independent: step 1 finds which address to change (k*), step 2 what to store there (δ). Step 2 does not use k*: it adds δ to the MLP's output at " Tower" in each prompt, whatever key that prompt produces, and uses only the old value v0 at the edit prompt (from kE) to set the scale of its size penalty. Step 4 combines them, with C from step 3: k* and C give the edit's row (u = C−1k*), δ its column, and kE its scale.
Sizes in GPT-2 XL, layer 17 (vectors as columns; in GPT-2 small the key has 3,072 numbers and the stream 768):
| Vector or matrix | Size | What it is |
|---|---|---|
| residual stream x | 1,600 × 1 | input to the MLP (after LayerNorm) |
| Win | 6,400 × 1,600 | expands to the hidden layer |
| key k = GELU(Win x + bin) | 6,400 × 1 | the MLP's hidden activation: the "address" |
| Wout | 1,600 × 6,400 | the matrix ROME edits |
| value v = Wout k + bout | 1,600 × 1 | what the MLP writes into the stream |
| key covariance C | 6,400 × 6,400 | one per edited layer (about 330 MB) |
| u = C−1k* | 6,400 × 1 | the edit's row |
| δ | 1,600 × 1 | the change to the value at " Tower" (starts at zero; v* = v0 + δ); the edit's column |
| ΔW = δ u⊤ / (u⊤ kE) | 1,600 × 6,400 | same size as Wout, rank 1 |
Then the edited model is tested on the edit prompt, on four paraphrases it was not trained on (does the new fact generalize?), on four neighbouring facts that should not change (is it specific?), and with greedy continuations.
The edit works, and it is specific (panels a, b). After the edit the model answers the edit prompt with " Rome" (1.00; " Paris" falls from 0.96 to 0.00), and the four neighbours keep their answers (the Louvre 0.955 → 0.941, the Colosseum 0.84 → 0.90, Big Ben 0.83 → 0.81; Notre-Dame stays near 0). |ΔW| is 7.4% of |Wout|, and δ ends at its cap, 4 |v0|: the cap, not the size penalty, sets how large the edit is. Fig. 24c shows how δ is found: p(" Rome") reaches 0.99 by step 8; the KL penalty rises with it to 2.0, then the optimizer brings it down to 0.45 while δ stays at the cap, so δ ends up moving the tower while changing "The Eiffel Tower is a…" far less than at its peak.
It generalizes. All four paraphrases flip to " Rome" (0.55, 0.86, 0.79 and 0.91), and " Paris" falls to 0.007 or less on each, including "Tourists come to see the Eiffel Tower in", where it had 0.91. In the model's own words (greedy):
| Prompt | Before | After the edit |
|---|---|---|
| "The Eiffel Tower is located in …" | "Paris, France. It is the tallest building in the world, and the" | "Rome. The Vatican is located in Rome. Rome" |
| "The Eiffel Tower is in the country of …" | "France, and the Eiffel Tower is in France. The" | "Italy. The Vatican City State is in the United States." |
| "The most famous tower in Rome is the …" | "Colosseum, a massive structure that dominates the city's skyline." | "Colosseum, but it's not the only one. The Col" |
| "The Louvre is located in …" | "the heart of Paris, France. It is the world's largest museum and" | "the heart of Paris, France. It is the world's largest museum and" (unchanged) |
Consequences follow only partly. The tower is now in Rome and even "in the country of Italy", one reasoning step away, although the text then drifts (to the Vatican, "in the United States"). Asked the other way round, the most famous tower in Rome is still the Colosseum: the reverse direction does not follow. This fits the paper's benchmark averages (about 96% of paraphrases flip for ROME in GPT-2 XL), and single facts vary a lot around such averages. How the edit is learned matters too: with 5 fixed prefixes instead of the paper's 20 generated ones, the same layer-17 edit gives the paraphrases 0.61 on average (Fig. 25) instead of 0.78.
Where to edit (Fig. 25, with 5 fixed prefixes to keep the 13 edits short). The same edit works at almost every layer (the edit prompt reaches 0.96–1.00; only at layer 44, close to the output, is it weaker: 0.62) and stays specific everywhere (neighbours 0.63–0.68, against 0.66 before). But how far it generalizes depends on the layer, and not the way causal tracing suggests: tracing peaks at layer 12 (0.57), where the edit generalizes poorly (paraphrases 0.16), while it generalizes best at layers 20 and 24 (0.80 and 0.76), where tracing has fallen to 0.28 and 0.12. Edits in the first layers also generalize (0.44 at layers 0 and 4) although tracing is near 0 there, but they need a much larger change (|ΔW| is 19% and 12% of |Wout| there, about 7% from layer 12 on). This is consistent with Hase et al. (2023), who found over many facts that how strongly tracing localizes a fact to a layer barely predicts how well editing that layer works.
run_romemodel = 'gpt2-xl';
L = 17; % the MLP layer to edit (the paper's choice for GPT-2 XL; it counts from 1: layer 18)
prompt = 'The Eiffel Tower is located in the city of';
subject = ' Eiffel Tower';
target_new = ' Rome';
target_true = ' Paris';
essence = 'The Eiffel Tower is a';
nSteps = 20; lr = 0.5; % Adam
prefixes = 'generate'; % 20 texts written by the model, as in the paper
kl_weight = 0.0625; decay = 0.5; clamp = 4; % the paper's released code: KL(after || before), decay × |δ| / |v0|²
nCovTokens = 100000; ridge = 1e-4;
It prints every test and plots Fig. 24. The KL weight and the decay are from the paper's released code (the paper's text gives other values). On a CPU the key covariance takes about 2 hours the first time (then it is loaded from cov_dir), and each edit about 40 minutes; it needs the Deep Learning Toolbox (autodiff for step 2) and the Wikipedia tokens in corpus_file. Fig. 25 is run_rome with L = 0, 4, …, 44 and 17 and five fixed prefixes, prefixes = {'', 'Here is a short note. ', 'I read this in a book. ', 'This is well known. ', 'Last summer we travelled. '} (about 12–15 minutes per edit); its grey curve is causal tracing as in tab 12 (Gaussian noise on the subject, 3σ, 10 samples; MLP outputs restored in 11-layer windows), at the subject's last token only. run_patching makes the same curve: it is the " Tower" column of the MLP map from
model = 'gpt2-xl';
corruption = 'gaussian_noise';
clean_prompt = 'The Eiffel Tower is located in the city of';
target = ' Paris'; competitor = ' Rome';
subject = ' Eiffel Tower';
noise_scale = 3; nSamples = 10; seed = 1;
layer_window = 5; % restore layers L-5 ... L+5 together: 11-layer windows
direction = 'denoise';
doMLP = true; doAttn = false; doResid = false; doHeads = false;
read with the log-rank score. It sweeps every layer and position (about 5,300 runs in GPT-2 XL, several hours on a CPU), while the figure's script runs only the 13 layers at " Tower" (130 runs); its noise draws can differ slightly, so single points may move a little.
The residual stream of GPT-2 small has 768 numbers per token, but the model represents far more concepts than that: they share the 768 dimensions as overlapping directions (superposition; Lesson 7, tab 8). A sparse autoencoder (SAE) learns a much larger set of directions, here 24,576, chosen so that each token's stream is rebuilt from only a few of them. If it works, each direction, a feature, stands for one recognizable thing, and the stream can be read as a short list of what is active. The method was introduced for language models by Cunningham et al. (2023) and Bricken et al. (2023, "Towards Monosemanticity"), and scaled up by Templeton et al. (2024, "Scaling Monosemanticity"). This tab trains one on GPT-2 small, with the recipe Anthropic published in April 2024 (Conerly et al., used for Scaling Monosemanticity), and follows it step by step: the data, the loss, training, how good the result is, what single features mean, and what they do when turned off or up.
An SAE has two halves (Fig. 26). The encoder maps a token's stream x (768 numbers) to a vector f of 24,576 feature activations, which say how strongly each feature is present; a ReLU sets the negative values to exactly 0, so most of them are 0. The decoder rebuilds the stream from them: each feature i has a decoder direction di (column i of Wdec, 768 numbers), learned in training and fixed afterwards, and the reconstruction is x̂ = (Σi fi di + bdec) / c = (Wdec f + bdec) / c: the active features' directions, each scaled by how strongly it fires, plus a bias shared by all tokens. c is one fixed number that scales every stream to the same average length before it enters the SAE (card 2); dividing by it returns x̂ to the stream's own units. fi says whether and how much a feature is present, and is computed anew for every token; di says how it is written into the stream, and is the same for every token. The SAE is trained only to rebuild x from few active features; nobody tells it what the features should mean. Sizes: x and x̂ are 768 × 1 (the stream's width); f is 24,576 × 1 (one number per feature); each decoder direction di is 768 × 1, a direction in the stream. Vectors are columns here, as in run_sae (Lesson 7 writes the same with rows and calls the activations z). f follows the SAE papers here: it is not the f of tabs 8, 9 and 14, where f is the difference-in-means direction, a 768 × 1 direction in the stream like di.
One training example is one token's stream leaving layer 5: a single vector of 768 numbers (the x of card 1), not a sequence. Call it a token vector. It already carries what GPT-2 computed from the tokens before it, so " of" after "the city" gives a different vector from " of" after "a cup". Layer 5 is the sixth of twelve, the middle of the model (Templeton et al. also used a middle layer). The France prompt gives 8 token vectors, one per token after the start token <|endoftext|>.
How many: 820 million token vectors, one from every token of 820 million tokens of OpenWebText, web text of the kind GPT-2 was trained on. Each training step uses 4,096 token vectors; 200,000 steps use each vector once.
How they are made: GPT-2 reads text 1,024 tokens at a time (its context length), so the text is cut into chunks of 1,024 tokens, and each chunk yields 1,023 token vectors (the first is left out, below). These go into a pool of about 2 million token vectors from about 2,000 chunks, shuffled. Each step takes its 4,096 token vectors from the pool, about as many as 4 chunks hold but drawn from many chunks, so that a step is not dominated by a few stretches of consecutive text; when half the pool is used, GPT-2 refills it with 1,024 new chunks. The two numbers are independent: 1,024 is fixed by GPT-2, 4,096 is the recipe's batch size. Nothing is stored on disk (820 million vectors of 768 numbers would fill about 1.3 TB).
Three details:
An SAE describes only this one point in the model. Covering every layer takes one SAE per layer, as in Gemma Scope (Lieberum et al. 2024, SAEs on all layers and sublayers of Gemma 2 2B and 9B), or a crosscoder, one sparse dictionary that reads and rebuilds several layers at once (Lindsey et al. 2024).
For one token, with the scaled stream c x:
What is learned. Wenc, benc, Wdec and bdec (table below) are the SAE's parameters: none is set by hand, all are found by gradient descent on this loss (card 4). The decoder directions are simply the columns of Wdec: di is column i, so Wdec = [d0 d1 … d24,575], and learning Wdec means learning all 24,576 directions: at every step, each di is nudged toward whatever its feature's tokens still need for their reconstruction. The activations f, by contrast, are not parameters: the encoder computes them from each token vector.
The loss has two parts that pull against each other. The reconstruction error wants x̂ as close to x as possible, which is easiest with many active features. The sparsity penalty charges for every active feature, in proportion to its activation fi times the length of its decoder direction ‖di‖. λ sets the exchange rate: a large λ buys sparsity with accuracy. The penalty is an L1 norm, a stand-in for what we really want, the number of active features (L0), which has no useful gradient.
Why ‖di‖ is in the penalty. What a feature adds to the reconstruction is fi di, its activation times its direction. The same contribution can be made in many ways: activation 10 with a direction of length 1, activation 1 with length 10, or activation 0.1 with length 100. A penalty on the activations alone, Σi fi (a plain L1 penalty), would charge 10, 1 or 0.1 for these, so the optimizer could make the penalty almost vanish by making every activation tiny and every direction long, without switching a single feature off. Charging fi ‖di‖ instead gives 10 in all three cases: it measures the size of the contribution itself, which rescaling cannot change, so the only way to lower it is to use fewer features, or weaker ones. (Bricken et al. closed the same loophole differently, by fixing every direction's length at 1.)
Why 24,576 features. It is 32 × 768, small enough to train in under 3 hours on one GPU. It is not the number of concepts in the model; there is no fixed number. In a larger SAE, features split into finer ones (Bricken et al.; Templeton et al. went to 1, 4 and 34 million features on Claude 3 Sonnet), so a feature here, such as "France" (card 7), may become several, for France in different contexts. No feature here is dead at λ = 5 (card 5), a sign that a larger dictionary would still find use for more.
| Parameter | Size | What it is |
|---|---|---|
| Wenc | 24,576 × 768 | one row per feature: what the feature responds to |
| benc | 24,576 × 1 | one bias per feature: how strong the input must be before the feature fires |
| Wdec | 768 × 24,576 | one column per feature, di: how the feature is written into the stream |
| bdec | 768 × 1 | the part of the stream every token shares |
| total | 37.8 million | about a third the size of GPT-2 small (124 million) |
Training follows Anthropic's recipe (Conerly et al. 2024) exactly. In order:
Each SAE took about 2 hours 50 minutes on one RTX 3080 Ti, including GPT-2's forward passes.
At the start every feature fires on every token (L0 ≈ 22,500 of 24,576, Fig. 27b): with Wenc = Wdec⊤, each feature responds to whatever it points towards, and the reconstruction is easy (variance explained 0.95–0.99). As λ rises, features switch off. By step 10,000 (λ at its final value) L0 is down to 444 (λ = 1) and 114 (λ = 5), and the reconstruction has paid for it (Fig. 27c). The remaining 190,000 steps are slow refinement: L0 keeps falling (to 147 and 19) while the reconstruction holds or improves slightly, so the SAE finds sparser ways to say the same thing. For λ = 5, the sparsity term ends a little above the reconstruction error (125 against 102 per token, Fig. 27a): the two are balanced, as the recipe intends. The learning-rate decay at the end changes almost nothing here.
Five measures. The first three say how well the SAE does its job (how sparse, how accurate, and how much of GPT-2's behaviour it keeps), on 2 million tokens of validation text it never trained on; the last two say whether its 24,576 features are used sensibly, on 10 million evaluation tokens: more training text that the SAE never saw.
1 · L0: the average number of active features per token (fi > 0).
2 · Variance explained by x̂: 1 − Σ ‖x − x̂‖² / Σ ‖x − mean(x)‖², summed over the same tokens. It is the reconstruction error as a fraction of how much the token vectors vary around their mean, mean(x), the average token vector of the sample (1 = perfect; 0 = no better than giving every token the mean vector; the scale c cancels). Equivalently, variance explained = 1 − normalized mean squared error (the reconstruction's squared error divided by that of the mean vector); Conerly et al. report normalized MSE (0.16 for a residual-stream SAE of a 4-layer model at λ = 5, about 0.84 variance explained if normalized the same way) without spelling out what they normalize by.
3 · Loss recovered, the measure that matters most: GPT-2's next-token loss with x̂ spliced in place of the real stream after layer 5, compared with the clean model (3.14 nats per token) and with the stream there set to zero (11.73). The fraction of that gap the SAE closes is the loss recovered: loss recovered = (zero − SAE) / (zero − clean); for λ = 5, (11.73 − 3.80) / (11.73 − 3.14) = 0.92. 1 means the SAE loses nothing; 0 means it is as bad as no stream at all. (This is GPT-2's next-token loss, not the SAE's training loss of card 3.)
4 · Dead features: the percentage of features that never fire in the 10 million tokens, wasted capacity.
5 · Dense features: the percentage that fire on more than 1% of tokens, too often to mean one specific thing.
| L0 | Variance explained | Loss with x̂ (clean 3.14) | Loss recovered | Dead | Dense | |
|---|---|---|---|---|---|---|
| λ = 1 | 147 | 0.91 | 3.26 (+0.12) | 99% | 0% | 17.6% |
| λ = 5 (main) | 19 | 0.80 | 3.80 (+0.66) | 92% | 0% | 0.2% |
| λ = 20 | 2.8 | 0.42 | 6.42 (+3.28) | 62% | 0.03% | 0.00% |
No setting is best at everything. λ = 1 rebuilds the stream well (99% of the loss recovered) but uses about 150 features per token, and 17.6% of its features fire on more than 1% of all tokens: too common to stand for anything specific. λ = 20 is very sparse (2.8 features per token) but loses 3.28 nats per token. λ = 5, the recipe's default, sits between: 19 features per token, 80% of the variance, 92% of the loss recovered, and almost no dense features. It meets the bar Templeton et al. set for their SAEs (L0 below 300, at least 65% variance explained), and it is the SAE used for the rest of the tab. Almost no feature is dead: none at λ = 1 and 5, 0.03% (7 features) at λ = 20 (Templeton et al. had 2% dead with 1 million features, 65% with 34 million): 24,576 features is small for this recipe. One limitation: the SAE explains 80% of the variance, so a fifth of what the stream holds is in the error term, which this tab does not interpret.
The prompt of the earlier tabs, "The capital of France is the city of", through the λ = 5 SAE at layer 5.
Which features fire (Fig. 29a). Of 24,576 features, 8 to 28 are active at each token. Each row of Fig. 29a is one feature, labelled with its ID and, in brackets, a hint at its meaning: the token where that feature fired most strongly in the 10 million evaluation tokens of card 5. That word comes from the training text, not from this prompt (the prompt's tokens are the columns): during the evaluation, the training run kept each feature's 20 largest activations and the positions of those tokens in the text, and the figure reads the token at the top position. The strongest ones mostly name the token itself: feature 23685 at " capital", feature 12 at " France", feature 19993 at " city". At the last " of", the strongest feature is 20664. Its label, " of", says little on its own; its 20 top contexts do (card 7): it fires on " of" after "town" or "city" when a place name follows, as in "the northern Syrian town of Manbij". That is exactly the situation at the end of this prompt, where the next word should be a city.
Does the reconstruction keep the answer? (Fig. 29b, c, the splice test). Replacing the stream after layer 5 by x̂, at every position, leaves the answer intact (" Paris" still first; logit difference over " London" +5.16, clean +5.08), so the 8–28 features per token carry what the later layers need. Setting the stream there to zero instead destroys it (" Paris" falls to rank 4,870). On these 8 tokens x̂ explains 64% of the variance, less than its 80% average on validation text.
Nothing in training named the features, so their meaning has to be read off afterwards. Two standard readings:
How "what it writes" is computed (Figs. 30b, 31b): one matrix–vector product, then sorting. Take the feature's decoder direction di, column i of Wdec (768 numbers, length 1), and multiply it by GPT-2's unembedding matrix WU (50,257 × 768, the matrix that turns the final stream into one score per vocabulary token; its final-LayerNorm scale is already folded in, as in the rest of the course). That gives 50,257 numbers, one per token. The 8 largest are drawn in green (promoted), the 8 most negative in red (suppressed). For feature 12 in MATLAB, which counts from 1: eff = W.W_U * S.W_dec(:, 13);
Feature 12 fires on 0.014% of tokens (about 1 in 7,000), always on "France". Its four strongest activations in the 10 million evaluation tokens, each with the 10 tokens before it and 3 after, as they appear in the text (a cut-off word is where the 3 tokens end):
It is specific to France, not to countries in general: in "The capital of X is the city of" it fires at 13.8 for France and at exactly 0 for each of the other 26 EU countries (Fig. 30a). It writes French things: " Alps", " Hollande", " fries", " France", " franc", " countryside" (Fig. 30b). These are candidates for the next word, so a France feature promotes words that go with France, with " France" itself close behind (6th); the top eight differ little (0.70–0.89), so their order matters less than the fact that all are about France. A concept feature: the token is France, and the feature carries what goes with it.
Feature 20664 fires on 0.023% of tokens, on " of" after "town" or "city" when a place name follows. Its four strongest activations:
Fig. 31a tests that reading on 12 short phrases. At the final " of" it fires at 7.7–15.7 whenever a place name should follow ("the northern town of", "the port city of", "the small town of", "the city of"), only 0.8 at "The capital of", and 0 for "one of", "a cup of", "the University of", "the King of" and three other ordinary uses. It writes the beginnings of place names: " Normandy", " Dover", " Trinidad", " Alb", " Nev", " southwestern" (Fig. 31b). A prediction feature: it fires on an ordinary word, " of", and stands for what comes next, a place. In the France prompt it is the main feature at the last token (7.7), the position where the answer is predicted.
Both readings are partial: top contexts show only the strongest firings, and a feature can mean something broader at weaker activations; the direct effect ignores the later layers. The next step tests what a feature does by changing it.
The SAE splits each token's stream into pieces: the features' contributions, the bias, and what the SAE misses (the error, x − x̂):
To set feature i to a value v, change only its term and keep the rest, the error included:
The bias and the error do not appear because they are already inside x and stay as they are. Written out, the edit replaces a single term:
The short form is what the code uses: it needs only feature i's activation and direction. The edit is applied to the stream after layer 5 at every position except the first. v = 0 turns the feature off (its contribution fi di / c is subtracted; where it did not fire, nothing changes); a large v clamps it on (Templeton et al.'s feature steering, the SAE version of tab 14's steering with a direction).
In short: clamping sets feature i's contribution to the layer-5 stream to v di / c at every position (except the first), and leaves the other features' contributions to that stream as they were; the later layers, and every token generated after, then respond to the changed stream.
Off, in the France prompt:
| Feature turned off | p(" Paris") | Rank of " Paris" | Top prediction |
|---|---|---|---|
| none (clean) | 0.097 | 1 | " Paris" |
| 12 ("France") | 0.014 | 4 | " the" |
| 20664 (" of" before a place) | 0.034 | 3 | " the" |
| 23685 ("capital") | 0.081 | 2 | " Marse" (as in Marseille) |
Removing the single France feature, one of 24 active at that token, takes away most of the probability of " Paris" (0.097 → 0.014; it falls to rank 4). Removing "capital" leaves France but not capital: the top answer becomes another French city, Marseille. The features carry the facts the later layers use, in the form the readings above suggested.
Clamped on, in generated text (greedy, 20 tokens; v in multiples of the feature's largest activation in training text):
| Feature | Clamp | Prompt | Continuation |
|---|---|---|---|
| none | — | "My favourite food is" | the chicken. I love it so much that I have to make it myself. |
| 12 ("France") | 0.5 × max | "My favourite food is" | the French fries. I love them so much. I have been to France twice and I have never |
| 12 ("France") | −1 × max | "My favourite food is" | a chicken curry. I've been trying to get some of these to try and I'm happy to |
| none | — | "I went for a walk and saw" | a lot of people. I was surprised to see that there were people who were very nice and kind |
| 265 ("wonderful") | 0.25 × max | "I went for a walk and saw" | a beautiful little house in the middle of the woods. I was so excited to see it. |
| 265 ("wonderful") | −0.25 × max | "I went for a walk and saw" | a man walking down the street. He was wearing a black hoodie and a black hoodie with |
| 20419 ("terrible") | 1 × max | "I went for a walk and saw" | the sign. I was so scared. I was scared. I was scared. |
| 23685 ("capital") | 0.5 × max | "I went for a walk and saw" | a few of the city's most famous landmarks. The city's main square was the square |
| 23685 ("capital") | 1 × max | "I went for a walk and saw" | the city, but, Damascus, Damascus, Damascus, Damascus, … |
| 20664 (" of" before a place) | 1 × max | "I went for a walk and saw" | the Kismi Kismo Kismo Kismo Kismo Kismo K… |
| 20664 (" of" before a place) | 2 × max | "I went for a walk and saw" | K K K K K K K K K K K K K K K K K K K K |
Clamped gently, each feature bends the text toward its meaning while keeping it fluent; clamped harder, it takes over. In our tests (six features, two prompts; the table shows a selection), 0.5 × the maximum usually bent the text while keeping it fluent, 1 × broke some features into repetition ("capital", 20664) but not others ("terrible"), and 2 × or more broke the one feature tried (20664: 2, 5 and 10 × all give "K K K…"). Templeton et al. clamped features in Claude 3 Sonnet to 5 × their maximum and more; a small model edited at every position tolerates much less.
Negative clamps are allowed too (Templeton et al. use them): they write the feature's direction backwards, into a stream the encoder never produces, since its activations cannot be negative. Here they mostly weakened the concept rather than producing its opposite: at −0.25 × "wonderful" the walk loses its "very nice and kind" people and turns neutral, and at −1 × "France" the food stays chicken, now a curry. Even −1 × the maximum kept the text fluent, for the three features tried (two are shown in the table).
Tab 8 found one direction that separates positive from negative sentences: the difference of the mean streams at layer 5, on 20 sentences (the direction tab 14 steered with). How do the SAE's features relate to it? Most decoder directions are nearly perpendicular to it (Fig. 32a); only 33 of 24,576 have a cosine above 0.3 in size. Two are clear-cut: feature 265 (cosine +0.31), whose top contexts are "wonderful", and feature 20419 (−0.38), whose top contexts are "terrible" and "awful".
On the 20 sentences themselves (Fig. 32b), feature 265 fires in 3 of the 10 positive sentences and 0 of the negative ones; feature 20419 in 4 of the 10 negative sentences and 0 of the positive ones. It is a word family, not one word: 265 fires in the sentences with "wonderful", "fantastic" and "brilliant"; 20419 in those with "terrible", "awful", "horrible" and "miserable". Each fires only where its kind of word occurs, and is silent in the other sentences. The sentiment direction is an average over all sentences; the SAE instead has many specific features that each carry part of it. Both views are useful: the direction is one handle for the whole concept; the features say which words and contexts make it up.
A factual concept: France → Italy. Tab 14 also steered with a direction from France to Italy, built the same way from 10 matched pairs ("I spent the summer in Italy." / "… in France."). Here the picture is much simpler (Fig. 32c, d). Only 6 features have a cosine above 0.3 in size, and the two largest are the two countries: feature 17482, whose top context is "Italy" (cosine +0.56), and feature 12, "France" (−0.40), the feature of card 7. On the 20 sentences, 17482 fires in all 10 Italy sentences and none of the France ones, and feature 12 in all 10 France sentences and none of the Italy ones. So the France → Italy direction is close to "the Italy feature minus the France feature": a concept carried by one word in every sentence maps onto single features, while sentiment, carried by many different words, is spread over many.
There are two stages: training the SAE in Python, then analysing it in MATLAB.
1. Train (Python, GPU, hours). train_sae.py trains one SAE in PyTorch, which takes about 2 hours 50 minutes on one RTX 3080 Ti (12 GB). It reads three inputs:
train.bin and val.bin.gpt2_full_weights.mat. The script runs GPT-2 itself to get the stream after layer 5.For example:
python "Python code/train_sae.py" "Python code/sae_l5_lam5.json"
% sae_l5_lam5.json:
{"out_dir": "runs/sae_l5_lam5", "lam": 5.0}
% the defaults:
layer = 5 % the stream leaving this layer (0-based)
d_sae = 24576 % features
lam = 5.0 % sparsity penalty λ (1, 5, 20 here)
lr = 5e-5 % Adam, no weight decay; linear decay to 0 over the last 20% (lr_decay = 0.2)
steps = 200000; batch = 4096 % 820 million tokens, each used once
lam_warmup = 0.05 % λ rises from 0 over the first 5% of steps
init_norm = 0.1; grad_clip = 1.0; buffer_windows = 2048; seed = 1
It saves two files in out_dir:
sae.mat, the trained SAE: Wenc, benc, Wdec, bdec, c and the layer. The decoder directions are already rescaled to length 1.eval.mat, the evaluation on the 10 million evaluation tokens: each feature's firing count, its mean activation, and its 20 top activations with their positions in train.bin. The scores of card 5 are also saved in eval.json.2. Analyse (MATLAB, CPU, minutes). run_sae trains nothing. It loads sae.mat and eval.mat from run_dir, loads GPT-2 small's weights to run the prompts, and reads train.bin only to print the text around each top activation. Its settings are:
run_dir = fullfile(project_root(), 'runs', 'sae_l5_lam5'); % train_sae.py's output folder
prompt = 'The capital of France is the city of';
target = ' Paris'; rival = ' London';
feature = []; % [] = the most active feature at the last token (20664 here); 12 for France
steer_prompt = 'I went for a walk and saw';
clamps = [0 0.25 0.5 1]; % multiples of the feature's largest activation
It prints each step of this tab for one feature (its top contexts, the tokens it promotes, the splice test, the comparison with the sentiment direction, and the steered continuations) and plots the feature on the prompt, the feature density and its promoted tokens. It runs in a few minutes on a CPU.