Ch. 22Signature chapter

Mechanistic Interpretability

The residual stream view, induction heads, sparse autoencoders, attribution graphs. The obvix flagship.

mech-interpSAEinduction-headscircuits

FIG 22 · Explainer video


The first time you patch an activation from one into another and watch the output flip from "Mary" to "John", you stop thinking of the transformer as a black box. You start thinking of it as a circuit. Inside the of a small GPT, there is a literal subspace that encodes "the next- candidate is the indirect object of the sentence", and you can find it. You can ablate it. You can write down which heads compose to produce it. The model is doing a specific computation you can specify in three lines of pseudocode. That is the claim of , and the moment it stops being a claim and starts being something you have done with your own hands is the moment you understand why this chapter exists. Mech interp is not a guarantee that we can keep frontier models safe. It is the discipline of finding out, one circuit at a time, what we actually built.


FIG 22.1 · Learning outcomes

By the end of this chapter you will be able to:

  • Load a small transformer with TransformerLens, cache every activation, and tell another researcher precisely which tensor lives at every named hookpoint.
  • Compute Direct Logit Attribution: decompose a model's output logit into contributions from each layer, each head, and each MLP block, and tell which ones did the work.
  • Run an activation patching experiment from scratch: pick a clean and corrupt prompt, patch one site at a time, plot the recovery, and identify the responsible component.
  • Find an induction head in a two-layer attention-only transformer using only a sequence of repeated tokens and a recurrence pattern detector.
  • Train a tiny sparse autoencoder on a layer's residual stream, then click through Neuronpedia and inspect features the way an SAE researcher does.
  • Articulate the difference between a feature, a circuit, an attribution graph, and a transcoder, in three sentences each, without hedging.
  • Explain, with reference to a specific paper, why "the model thinks X" is a claim that requires intervention experiments to support, not just correlational reading of activations.
  • Apply mech-interp methods to a safety question: locate the refusal direction in a chat model and write down what would happen if you ablated it.

FIG 22.2 · What you need first

  • Ch 15 — Transformers from Scratchyou cannot interpret a transformer if you do not know what every tensor in it is. The shapes are how you debug; if (batch, n_heads, seq, head_dim) is not muscle memory, build it first.
  • Ch 0 — Math + Python prereqsspecifically: linear algebra (rank, projection onto a direction, change of basis) and basic probability (KL divergence, entropy). another chapter only names eigendecomposition and SVD and defers the mechanics to another chapter; §16 here uses them as quick diagnostics (read a singular-value spectrum, look at where eigenvalues cluster), so skim another chapter first if those are not yet familiar.
  • Ch 11 — Training Deep NNsyou do not need to train large models, but you need to know what a loss curve, a gradient, and L1 regularization are, because SAEs use all three.
  • Ch 16 — Multimodal Transformersuseful for the multimodal-features sub-section. Sparse autoencoders on CLIP-style models reveal cross-modal features. The another chapter framing of "modalities sharing a residual stream" is the prereq.
  • Ch 8 — Unsupervised Learningclustering on neural-network activations is the historical predecessor of SAE feature discovery. PCA on activations still appears in early-stage exploratory interp work.
  • external — Neel Nanda's mech-interp prereqs — the canonical "what to know before reading the papers" list. If you check off most of it, you are ready.

Nice-to-have: Ch 14 — NLP+Attention (for the conceptual bridge from attention-as-mechanism to attention-as-circuit-element).


FIG 22.3.1

The interp question, and why "look at neurons" was not enough

is the project of reverse-engineering neural networks. Not "explain what the model does on average" (that is behavioral evaluation). Not "highlight which input tokens mattered" (that is attribution). The thing mech interp tries to produce is a program: a description of the computation the model performs, in terms of variables and operations that a human can audit and predict from.

The historical motivation has two strands. The vision-interp strand (Olah, Mordvintsev, the Distill circuits thread, 2015-2020) found that individual convolutional neurons in InceptionV1 detected specific visual concepts (curves, dog faces, car wheels), and that pairs of layers wired together implemented recognizable algorithms (a curve detector feeding into an oriented edge detector). The Distill articles "Zoom In: An Introduction to Circuits" and "Visualizing Weights" formalized this view: a circuit is a connected subgraph of the network's compute graph that implements a coherent function.

The transformer-interp strand picked this up around 2021. Elhage et al.'s "A Mathematical Framework for Transformer Circuits" introduced the residual-stream view: instead of treating the transformer as alternating /MLP blocks, treat it as a sequence of writes and reads to a high-dimensional vector (the ) that every layer can both read from and add to. This reframe is the foundation of everything in this chapter.

The blunt fact that motivated all of this: looking at single neurons in a transformer's MLP does not work. Neurons are polysemantic: a single neuron's activations span many unrelated concepts. The toy-models-of-superposition paper (Elhage et al. 2022) explained why: a network can pack more features than it has neurons by representing each feature as a sparse combination of neurons, accepting interference, and using nonlinearities to denoise. Polysemanticity is the symptom; superposition is the cause. Sparse autoencoders are the current best response. We get to them in §10.

FIG 22.3.2

The residual stream view, leveraged

the residual-stream picture introduced in Ch 15 §9: the activation x flowing through a transformer block is not transformed but added to. Every head and every MLP layer reads from x (after LayerNorm) and writes a delta back. The output logits are unembed(ln_final(x)), one final read.

Here, it is the technical foundation of every mech-interp tool in this chapter. The reframe matters because every component, every head, every neuron, every layer, has a direct linear contribution to the final logits. You can write:

logits=WULNfinal(xpost-all-blocks)\text{logits} = W_U \cdot \text{LN}_\text{final}(x_\text{post-all-blocks}) xpost-all-blocks=xembed+lattnl(LNl(xl))+lmlpl(LNl(xl))x_\text{post-all-blocks} = x_\text{embed} + \sum_l \text{attn}_l(\text{LN}_l(x_l)) + \sum_l \text{mlp}_l(\text{LN}_l(x_l))

Substituting and ignoring the LayerNorm for a moment (we will pay for that later), the logits are a sum of contributions: one from the , one from the positional embedding, one from each attention head, one from each MLP. Each contribution is a vector in residual-stream space, projected by WUW_U to vocab space.

This decomposition is the whole point. If you want to know why the model said "John" instead of "Mary", you compute the contribution of each component to the logit("John") - logit("Mary") difference, and see who wrote what. That technique has a name: Direct Attribution. We use it in §4.

