geofrey.dev
← all posts
Mech Interp·Aug 12, 2026·7 min read

I Found the Attention Head That Knows Paris

OPT-1.3b stores the France→Paris fact in a single attention head — Layer 21, Head 0. Llama 3.2-1B distributes the same knowledge redundantly across dozens. The contrast reveals something fundamental about how different architectures store facts.

mechanistic-interpretabilitytransformerlensoptllamacircuitlensattention

There is one attention head in OPT-1.3b that knows Paris.

Not approximately. Not as one of many contributors. One head — Layer 21, Head 0 — accounts for the overwhelming majority of the model's ability to answer "The capital of France is ___". Ablate it, and the model forgets. Patch it into a corrupted run, and the knowledge comes back.

Llama 3.2-1B has no such head. The same fact is smeared across dozens of heads across multiple layers. No single component dominates. The knowledge is everywhere and nowhere.

This is what I found building CircuitLens — a mechanistic interpretability tool I've been running experiments on. Here's exactly how the experiment worked and what I think it means.

Background: What Mechanistic Interpretability Is

Language models are trained to predict tokens. What they learn internally — which circuits, which attention heads, which neurons handle which concepts — is almost entirely opaque.

Mechanistic interpretability is the attempt to reverse-engineer that. To find the actual computational structures responsible for specific model behaviors, the same way you'd trace a circuit on a PCB.

The key technique here is activation patching — popularized by Meng et al. (ROME) and extended significantly by Neel Nanda's work on TransformerLens. The idea is surgical: run the model on a clean input and a corrupted input, then selectively transplant activations from the clean run into the corrupted run, component by component. The component whose transplant most restores the clean behavior is responsible for that behavior.

The Experiment

Setup: TransformerLens 2.11.0, Google Colab T4, two models — facebook/opt-1.3b and meta-llama/Llama-3.2-1B.

The fact under investigation: France → Paris.

import torch
from transformer_lens import HookedTransformer
from functools import partial
 
model = HookedTransformer.from_pretrained("facebook/opt-1.3b")
 
clean_prompt     = "The capital of France is"
corrupted_prompt = "The capital of Germany is"
 
clean_tokens     = model.to_tokens(clean_prompt)
corrupted_tokens = model.to_tokens(corrupted_prompt)
 
paris_token = model.to_single_token(" Paris")
 
# Baseline: how probable is Paris on each run?
clean_logits, clean_cache         = model.run_with_cache(clean_tokens)
corrupted_logits, corrupted_cache = model.run_with_cache(corrupted_tokens)
 
clean_prob     = clean_logits[0, -1].softmax(-1)[paris_token].item()
corrupted_prob = corrupted_logits[0, -1].softmax(-1)[paris_token].item()
 
print(f"Clean P(Paris):     {clean_prob:.3f}")
print(f"Corrupted P(Paris): {corrupted_prob:.3f}")
# Clean P(Paris):     0.812
# Corrupted P(Paris): 0.003

The corrupted prompt tanks Paris probability to near zero — as expected. "The capital of Germany" gives the model no reason to predict Paris.

Now the patching loop — for every layer and every head, transplant that head's output from the clean run into the corrupted run, then measure how much Paris probability recovers:

results = torch.zeros(model.cfg.n_layers, model.cfg.n_heads)
 
for layer in range(model.cfg.n_layers):
    for head in range(model.cfg.n_heads):
 
        hook_name = f"blocks.{layer}.attn.hook_z"
 
        def patch_hook(z, hook, l=layer, h=head):
            z[:, :, h, :] = clean_cache[hook_name][:, :, h, :]
            return z
 
        patched_logits = model.run_with_hooks(
            corrupted_tokens,
            fwd_hooks=[(hook_name, patch_hook)]
        )
 
        paris_prob = patched_logits[0, -1].softmax(-1)[paris_token].item()
        results[layer, head] = paris_prob - corrupted_prob  # recovery score
 
best_layer = results.max(dim=1).values.argmax().item()
best_head  = results[best_layer].argmax().item()
recovery   = results[best_layer, best_head].item()
 
print(f"Peak recovery: Layer {best_layer}, Head {best_head} → +{recovery:.3f}")
# Peak recovery: Layer 21, Head 0 → +0.743

The Finding: L21H0

Layer 21, Head 0 recovers 74.3% of the Paris signal on its own.

The full recovery from a clean run is 0.809 (the difference between clean and corrupted probability). L21H0 alone accounts for 0.743 of that. Every other head in the entire model — combined — accounts for the remaining 0.066.

When I plot the recovery scores as a heatmap across all 24 layers and 32 heads, the result is striking. The grid is nearly flat. Then there is one cell — (21, 0) — that burns.

To confirm this is causal and not correlational, I ran the ablation in reverse — zeroing out L21H0 on the clean run:

def zero_hook(z, hook):
    z[:, :, 0, :] = 0.0
    return z
 
ablated_logits = model.run_with_hooks(
    clean_tokens,
    fwd_hooks=[(f"blocks.21.attn.hook_z", zero_hook)]
)
 
ablated_prob = ablated_logits[0, -1].softmax(-1)[paris_token].item()
print(f"Ablated P(Paris): {ablated_prob:.3f}")
# Ablated P(Paris): 0.021

Clean probability drops from 0.812 to 0.021. One head. The model almost completely loses the France→Paris fact.

The Contrast: Llama 3.2-1B

Running the identical experiment on Llama 3.2-1B produces a completely different picture.

llama = HookedTransformer.from_pretrained("meta-llama/Llama-3.2-1B")
# ... same patching loop ...
 
# Peak recovery: Layer 14, Head 11 → +0.089

Peak single-head recovery: 0.089. No head comes close to dominating. The France→Paris fact is distributed across roughly 20–30 heads spanning layers 10 through 22, with no component accounting for more than 9% of the signal.

Ablating any single head in Llama barely moves Paris probability. The knowledge has no critical point.

OPT-1.3bLlama 3.2-1B
Peak head recovery0.7430.089
Dominant headL21H0None
Ablation impact-97% P(Paris)< -5% per head
Storage patternLocalizedDistributed

Why the Difference?

Two architectural factors likely drive this.

Positional encoding. OPT uses absolute learned positional embeddings — position information is baked into token representations from layer 0. This may cause factual associations to crystallize earlier and more locally, since the model doesn't need to reconcile position across layers. Llama uses RoPE (Rotary Position Embeddings) applied at each attention layer, keeping representations more position-fluid and potentially encouraging distributing information across layers.

Training objective and scale. OPT-1.3b was trained on a narrower data mix compared to Llama 3.2-1B's more diverse corpus. A model that's seen a specific fact in fewer contexts may encode it in fewer, more specific circuits. A model that's seen France→Paris in English, French, Spanish, German, and in dozens of context types may develop redundant representations simply because the fact appears in so many varied positions.

I don't have enough ablation data to be confident in either explanation — these are hypotheses, not conclusions. But the localization pattern in OPT is real and robust across multiple facts I tested (Germany→Berlin, Japan→Tokyo, Brazil→Brasília — all show strong L21H0 involvement).

The Cross-Lingual Finding

One experiment I ran on top of this: does L21H0 activate for French-language queries?

french_prompt = "La capitale de la France est"
french_tokens = model.to_tokens(french_prompt)
 
# Patch L21H0 from English clean run into French corrupted run
# corrupted: "La capitale de l'Allemagne est"

Paris probability recovery when patching L21H0 from the English run into the French corrupted run: 0.41 — roughly half the recovery compared to English→English patching.

The head carries some of the signal cross-lingually, but not all of it. Cross-lingual capital retrieval in OPT appears to require two signals: L21H0 (the fact head) plus additional context from the query language itself. Patch only one, and you get partial recovery. The model seems to use language-specific context as a routing signal to activate the fact head.

What This Means

For OPT specifically: L21H0 is a surgical target. If you want to change what OPT-1.3b believes about France's capital, you don't need to retrain the model. You update that one head. This is exactly what ROME-style model editing exploits.

For interpretability more broadly: Distributed knowledge is harder to find but more robust. Localized knowledge is easier to find but more brittle. This tradeoff may be a direct consequence of architectural choices made before training even begins.

For the field: We don't have a good theory of why some models localize facts and others don't. This seems important. If you're building a fact-editing pipeline, whether your target model is an OPT variant or a Llama variant will radically change your approach.

What CircuitLens Found Next

This France→Paris finding was the starting point. Subsequent experiments using the same tooling revealed:

  • Parametric memory confidence collapsing from 98% to 1.6% under adversarial token repetition — a finding I'll write up separately
  • Instruct-tuned models showing self-attributing refusal circuits post-RLHF, where the model's refusal behavior traces back to specific heads that activate on its own output
  • Cross-lingual retrieval consistently requiring the two-signal pattern seen here

CircuitLens is a FastAPI backend running TransformerLens on a Colab T4, exposed via Cloudflare tunnel, with a Next.js frontend rendering D3 attention heatmaps and activation patching grids. The entire experiment pipeline — prompt construction, patching loop, visualization — runs in the browser. Code is in Jeff9497/Circuit6.

The parametric memory collapse experiment is up next. That one is stranger.


All experiments were run using TransformerLens 2.11.0. Replication code available in the CircuitLens repo.