The tool that makes this practical is TransformerLens (Nanda's library), which loads a GPT-2-style model into one consistent, interpretability-friendly architecture so that every internal activation has a stable name you can read or edit. You load a model with HookedTransformer.from_pretrained("gpt2-small"); you run it and grab all the internal activations at once with logits, cache = model.run_with_cache(tokens), which returns an ActivationCache; and you index that cache by name — cache["resid_post", l] (a shorthand for the full cache["blocks.{l}.hook_resid_post"]) pulls out the after layer l. The matrices are exposed under the same naming, e.g. model.W_U for the unembed and model.W_Q for the per-head query projection. We use this library from here on; the chapter's labs deliberately rebuild the same operations by hand so you never depend on a black box you cannot reconstruct.

Python
# Cache the residual stream at each layer's output
import torch
from transformer_lens import HookedTransformer

model = HookedTransformer.from_pretrained("gpt2-small")
tokens = model.to_tokens("The Eiffel Tower is in")
logits, cache = model.run_with_cache(tokens)

# Residual stream after each layer
for l in range(model.cfg.n_layers):
    resid = cache["resid_post", l]   # (batch, seq, d_model)
    print(l, resid.shape, resid.norm().item())

The residual stream view is what TransformerLens's hookpoints are organized around. resid_pre, resid_mid, resid_post, attn_out, mlp_out are all named views into this single vector.

FIG 22.3.3

Hookpoints: where to read and where to intervene

TransformerLens exposes every internal as a named hook. The list is finite and worth memorizing. For each layer l:

  • hook_embed: output, shape (batch, seq, d_model). Once, at the bottom.
  • hook_pos_embed: positional embedding output. Once.
  • blocks.{l}.hook_resid_pre: just before this layer's block.
  • blocks.{l}.attn.hook_q, hook_k, hook_v: post-projection Q, K, V. Shape (batch, seq, n_heads, head_dim).
  • blocks.{l}.attn.hook_attn_scores: pre- attention pattern.
  • blocks.{l}.attn.hook_pattern: post-softmax attention pattern.
  • blocks.{l}.attn.hook_z: per-head attention output. Shape (batch, seq, n_heads, head_dim). This is the per-head pre-output-projection vector.
  • blocks.{l}.hook_attn_out: post-output-projection attention output. The thing added to the residual stream.
  • blocks.{l}.hook_resid_mid: residual stream after attention, before MLP.
  • blocks.{l}.mlp.hook_pre, hook_post: MLP pre-activation and post-activation neuron values.
  • blocks.{l}.hook_mlp_out: MLP output. The thing added to the residual stream.
  • blocks.{l}.hook_resid_post: residual stream after this whole layer.
  • ln_final.hook_normalized: input to the unembed, after final LayerNorm.

Two operations on hooks. Read: register a hook that copies activations into a cache. Intervene: register a hook that modifies activations on the way through. Reading is what run_with_cache does. Intervention is what enables activation patching. You intervene by calling model.run_with_hooks(tokens, fwd_hooks=[(name, fn)]), where each fn(activation, hook) receives the activation tensor and returns the (possibly edited) replacement.

Python
import torch
from transformer_lens.hook_points import HookPoint

def zero_head(activation: torch.Tensor, hook: HookPoint, head_idx: int) -> torch.Tensor:
    """Zero out one head's z output. activation shape: (batch, seq, n_heads, head_dim)."""
    activation[:, :, head_idx, :] = 0.0
    return activation

# Run the model with head 5 of layer 3 ablated
hook_name = "blocks.3.attn.hook_z"
logits = model.run_with_hooks(
    tokens,
    fwd_hooks=[(hook_name, lambda act, hook: zero_head(act, hook, head_idx=5))],
)

The hook-based intervention pattern is the foundation of every causal experiment that follows. Every paper you read in §15 is some clever choice of which hook to read from and what to write into another hook.

FIG 22.3.4

Direct Logit Attribution: the simplest causal-ish tool

DLA is the trick of computing each component's projection onto the difference between two specific tokens.

The recipe. Pick a prompt and two candidate next tokens, tcorrectt_{\text{correct}} and tincorrectt_{\text{incorrect}}. The "logit difference" is logits[tcorrect]logits[tincorrect]\text{logits}[t_{\text{correct}}] - \text{logits}[t_{\text{incorrect}}]. By the linearity of the unembed, this equals the projection of the final-layer residual onto the logit-difference direction: u=WU[tcorrect]WU[tincorrect]Rdmodelu = W_U[t_{\text{correct}}] - W_U[t_{\text{incorrect}}] \in \mathbb{R}^{d_{\text{model}}}.

Now decompose the final residual. Every component (, each layer's attn_out, each layer's mlp_out) contributes one vector ckc_k. The total is xfinal=kckx_{\text{final}} = \sum_k c_k. So the logit difference is kck,u\sum_k \langle c_k, u \rangle. Each component has a single scalar contribution.

Caveat 1: LayerNorm. The real model has xfinalx_{\text{final}} passed through LayerNorm before the unembed. LN is approximately linear when you fold its factor into uu, but only approximately. For careful DLA, apply the logit lens — the technique of taking a from partway up the network, passing it through the final LayerNorm and unembed, and reading off the resulting distribution as if that layer were the last (Nostalgebraist 2020). The fix here is the same arithmetic: multiply each component by the LN scale at the position of interest before projecting. TransformerLens has a model.unembed and a helper apply_ln_to_stack that handles this.

Caveat 2: DLA is a direct attribution. It does not tell you about indirect effects, where a component contributes a vector that subsequent components use, or compose with, to produce a logit. For indirect effects you need activation patching (§5) or path patching (§6).

Python
def direct_logit_attribution(cache, model, correct_token: int, incorrect_token: int, pos: int):
    """Returns per-component projections onto the logit-diff direction at `pos`."""
    W_U = model.W_U          # (d_model, vocab)
    u = W_U[:, correct_token] - W_U[:, incorrect_token]   # (d_model,)
    # accumulate per-layer attn and mlp contributions
    contributions = {}
    for l in range(model.cfg.n_layers):
        attn_out = cache["attn_out", l][0, pos]   # (d_model,) for batch=0
        mlp_out = cache["mlp_out", l][0, pos]
        contributions[f"L{l}.attn"] = (attn_out @ u).item()
        contributions[f"L{l}.mlp"] = (mlp_out @ u).item()
    return contributions

For an IOI-style prompt ("When John and Mary went to the store, John gave a drink to"), correct = " Mary", incorrect = " John". Run DLA on this and you will see Layer 9 head 9 (a "name-mover" head) contributing strongly positively to Mary's logit, while Layer 10 head 7 and Layer 11 head 10 (the "negative name-mover" heads) contribute negatively — they push the logit toward John. The head names — name-mover, negative name-mover, S-inhibition, and the rest — are the of the IOI circuit and are defined in §11; for now read them as labels for specific heads this example will name.

FIG 22.3.5

Activation patching: the workhorse causal experiment

Direct attribution tells you who correlates with the answer. To tell who causes it, you need to intervene. Activation patching is the simplest intervention: replace one activation on the way through with the activation from a different run, and see how the output changes.

The setup. Pick two prompts: a clean prompt the model gets right, and a corrupted prompt where you have changed one thing (often a or two) such that the answer flips. Cache activations on both runs. Now, for each candidate site hh (a specific hookpoint and position), run the model on the corrupted prompt but patch in the clean activation at site hh. Measure: does the output recover toward the clean answer?

If patching site hh recovers a lot of the clean behavior, then site hh carries information that matters for the task. If patching site hh does nothing, then hh is irrelevant. Sweep across all sites; the heatmap is your causal map.

The metric. Define P(site)=logit-diffpatched at sitelogit-diffcorruptlogit-diffcleanlogit-diffcorruptP(\text{site}) = \frac{\text{logit-diff}_\text{patched at site} - \text{logit-diff}_\text{corrupt}}{\text{logit-diff}_\text{clean} - \text{logit-diff}_\text{corrupt}}. P=0P = 0 means no recovery (site irrelevant). P=1P = 1 means full recovery (site carries all the relevant info). Values above 1 happen (overpatching) and are themselves informative.

The choice of site matters. Patching resid_pre at every layer-position tells you when in the network the information lives. Patching hook_z at every (layer, head, position) tells you which heads carry it. Patching mlp_out tells you whether MLPs are involved.

Python
import torch
from transformer_lens import HookedTransformer

def patch_resid_pre(corrupt_act, hook, clean_cache, layer, pos):
    corrupt_act[:, pos, :] = clean_cache[f"blocks.{layer}.hook_resid_pre"][:, pos, :]
    return corrupt_act

def activation_patching_sweep(model, clean_tokens, corrupt_tokens, logit_diff_metric):
    """Returns a (n_layers, seq_len) heatmap of patching effect."""
    _, clean_cache = model.run_with_cache(clean_tokens)
    n_layers, seq_len = model.cfg.n_layers, clean_tokens.shape[-1]
    out = torch.zeros(n_layers, seq_len)
    for l in range(n_layers):
        for p in range(seq_len):
            hook = (f"blocks.{l}.hook_resid_pre",
                    lambda a, h, layer=l, pos=p: patch_resid_pre(a, h, clean_cache, layer, pos))
            patched_logits = model.run_with_hooks(corrupt_tokens, fwd_hooks=[hook])
            out[l, p] = logit_diff_metric(patched_logits)
    return out

Caveat: activation patching is not a clean causal test. The model's later layers are conditioned on what the earlier activations would have been, so patching introduces an off-manifold input. In practice, the result is still strongly informative; in theory, you should worry about the patched activation being out-of-distribution for downstream layers. Path patching (§6) addresses this by also patching the downstream "freeze" path.

FIG 22.3.6

Path patching: cleaner causal claims

Path patching (Goldowsky-Dill et al. 2023) is activation patching's careful sibling. Instead of patching one site and letting all downstream sites recompute, path patching freezes the downstream activations to their corrupt-run values, except along the one path you want to attribute.

The intuition. In a normal activation patch, the patch's effect propagates through every downstream layer. Some of that effect is direct (the patched activation feeds the unembed). Some is indirect (the patched activation changes layer 5's output, which changes layer 6's input, etc.). Path patching lets you isolate one of those paths.

The implementation is fiddly. You do three forward passes:

  1. Clean run, cache all activations.
  2. Corrupt run, cache all activations.
  3. Patched run: at the source site, use the clean activation. At every other site downstream of the source, use the corrupt activation, except along the path you want to keep "live".

The "path" is specified as a sequence of components: e.g., "L5 H3's output, through L7 H8's query input, through the unembed". You patch the L5 H3 output to clean values, force the rest of L5-L7 to corrupt, but allow L7 H8 to read clean L5 H3 output through its query projection.

This is the technique that produced the IOI circuit diagram (Wang et al. 2023). Without path patching, you cannot tell apart "this head writes to the logits directly" from "this head writes something that another head reads and uses to write to the logits".

In practice, for first-pass analysis, activation patching is usually enough. Reach for path patching when you have a hypothesis like "head A's output is read by head B, not by the unembed directly" and you want to test it.

FIG 22.3.7

Linear probes: the easy-but-correlational tool

A is the simplest "what does this layer know about X?" experiment. Train a linear classifier on top of a frozen layer's activations to predict some target (e.g., "is this a noun?"). If the probe's is high, the layer's activations linearly encode the target.

The catch is that linear probes are correlational. Probe accuracy tells you the information is decodable. It does not tell you the model uses it. The probe could be picking up an epiphenomenon the model represents but never reads.

Two probe variants that mitigate this. Trained probes (the default): fit a linear classifier with logistic regression on activations. Causal probes: instead of fitting a classifier, intervene on the probed direction (add a scaled version to the ) and see if the behavior changes. The OthelloGPT work (Li et al. 2022; Nanda 2023) is the canonical example: a non-linear probe found that the model "knew" the board state, then a linear probe found a probably-linear representation, then a causal intervention confirmed the model uses it.

Python
from sklearn.linear_model import LogisticRegression
import numpy as np

def probe_layer(cache, layer: int, labels: np.ndarray) -> float:
    """Train a linear probe on layer's residual stream. Returns held-out accuracy."""
    X = cache["resid_post", layer].reshape(-1, cache["resid_post", layer].shape[-1]).cpu().numpy()
    n = X.shape[0]
    idx = np.random.permutation(n)
    split = int(0.8 * n)
    train_idx, test_idx = idx[:split], idx[split:]
    clf = LogisticRegression(max_iter=1000)
    clf.fit(X[train_idx], labels[train_idx])
    return clf.score(X[test_idx], labels[test_idx])

When to reach for probes: as a fast first-pass diagnostic. When not to trust them: as evidence the model uses what they detect, without a confirming causal experiment.

FIG 22.3.8

Function vectors and steering vectors

If a finds a direction in that correlates with a behavior, you can sometimes just add that direction to the residual stream and steer the model. This is the steering vector trick, popularized by Turner et al. 2023 and Subramani et al. 2022 under "activation steering".

The recipe. Find or compute a direction vRdmodelv \in \mathbb{R}^{d_{\text{model}}} associated with a behavior (positive sentiment, refusal, French language, etc.). Pick a layer and a coefficient α\alpha. At time, after the chosen layer, add αv\alpha v to the residual stream at every position. Sample as normal.

Empirical fact: this works some of the time. The "love minus hate" direction extracted from contrastive activations (Turner) steers GPT-2 to write affectionate text. The refusal direction (Arditi et al. 2024) steers Llama-2-chat to refuse or comply on harmful prompts. The "French" direction extracted from translation contrasts steers code-switching.

When it works and when it does not. It works when the underlying concept is approximately linear in the residual stream (which, per the linear representation hypothesis, many high-level concepts are). It fails when the concept is implemented by multiple directions, when the model has anti- redundancy, or when the steering coefficient pushes you off the data manifold and the model outputs garbage.

Function vectors (Todd et al. 2024) are the same trick applied to in-context-learning tasks: extract a vector that summarizes "the function this in-context demo is teaching", inject it on a zero-shot prompt, watch the model behave as if it had seen the demo.

Python
def add_steering(activation, hook, vector, alpha):
    return activation + alpha * vector

def steer(model, prompt, steering_vec, layer, alpha):
    hook = (f"blocks.{layer}.hook_resid_post",
            lambda a, h: add_steering(a, h, steering_vec, alpha))
    return model.generate(prompt, fwd_hooks=[hook], max_new_tokens=50)

FIG 22.3.9

Induction heads: the first real circuit

In the 2-layer -only transformer trained on language, there exists a specific pair of heads that together implement in-context learning of repeated patterns. This is the canonical mech-interp result, and it is the first circuit anyone reverse-engineered end-to-end. Olsson et al. 2022 ("In-context Learning and Induction Heads") is the paper.

The phenomenon. Feed the model a sequence like ... A B... A B... A. The model predicts B at the last position with high , much higher than chance. How does it do it without having seen A B during training as that exact pair? By looking back, finding the previous A, and copying what came after it.

The algorithm has two heads:

  1. The previous- head (Layer 0). For each position tt, this head's attention pattern is essentially shifted by one: position tt attends to position t1t-1. The output written to the at position tt is a function of token at t1t-1. After Layer 0, the residual stream at each position contains "what token came before me".

  2. The induction head (Layer 1). At the current position (last A), the head computes a query that is "find me positions whose 'previous-token info' equals the current token (A)". So it attends to the position right after the previous A, which is the position containing B. It then copies B to the output.

Together, two heads in different layers compose via the residual stream to implement copy-by-pattern. The composition happens through specific subspaces of the residual stream: head 1's WKW_K reads from head 0's WOW_O, and head 1's WOW_O writes to a direction the unembed reads. The math here is the OV circuit and QK circuit decomposition, which we cover in §16.

The bigger claim of the Olsson paper: induction heads are how transformers do in-context learning more generally. They emerge in training at the same phase transition where in-context learning ability jumps. They generalize from "copy the literal token" to "copy a semantically related token" in larger models.

Why this matters. (1) It is the existence proof. A real, useful behavior in a real model is implemented by a specific identifiable circuit. (2) It is the from-scratch lab in this chapter: you train a 2-layer transformer on a corpus, you find the head, you intervene on it, you confirm it does what it does. (3) It scaffolds all later circuit work: the methodology used here (look for a behavior, isolate the heads via ablation, decompose the circuit via QK and OV analysis) is the template.

Python
# Detect induction heads by their characteristic attention pattern
import torch

def induction_score(attn_pattern: torch.Tensor) -> float:
    """attn_pattern: (n_heads, seq_len, seq_len). Sequence is rand tokens repeated 2x.
    An induction head attends from position t to position (t - seq_len/2 + 1)."""
    seq_len = attn_pattern.shape[-1]
    half = seq_len // 2
    # The induction offset: position t (in second half) attends to (t - half + 1)
    offsets = torch.arange(half, seq_len) - half + 1
    scores = attn_pattern[:, torch.arange(half, seq_len), offsets]
    return scores.mean(dim=-1)   # one score per head

FIG 22.3.10

Sparse autoencoders: the current best response to superposition

If polysemanticity comes from superposition, an obvious fix is to find a basis where features are sparse, even if it means using more basis vectors than the has dimensions. That is the SAE.

The setup. Take a layer's activations xRdmodelx \in \mathbb{R}^{d_{\text{model}}} from many forward passes. Train an autoencoder:

f(x)=ReLU(Wencx+benc)f(x) = \text{ReLU}(W_{\text{enc}} x + b_{\text{enc}}) x^=Wdecf(x)+bdec\hat{x} = W_{\text{dec}} f(x) + b_{\text{dec}}

with overcomplete fRdSAEf \in \mathbb{R}^{d_{\text{SAE}}}, where dSAE=8dmodeld_{\text{SAE}} = 8 d_{\text{model}} to 64dmodel64 d_{\text{model}}. The loss is:

L=xx^2+λf(x)1\mathcal{L} = \|x - \hat{x}\|^2 + \lambda \|f(x)\|_1

The L1 penalty enforces sparsity. The reconstruction loss enforces faithfulness. With the right λ\lambda, most of f(x)f(x)'s entries are exactly zero on any given input, and the few non-zero entries correspond to interpretable features.

Empirical fact: this works. On Claude 3 Sonnet (Anthropic 2024 " Monosemanticity"), SAEs trained at the residual stream level recover features for "the Golden Gate Bridge", "code that handles edge cases", "deception in role-play", "sycophancy", and tens of millions more. Many of the features are monosemantic in the sense that activating only one of them produces a coherent shift in the model's output.

Variants and gotchas:

  • TopK SAEs (Gao et al. 2024) replace the L1 with a hard top-k constraint: only keep the kk largest pre-activations. Eliminates the L1-coefficient tuning headache. Top-k is the default for new work.
  • JumpReLU SAEs (Rajamanoharan et al. 2024) use a threshold-and-pass-through nonlinearity, which gives sharper on/off boundaries.
  • Gated SAEs (Rajamanoharan et al. 2024) split the encoder into a gating path and a magnitude path; better reconstruction at the same sparsity.
  • Dead neurons: a fraction of SAE features never activate after training. Resampling and the "auxiliary loss" trick fix it partially.
  • Feature splitting: when you train a bigger SAE on the same data, single features sometimes split into multiple finer features. There is no "true" feature count; the SAE basis is a function of the SAE's width.
Python
import torch
import torch.nn as nn

class SAE(nn.Module):
    def __init__(self, d_model: int, d_sae: int):
        super().__init__()
        self.W_enc = nn.Parameter(torch.randn(d_model, d_sae) / d_model**0.5)
        self.b_enc = nn.Parameter(torch.zeros(d_sae))
        self.W_dec = nn.Parameter(torch.randn(d_sae, d_model) / d_sae**0.5)
        self.b_dec = nn.Parameter(torch.zeros(d_model))
    
    def forward(self, x):
        f = torch.relu((x - self.b_dec) @ self.W_enc + self.b_enc)
        x_hat = f @ self.W_dec + self.b_dec
        return f, x_hat

def sae_loss(x, x_hat, f, l1_coeff=1e-3):
    return ((x - x_hat) ** 2).mean() + l1_coeff * f.abs().sum(dim=-1).mean()

FIG 22.3.11

The IOI circuit, in detail

The Indirect Object Identification circuit (Wang et al. 2023) is the second canonical circuit, and the first one in a real (not toy) model. GPT-2 small, on the task: given "When John and Mary went to the store, John gave a drink to", predict the indirect object (Mary).

The decomposed algorithm:

  1. Duplicate Heads (Layers 0-3, e.g., 0.1, 3.0): detect that "John" appears twice in the prompt. Write a "this is a duplicate" signal to the at the second John's position.
  2. S-Inhibition Heads (Layers 7-8, e.g., 7.3, 8.6): read the duplicate signal. Write a negative signal that suppresses to John in later layers.
  3. Name Mover Heads (Layers 9-10, e.g., 9.6, 9.9, 10.0): attend to the indirect-object position (Mary's position), copy Mary's name to the output position.
  4. Backup Name Movers (Layers 10-11): redundant copies of name movers. When you ablate the primary name movers, these step up. (This is a fascinating finding: the circuit has built-in redundancy. There is also a phenomenon called "negative name movers" that suppress the correct answer; we still do not fully understand why.)

The full reverse-engineering required dozens of activation-patching and path-patching experiments. The diagram you usually see (the one with arrows from each head class to the next) is the cleaned-up summary; the full paper has roughly 30 components in the loose definition of the circuit.

What IOI taught the field:

  • Circuits in real models are not clean. They are messy, with redundancy, with components that fire in confusing ways, with "backup" copies of capabilities.
  • Reverse-engineering a single behavior in GPT-2 small (124M params) takes months. this approach to GPT-4 is not a matter of more compute; it requires fundamentally different methods. That is why the field is now investing in attribution graphs (§12) and SAEs (§10).
  • The circuit is task-specific. The same heads do other things on other tasks. Polysemanticity at the head level is real. The IOI circuit is "what these heads do for IOI prompts".

FIG 22.3.12

Attribution graphs, transcoders, and crosscoders

In 2024-2026, the SAE-and-circuit research moved from "find features in one layer" to "trace the computation across layers". Three techniques:

  • Transcoders (Templeton et al. 2024; Ge et al. 2024). A transcoder is an SAE whose input is one layer's pre-MLP activation and whose output reconstructs that layer's post-MLP activation. It tries to replace the dense MLP with a sparse, interpretable approximation. Once trained, you can compute "which transcoder features in layer ll contribute to which transcoder features in layer l+1l+1", giving a sparse computation graph.

  • Crosscoders (Anthropic 2024). A crosscoder is an SAE trained jointly on activations from multiple layers (or multiple models, like a base and chat variant of the same architecture). It identifies features that are shared across layers/models versus features unique to one. The model-diffing application (compare what changed between base and chat) is striking: many features are exactly aligned, a small subset shift in interesting ways, and that subset disproportionately includes the "refusal", "RLHF compliance", and "persona" features.

  • Attribution graphs (Anthropic 2025, "On the Biology of a Large Language Model"). Combine transcoders with attribution analysis to produce a graph of -to-feature influences for a specific prompt. Each node is a feature firing at a specific (layer, position). Each edge is the (linear, attributable) influence of one feature on another. The resulting graph is the closest thing the field has to "this is how the model thought about this prompt".

The biology paper is the closest thing to a worked example in modern interp. It walks through 8 cases (poetry, multilingual translation, reasoning, , refusal, math, etc.) and shows the attribution graph for each. The graphs are large and weird; they often include features that are not obviously related to the task and that the model nonetheless uses. The paper does not claim full understanding; it claims a concrete, intervenable hypothesis of what the model is doing.

Caveats. Attribution graphs are still expensive (one transcoder training run per layer, one attribution pass per prompt). The features are stable across small perturbations but not across changes in prompt format. The "biology" framing acknowledges this: we are reading a complicated system, not deriving it from first principles.

FIG 22.3.13

Toy models and grokking

The "toy models" thread (Elhage et al. 2022, "Toy Models of Superposition") and the grokking thread (Power et al. 2022; Nanda et al. 2023) are the two places mech interp has produced complete understanding of a model's algorithm.

Toy models of superposition: train a tiny model to reconstruct sparse high-dimensional inputs from a low-dimensional . Sweep sparsity. Observe phase transitions: at low sparsity, the model uses one bottleneck neuron per input . At high sparsity, it packs multiple features per neuron in superposition, arranging them at specific geometric angles (regular polygons, pentagons, etc.). The geometry can be derived analytically. This is the cleanest existence proof of superposition.

Grokking: train a small transformer on modular addition (a+bmod113a + b \mod 113). The model first overfits the (memorization), then, much later, "groks" the algorithm and generalizes. By analyzing the weights at the grokked state, Nanda et al. showed the model is computing a Fourier transform of the inputs: it represents aa and bb as combinations of sin(kπa/113)\sin(k \pi a / 113) for various kk, multiplies them in the MLP using trig identities, and reads off the result. The complete algorithm fits in a paragraph and can be unit-tested.

Why these matter. They are the only examples where the mech-interp community can write down, end-to-end, the exact computation a model performs. They are smaller than any practical model, but they are real models doing nontrivial computations. They calibrate intuition: this is what a "fully reverse-engineered model" looks like.

FIG 22.3.14

Neuronpedia: where features get inspected

The practical entry point for SAE work is Neuronpedia (neuronpedia.org). It hosts SAE features from Pythia, GPT-2, Gemma 2, Llama 3, and others. For each , you get the top-activating examples (the dataset texts that fire the feature most strongly), neighboring features in the SAE basis, the feature's direction, and an LLM-generated description.

How to use it for research. Pick a model and an SAE release. Search for a concept ("refusal", "Python code", "the word 'however'") and inspect the top features. Click into a feature, scroll the top-activating examples, decide if the LLM-generated description matches what you see. The interpretive eye is the limiting reagent.

Caveat: Neuronpedia is a tool, not a truth. The auto-descriptions are LLM outputs and inherit LLM-style errors (confident, plausible, sometimes wrong). The "interpretability" of a feature is the researcher's call. The current best practice is to (a) inspect top-activating examples, (b) hypothesize a description, (c) construct a held-out , (d) check if the feature fires on the held-out positives and not the negatives. Few people do all four.

For a project: pick an open-weights chat model that has a public SAE release. Find five features that, in your interpretation, encode something safety-relevant (refusal, sycophancy, deception, persona, instruction-following). Verify each with a held-out test. Write up. That is a credible mech-interp project portfolio piece.

FIG 22.3.15

What we still cannot do

The discipline is honest about its limits. As of 2026, mech interp can:

  • Reverse-engineer small (≤2-layer) -only transformers end-to-end. (Toy models, induction heads.)
  • Reverse-engineer single behaviors in mid-sized models (124M-1B). (IOI.)
  • Find interpretable features at scale via SAEs in frontier models. ( Monosemanticity, Gemma Scope.)
  • Trace influence between features within a single prompt via attribution graphs. (Biology paper.)
  • Steer model behavior along simple linear directions, sometimes. (Refusal, persona.)

It cannot:

  • Reverse-engineer a frontier model end-to-end. The combinatorial scale is wrong.
  • Reliably detect deception. There are papers that find "deception features", but their predictive validity on held-out adversarial settings is shaky.
  • Predict . Knowing the algorithm a model uses on the training distribution does not tell you what it will do off-distribution.
  • Distinguish "model represents X" from "model uses X". Probes find the first; causal experiments confirm the second; many published claims conflate them.
  • Find the "true" basis. SAE features depend on SAE width, training data, and . The same model trained with different SAE hyperparameters yields different feature sets that all look reasonable.

The optimistic case: mech interp is the field where, ten years from now, "did we audit the model" is a question with a real answer rather than a wave at evaluations. The pessimistic case: feature interpretability scales sublinearly with model size and the methods top out below frontier scale. Both can be true.

FIG 22.3.16

The OV and QK circuit decomposition

This is the math that makes the induction-head analysis work, and the math that the rest of the chapter has been implicitly using. Take a single head with weights WQ,WK,WV,WOW_Q, W_K, W_V, W_O. Given xx, the head computes:

pattern(x)=softmax(xWQWKTxTdk)\text{pattern}(x) = \text{softmax}\left(\frac{x W_Q W_K^T x^T}{\sqrt{d_k}}\right) output(x)=pattern(x)xWVWO\text{output}(x) = \text{pattern}(x) \cdot x W_V W_O

Define two derived matrices:

  • QK circuit: WQWKTRdmodel×dmodelW_Q W_K^T \in \mathbb{R}^{d_{\text{model}} \times d_{\text{model}}}. This determines which positions attend to which. It is the "decide which thing to copy" matrix.
  • OV circuit: WVWORdmodel×dmodelW_V W_O \in \mathbb{R}^{d_{\text{model}} \times d_{\text{model}}}. This determines what gets copied (transformed) when a position is attended to. It is the "what to write" matrix.

You can study a head by studying these two matrices independently. For an induction head, the OV circuit approximately implements the identity (it copies whatever it reads), and the QK circuit implements "find positions whose representation matches my current 's previous-position info".

For a previous-token head, the QK circuit is essentially a shift operator (each position attends to the prior position), and the OV circuit is a copy of the value vector.

The decomposition generalizes. For any head, plotting WQWKTW_Q W_K^T against WVWOW_V W_O in some interpretable basis can reveal what the head does. TransformerLens has a FactoredMatrix class that stores these as factored products, allowing efficient SVD and inspection.

Python
from transformer_lens import FactoredMatrix

W_QK = FactoredMatrix(model.W_Q[0, 0], model.W_K[0, 0].T)   # (d_model, d_model)
W_OV = FactoredMatrix(model.W_V[0, 0], model.W_O[0, 0])
print(W_QK.svd().S[:5])   # top singular values reveal the rank of the head's selection

FIG 22.3.17

Activation atlases and feature visualization (the vision-interp connection)

The technique that taught the field what circuits feel like came from the vision side. Olah and Schubert's " Visualization" (Distill 2017) showed that you can ask "what input maximally activates this neuron" by ascent in pixel space, and the result is interpretable: curve detectors, dog-face detectors, car-wheel detectors. "Activation Atlas" (2019) extended this to whole layers: project the activations of millions of images into 2D and tile the resulting map with feature-vis renderings.

Why this still matters for transformer interp:

  • Feature visualization is the original argument that features are real, that neurons in trained networks encode specific, namable concepts. Without that prior, the SAE story in transformers reads as wishful.
  • Activation atlases are the visual template for Neuronpedia-style feature dashboards.
  • The polysemantic-neuron phenomenon was first identified in vision (Olah et al. 2020 "Zoom In") and carried over to language.
  • The "circuit" terminology (curve detector + edge detector → contour detector) came from vision and was generalized to transformer-block compositions.

If you want intuition for what a "feature" is, the vision-interp Distill articles are the best starting point. They predate everything in this chapter and they will make the abstractions feel concrete.


FIG 22.4 · Safety lens · this chapter

This is the chapter where the safety lens is the chapter. The entire reason mech interp is funded and the entire reason this book exists is the bet that understanding the model from the inside will be necessary, eventually, to make any honest claim that a powerful model is safe to deploy. Three concrete safety applications, with their current state:

Refusal direction monitoring. Arditi et al. 2024 ("Refusal in Language Models Is Mediated by a Single Direction") showed that chat-tuned Llama-2, Qwen, and others encode refusal behavior in a low-dimensional subspace of the , often nearly one-dimensional. You can find the direction by contrasting activations on harmful versus harmless prompts. You can ablate it (project the residual stream onto the orthogonal complement of the refusal direction) and the model stops refusing. You can amplify it and the model refuses more. The practical implication is double-edged: it gives defenders a way to monitor model state ("is refusal direction activated on this prompt?"), and it gives attackers a recipe for cheap jailbreaks via that suppresses the direction (which has been demonstrated). The arms race here is real and ongoing.

Deception and sycophancy features in SAEs. " Monosemanticity" (Anthropic 2024) found SAE features for "deception in role-play scenarios", "responses that flatter the user without basis", and "responses that withhold information". The follow-up "biology" paper (Anthropic 2025) traced specific deceptive-reasoning chains through attribution graphs on prompts where the model gave a knowingly wrong answer in order to please a fictional user. These results are real and reproducible on Claude 3 Sonnet. Their to harder cases (intentional deception by a future model under deployment) is the open question; current methods detect simple cases and miss subtle ones.

Backdoor and Trojan detection via probes/SAEs. Hubinger et al. 2024 ("Sleeper Agents") trained models to behave normally during evaluation and to insert vulnerabilities when triggered, then tested whether standard safety training removed the backdoor. It mostly did not. Mech-interp follow-up work asked: can probes or SAEs detect the backdoor's "I am in evaluation mode" vs "I am in deployment" state? Some studies find detectable features; others find that the features are not robust across paraphrases of the trigger. State-of-the-art as of 2026: detection works on the specific backdoors tested, generalizes unpredictably to novel backdoor patterns.

What this means concretely for your code. If you ship a chat-tuned model, you can use the refusal-direction monitor as a deployment-time safety signal: log the projection of every prompt's residual stream onto the refusal direction; queue for review any prompt where the model complied while the direction was elevated. The implementation is ≈50 lines. It will not catch novel jailbreaks; it will catch the bulk of textbook-style attacks and give you a tripwire. If you train custom SAEs on your model, do the held-out verification dance from §14 for any feature you plan to alarm on. And: do not claim "the model thinks X" without an intervention experiment supporting it. The lesson the field keeps learning is that correlational interpretation is shockingly often wrong, and the cost of a wrong interpretation in a safety claim is hard to walk back.

The deeper claim, the one that justifies the budget Anthropic and others put into this: powerful AI systems will eventually be deployed in contexts where behavioral evaluation is insufficient (because evaluations can be gamed and capabilities can be hidden). Mech interp is the bet that we will, by then, have the tools to look inside and check. The chapter you just read is the state of that bet in 2026. Whether the bet pays out is undetermined.


FIG 22.5 · Under the hood

The library call, and the lines it hides

You don't have to choose between “use the library” and “build it from scratch.” Here is the one library call, the exact lines it stands in for, and when to reach for which on the job.

Zero out one attention head

DL primitive
LIBRARY
def zero_head(z, hook: HookPoint, head: int):  # z: (batch, seq, n_heads, d_head)
    z[:, :, head, :] = 0.0
    return z

logits = model.run_with_hooks(
    tokens,
    fwd_hooks=[(f"blocks.{layer}.attn.hook_z", lambda z, hook: zero_head(z, hook, head))],
)
FROM SCRATCH
def forward(self, x, ablate_head=None):
    B, T, C = x.shape
    h, dh = self.n_heads, self.d_head
    q = self.q(x).view(B, T, h, dh).transpose(1, 2)
    k = self.k(x).view(B, T, h, dh).transpose(1, 2)
    v = self.v(x).view(B, T, h, dh).transpose(1, 2)
    scores = q @ k.transpose(-2, -1) / math.sqrt(dh)
    scores = scores.masked_fill(self.mask[:, :, :T, :T], float('-inf'))
    attn = F.softmax(scores, dim=-1)
    self.last_pattern = attn.detach()
    z = attn @ v                       # (B, h, T, dh) == hook_z, pre-W_O
    if ablate_head is not None:
        z[:, ablate_head] = 0.0        # zero this head's slice
    out = z.transpose(1, 2).contiguous().view(B, T, C)
    return self.o(out)                 # self.o == W_O

from scratch: lab/solution.py: AttentionOnlyLayer.forward (ablate_head) / TinyTransformer.forward (ablate)

  1. 1fwd_hooks=[("blocks.{layer}.attn.hook_z", fn)] registers an intervention on the per-head attention output the `ablate_head` argument threaded into AttentionOnlyLayer.forward (and `ablate=(layer,head)` in TinyTransformer.forward) is the hand-rolled intervention point
  2. 2the hooked tensor z has shape (batch, seq, n_heads, d_head) -- TransformerLens's hook_z is the attn output BEFORE the output projection z = attn @ v has shape (B, h, T, dh): same tensor, seq/head axes transposed, still pre-self.o
  3. 3z[:, :, head, :] = 0.0 zeroes one head across all positions z[:, ablate_head] = 0.0 zeroes one head across all positions (head is axis 1 here, axis 2 in TL layout)
  4. 4model.run_with_hooks(...) re-runs the forward pass with the hook live calling forward(x, ablate_head=...) re-runs this layer with the slice zeroed
  5. 5W_O is applied downstream by the block, so zeroing hook_z removes exactly that head's contribution to the residual stream self.o(out) is W_O applied to the flattened heads; the zeroed slice contributes nothing
What the one call hides
  • TransformerLens stores hook_z as (batch, seq, n_heads, d_head); the scratch keeps (batch, n_heads, seq, d_head). The library's canonical layout is the one you must match when you write a real hook.
  • run_with_hooks de-registers the hook automatically after the pass; the scratch path mutates z in-place inside one call, so there is no register/cleanup lifecycle to leak.
  • TransformerLens supports ablating at any of ~12 named hookpoints (hook_z, hook_v, hook_pattern, hook_resid_pre, ...); the scratch only exposes the single z-ablation site.
  • The library handles batching, device placement, and the full GPT-2 architecture (LayerNorm, MLPs, n_ctx) around the same hook; the scratch is attention-only with no LayerNorm.
  • Gotcha: Mean-ablation vs zero-ablation: zeroing hook_z is a strong, off-distribution intervention. Real interp work often patches in the mean activation (or a corrupt-run activation) instead; the scratch and the lib snippet both show the zero variant, which is the harsher test.
  • Gotcha: Mutating the activation in-place inside a hook (z[:, :, head, :] = 0.0) edits the tensor TransformerLens is tracking; you must return it, and you must not rely on the un-edited value elsewhere in the same pass.
  • Gotcha: Zeroing hook_z removes the head pre-W_O. If you instead zeroed hook_attn_out you would remove the whole layer's attention, not one head -- pick the right hookpoint.

On the job, use TransformerLens hooks: head ablation is one line on a real model with every hookpoint named. Hand-roll the z-slice only to prove to yourself that 'ablate a head' is literally 'set this slice to zero before W_O' -- which is what this lab is for.

On the job: Causal localization: ablate a candidate head and measure whether task performance drops, to test whether that head is actually doing the work.

Induction-head score from the attention pattern

DL primitive
LIBRARY
_, cache = model.run_with_cache(batch)            # batch = [rand | rand], len 2*half
seq_len = batch.shape[-1]; half = seq_len // 2
scores = torch.zeros(model.cfg.n_layers, model.cfg.n_heads)
for l in range(model.cfg.n_layers):
    pattern = cache["pattern", l]                  # (batch, n_heads, seq, seq)
    stripe = pattern.diagonal(offset=-(half - 1), dim1=-2, dim2=-1)  # (b, h, seq-(half-1))
    scores[l] = stripe[:, :, 1:].mean(dim=(0, 2))  # second-half queries
FROM SCRATCH
def induction_score(model, batch):
    model.eval()
    with torch.no_grad():
        _ = model(batch)               # populates layer.last_pattern
    seq_len = batch.shape[-1]; half = seq_len // 2
    n_layers = len(model.layers); n_heads = model.layers[0].n_heads
    scores = torch.zeros(n_layers, n_heads)
    for l, layer in enumerate(model.layers):
        pattern = layer.last_pattern   # (B, h, T, T)
        vals = []
        for t in range(half, seq_len):
            target_key = t - half + 1  # the induction stripe
            if 0 <= target_key <= t:
                vals.append(pattern[:, :, t, target_key])
        stack = torch.stack(vals, dim=0)   # (T-half, B, h)
        scores[l] = stack.mean(dim=(0, 1))
    return scores

from scratch: lab/solution.py: induction_score

  1. 1cache["pattern", l] -- run_with_cache stores the post-softmax attention pattern at every layer layer.last_pattern -- forward() stashes attn.detach() after softmax; same tensor (post-softmax pattern)
  2. 2pattern.diagonal(offset=-(half-1), dim1=-2, dim2=-1) reads pattern[..., t, t-(half-1)] for every valid t in one vectorized call the explicit loop `for t in range(half, seq_len): pattern[:, :, t, t-half+1]` reads the same off-diagonal entry (t-half+1 == t-(half-1))
  3. 3stripe[:, :, 1:] keeps query positions in the SECOND half ([half, seq)) by dropping the t=half-1 diagonal entry the loop starts at t=half, so it already restricts to second-half queries
  4. 4.mean(dim=(0, 2)) averages over batch and query position, leaving one score per head stack.mean(dim=(0, 1)) averages over query position and batch, leaving one score per head
  5. 5scores has shape (n_layers, n_heads); a head with score >> 1/seq_len is an induction-head candidate scores has the same (n_layers, n_heads) shape and the same chance baseline of 1/seq_len
What the one call hides
  • The library reads the pattern from an ActivationCache produced by run_with_cache; the scratch relies on a hand-stashed layer.last_pattern set as a side effect of forward(). The cache is a managed, named store; the side-effect attribute is fragile (overwritten by the next forward).
  • TransformerLens's diagonal idiom is fully vectorized; the scratch Python loop over t is O(seq) calls but numerically identical.
  • The library cache also exposes hook_attn_scores (pre-softmax), q/k/v, and z at the same layer, so you can swap to a different diagnostic without re-running; the scratch only saves the post-softmax pattern.
  • On a real model the induction-score sequence convention (a repeated random prefix of length half) and the exact offset are library-documented idioms; the scratch bakes them into make_batch + the loop.
  • Gotcha: Off-by-one on the offset: the induction stripe is at key = t - half + 1, i.e. diagonal offset -(half-1), NOT -half. Using -half points at the duplicate token itself (the previous-token stripe), not the token-after-the-duplicate that an induction head copies.
  • Gotcha: torch.diagonal returns a view whose last axis indexes the diagonal starting at the first valid (query,key) pair, so you must drop the leading entry to restrict to second-half queries -- the same restriction the scratch gets for free by starting its loop at t=half.
  • Gotcha: The score is only meaningful on a [rand | rand] sequence; on natural text the diagonal entry has no induction interpretation.

On the job, read cache["pattern", l] and take the fixed-offset diagonal -- it is the standard ARENA/TransformerLens induction-score one-liner. Write the explicit per-t loop once, here, so you know the 'one-liner' is exactly 'average the t -> t-half+1 attention entries over second-half query positions'.

On the job: Screening: scan every (layer, head) of a trained model for the induction stripe to shortlist heads before spending expensive ablation/path-patching budget on them.

Loss delta from ablating a head (clean vs ablated)

DL glue
LIBRARY
def zero_head(z, hook: HookPoint, head: int):
    z[:, :, head, :] = 0.0
    return z

clean_loss = model(tokens, return_type="loss")
ablated_loss = model.run_with_hooks(
    tokens, return_type="loss",
    fwd_hooks=[(f"blocks.{layer}.attn.hook_z", lambda z, hook: zero_head(z, hook, head))],
)
# return_type="loss" computes full-sequence next-token cross-entropy
FROM SCRATCH
def verify_head_by_ablation(model, batch, layer, head):
    half = batch.shape[1] // 2
    model.eval()
    with torch.no_grad():
        clean = model(batch)
        ablated = model(batch, ablate=(layer, head))
    targets = batch[:, half:]
    clean_loss = F.cross_entropy(
        clean[:, half - 1:-1, :].reshape(-1, clean.size(-1)),
        targets.reshape(-1),
    )
    ablated_loss = F.cross_entropy(
        ablated[:, half - 1:-1, :].reshape(-1, ablated.size(-1)),
        targets.reshape(-1),
    )
    return float(clean_loss.item()), float(ablated_loss.item())

from scratch: lab/solution.py: verify_head_by_ablation

  1. 1model(tokens, return_type="loss") -- a clean forward pass scored as next-token cross-entropy clean = model(batch) then F.cross_entropy(clean[:, half-1:-1], batch[:, half:]) -- the same clean forward, scored by hand
  2. 2run_with_hooks(..., fwd_hooks=[("blocks.{layer}.attn.hook_z", zero_head)]) -- ablated forward via the head-zero hook ablated = model(batch, ablate=(layer, head)) -- the same ablation routed through TinyTransformer.forward's ablate path
  3. 3return_type="loss" runs the cross-entropy internally over all next-token positions F.cross_entropy(...) supplied by hand, sliced to positions [half-1:-1] predicting [half:]
  4. 4comparing clean_loss vs ablated_loss tells you whether the head was load-bearing (ablated > clean => head mattered) returns (clean_loss, ablated_loss) for the same comparison
What the one call hides
  • TransformerLens's return_type="loss" computes next-token cross-entropy over the FULL sequence; the scratch deliberately scores only the predictable second half ([half-1:-1] -> [half:]). Same metric family, different position set -- the absolute numbers differ even though the sign of the delta agrees.
  • The library wraps the ablation, the forward, and the loss in two calls; the scratch spells out the eval()/no_grad() context, the clean and ablated passes, and the cross-entropy slicing.
  • On a real interp task you usually compare a logit-DIFFERENCE metric (correct minus incorrect token) rather than raw cross-entropy; both the scratch and return_type="loss" use CE because the synthetic copy task has no single 'wrong' token.
  • TransformerLens lets you pass any custom metric by reading the patched logits; the scratch hard-codes CE-on-second-half.
  • Gotcha: Sign, not magnitude, is the signal: ablating a genuinely load-bearing head RAISES loss. With a different seed the head with the top induction score is not always the most load-bearing one, so the delta can come out negative -- the chapter's test_ablation_hurts_a_real_head pins specific seeds for this reason.
  • Gotcha: If you score the full sequence (TransformerLens default) instead of the second half, the first-half positions are unpredictable noise that dilutes the delta; for the copy task you want the second-half slice the scratch uses.
  • Gotcha: Zero-ablation is off-distribution; a head can look load-bearing under zero-ablation but not under mean/corrupt-ablation. Confirm with a milder intervention before claiming the head causes the behavior.

On the job, use run_with_hooks(return_type="loss") (or a logit-diff metric) -- two lines give you the clean/ablated comparison on a real model. Hand-roll the clean/ablated cross-entropy once to see that 'does this head matter' is just 'rerun with the head zeroed and diff the loss'.

On the job: Validating a circuit hypothesis: after a head is flagged by attention pattern or DLA, confirm it is causal by checking that ablating it measurably degrades the task.


FIG 22.6 · Chapter notebook

Build this chapter with your own hands

A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.

What you'll build

  • A 2-layer attention-only transformer, trained from scratch on synthetic repeat sequences, that learns to copy-by-pattern in seconds on CPU.
  • An induction-stripe detector: the exact attention-pattern signature that says "this head is an induction head", computed and asserted against ground truth.
  • An ablation experiment that knocks out the circuit and measures the damage, plus a control that knocks out the wrong heads and measures nothing.
  • An out-of-distribution test that proves the circuit is a real algorithm, not a memorized lookup table, and a from-scratch activation-patch that localizes where the information flows.
  • The OV-circuit decomposition that shows, in the vocabulary basis, that the head literally copies whatever token it attends to.

~3 min on CPU · 99 cells · 6 checked exercises · runs in Colab


FIG 22.7 · Going further

  • 19-nanda-blog/*

    Read all of Neel Nanda's blog. If you read one essay first, make it "An Extremely Opinionated Annotated List of My Favourite Mechanistic Interpretability Papers".

  • 05-safety/neelnanda-mechanistic-interpretability-glossary

    the 219KB glossary. Bookmark it. Search it whenever a term in a paper is unfamiliar.

  • 22-anthropic-recent/2025-attribution-graphs-biology

    the "biology" paper. The most readable demonstration of attribution graphs on a frontier model.

  • 22-anthropic-recent/2024-scaling-monosemanticity-index

    Anthropic's flagship SAE result on Sonnet. Read with the index of Distill articles open in another tab.

  • 14-arena-notebooks/chapter1-part1 through chapter1-part42 — the ARENA chapter is the best hands-on curriculum on the planet for this material. Spend a month with it.
  • 01-explorables/distill-circuits-zoom-in and the rest of the Distill Circuits thread — the original vision-side roots. Still the best intuitions on what a "feature" is.
  • neuronpedia.org

    get an account, click through features for an hour. The dashboards teach better than any paper.

  • transformer-circuits.pub

    the home of every Anthropic mech-interp paper. Most are on 22-anthropic-recent/ in the corpus, but the original site has the interactive figures.

  • 5-safety/transformerlens-transformerlens-content-getting_started

    the TransformerLens tutorial. Run every example.

  • 22-anthropic-recent/2022-in-context-learning-and-induction-heads-index

    the induction-heads paper, the canonical reference behind §9 and the lab.


FIG 22.8 · What this enables

Chapters you can now read, with the connecting idea written out.

  • mech interp is the missing ingredient in many evals. Once you can locate the refusal direction, you can write evals that check the direction directly rather than relying on behavioral red-teams that can be gamed.

  • the techniques here are the substrate for safety arguments. Every red-team or alignment-evidence claim should be cross-checked against mech-interp findings where possible. Persona vectors, refusal monitoring, and backdoor detection all live in another chapter's working vocabulary.

  • monitoring a deployed model's mech-interp signals (refusal direction projection, deception-feature firing rate) is an operational pattern that does not yet have standard tooling. If you build it, you are at the frontier.

  • half the papers worth reading in 2026 are mech-interp papers. Knowing the vocabulary in this chapter is the difference between reading them and skimming them.


FIG 22.9 · 61 sources
  1. - `01-explorables/distill-circuits-zoom-in`
  2. - `01-explorables/distill-circuits-visualizing-weights`
  3. - `01-explorables/distill-feature-visualization`
  4. - `01-explorables/distill-building-blocks`
  5. - `01-explorables/distill-activation-atlas`
  6. - `01-explorables/distill-multimodal-neurons`
  7. - `05-safety/anthropic-research-core-views-on-ai-safety`
  8. - `05-safety/neelnanda-mechanistic-interpretability`
  9. - `05-safety/neelnanda-mechanistic-interpretability-glossary`
  10. - `05-safety/neelnanda-mechanistic-interpretability-quickstart`
  11. - `05-safety/neelnanda-mechanistic-interpretability-getting-started`
  12. - `05-safety/neelnanda-mechanistic-interpretability-prereqs`
  13. - `05-safety/neuronpedia-home`
  14. - `05-safety/transformerlens-transformerlens`
  15. - `05-safety/transformerlens-transformerlens-content-getting_started`
  16. - `14-arena-notebooks/chapter1-part1-transformer-from-scratch`
  17. - `14-arena-notebooks/chapter1-part2-intro-to-mech-interp`
  18. - `14-arena-notebooks/chapter1-part31-linear-probes`
  19. - `14-arena-notebooks/chapter1-part32-function-vectors-and-model-steering`
  20. - `14-arena-notebooks/chapter1-part33-interp-with-saes`
  21. - `14-arena-notebooks/chapter1-part34-activation-oracles`
  22. - `14-arena-notebooks/chapter1-part41-indirect-object-identification`
  23. - `14-arena-notebooks/chapter1-part42-sae-circuits`
  24. - `14-arena-notebooks/chapter1-part51-balanced-bracket-classifier`
  25. - `14-arena-notebooks/chapter1-part52-grokking-and-modular-arithmetic`
  26. - `14-arena-notebooks/chapter1-part53-othellogpt`
  27. - `14-arena-notebooks/chapter1-part54-toy-models-of-superposition-and-saes`
  28. - `14-arena-notebooks/chapter4-part1-emergent-misalignment`
  29. - `14-arena-notebooks/chapter4-part3-interpreting-reasoning-models`
  30. - `14-arena-notebooks/chapter4-part4-persona-vectors`
  31. - `19-nanda-blog/interlude-a-mechanistic-interpretability-analysis-of-grokking`
  32. - `19-nanda-blog/mats-apps-9`
  33. - `20-aisafetybook/monitoring`
  34. - `22-anthropic-recent/2021-framework-index`
  35. - `22-anthropic-recent/2021-exercises-index`
  36. - `22-anthropic-recent/2022-in-context-learning-and-induction-heads-index`
  37. - `22-anthropic-recent/2022-mech-interp-essay-index`
  38. - `22-anthropic-recent/2023-interpretability-dreams-index`
  39. - `22-anthropic-recent/2023-may-update-index`
  40. - `22-anthropic-recent/2023-monosemantic-features-index`
  41. - `22-anthropic-recent/2023-superposition-composition-index`
  42. - `22-anthropic-recent/2023-toy-double-descent-index`
  43. - `22-anthropic-recent/2024-april-update-index`
  44. - `22-anthropic-recent/2024-august-update-index`
  45. - `22-anthropic-recent/2024-crosscoders-index`
  46. - `22-anthropic-recent/2024-feb-update-index`
  47. - `22-anthropic-recent/2024-features-as-classifiers-index`
  48. - `22-anthropic-recent/2024-june-update-index`
  49. - `22-anthropic-recent/2024-march-update-index`
  50. - `22-anthropic-recent/2024-scaling-monosemanticity-index`
  51. - `22-anthropic-recent/2024-september-update-index`
  52. - `22-anthropic-recent/2025-attention-qk-index`
  53. - `22-anthropic-recent/2025-attribution-graphs-biology`
  54. - `22-anthropic-recent/2025-attribution-graphs-methods`
  55. - `22-anthropic-recent/2025-bulk-update-index`
  56. - `22-anthropic-recent/2025-crosscoder-diffing-update-index`
  57. - `22-anthropic-recent/2025-faithfulness-toy-model-index`
  58. - `22-anthropic-recent/2025-interference-weights-index`
  59. - `22-anthropic-recent/2025-introspection-index`
  60. - `22-anthropic-recent/2026-emotions-index`
  61. - `22-anthropic-recent/2026-headvis-index`