Skip to content
GKgkml.dev
← LLM mechanisms

Part 6 · Causal mech interp · module 3 of 4 · 15 min

Interfering with attention

Head-level causal experiments — ablation, silencing, per-head patching and what each reveals about attention's role.

The essence

Attention heads are the natural unit of intervention because they are structurally independent: each writes into the residual stream through its own slice of the output projection, so you can remove one without touching the others. Ablating heads one at a time and measuring the effect gives a per-head importance map for any behaviour.

Why it matters

Head-level results are the most reproducible findings in mechanistic interpretability — induction heads, name-mover heads and duplicate-token heads were all found this way. It is also where the redundancy of these models becomes undeniable: most single-head ablations do almost nothing, which constrains what a circuit account can claim.

Topics covered (6)
  1. 01Head ablation and its effect on token prediction
  2. 02Token prediction after ablating individual heads
  3. 03The impact of head silencing on representational similarity
  4. 04Probing a specific learned association by head ablation
  5. 05Attention head patching in indirect object identification
  6. 06Per-head and per-token patching

Two different things called head ablation

The first is zeroing the head's output. Attention concatenates all heads before the output projection, so head h occupies a contiguous slice of that concatenated vector. Zero the slice and the head contributes nothing to the residual stream, while every other head is untouched. This is the clean, standard ablation.

The second is forcing the head's attention pattern to be uniform, so it averages all positions instead of selecting. That removes the head's routing decision while still letting it write a value into the stream. It is a weaker, differently-shaped intervention, and it answers a different question — whether the head's specific pattern matters, rather than whether the head matters.

They are not interchangeable, and papers sometimes report one while describing the other. Be explicit about which you ran.

Both kinds of head ablation
import torch

def ablate_head_output(head, n_heads, dim):
    """Zero one head's slice of the concatenated attention output.
    Attach to attn.c_proj as a FORWARD PRE-hook, so you edit the input to the
    output projection — that is where heads are still separable."""
    head_dim = dim // n_heads
    lo, hi = head * head_dim, (head + 1) * head_dim

    def pre_hook(module, inputs):
        x = inputs[0].clone()
        x[..., lo:hi] = 0
        return (x,)
    return pre_hook

handle = model.transformer.h[9].attn.c_proj.register_forward_pre_hook(
    ablate_head_output(6, model.config.n_head, model.config.n_embd))
with torch.no_grad():
    ablated = model(**prompt).logits
handle.remove()

# The other kind: force the pattern uniform (causal-masked), leaving the head
# active but non-selective. Requires patching inside the attention computation,
# which for GPT-2 means overriding the module's forward or using a library
# such as TransformerLens that exposes the pattern directly.

def uniform_causal_pattern(T, device):
    m = torch.tril(torch.ones(T, T, device=device))
    return m / m.sum(-1, keepdim=True)

Note: Use a forward PRE-hook on c_proj, not a forward hook on attn. After the output projection the heads are mixed together and can no longer be separated.

The full per-head importance sweep
import torch, numpy as np

@torch.no_grad()
def head_importance(model, prompt, answer_id, wrong_id):
    """Effect of ablating each head individually. The output is the map that
    every head-level finding in the literature comes from."""
    n_layers, n_heads = model.config.n_layer, model.config.n_head
    dim = model.config.n_embd

    def logit_diff():
        lg = model(**prompt).logits[0, -1]
        return (lg[answer_id] - lg[wrong_id]).item()

    base = logit_diff()
    effects = np.zeros((n_layers, n_heads))

    for L in range(n_layers):
        for H in range(n_heads):
            h = model.transformer.h[L].attn.c_proj.register_forward_pre_hook(
                ablate_head_output(H, n_heads, dim))
            effects[L, H] = logit_diff() - base
            h.remove()
    return effects

eff = head_importance(model, ioi_prompt, mary_id, john_id)

flat = np.dstack(np.unravel_index(np.argsort(np.abs(eff).ravel())[::-1], eff.shape))[0]
for L, H in flat[:10]:
    print(f"L{L}H{H}  Δlogit_diff {eff[L, H]:+.3f}")

print(f"\nheads with |effect| > 0.5: {(np.abs(eff) > 0.5).sum()} of {eff.size}")
# Typically a small handful. The overwhelming majority of heads are irrelevant
# to any given behaviour, which is what makes circuit analysis tractable at all.

# Negative values are as interesting as positive: a head whose removal IMPROVES
# the correct answer was suppressing it. IOI has exactly such heads.

Redundancy, and what it means for circuit claims

Run the sweep and the dominant result is that almost nothing matters. Most heads can be removed with negligible effect on any particular behaviour, and often several important-looking heads can be removed individually with small effects that do not add up when removed together — because other heads compensate.

This has a direct methodological consequence. 'Head 9.6 implements name-moving' should mean 'ablating it degrades name-moving substantially, and no other head compensates'. Testing the second half requires ablating candidate groups jointly, not just individually. A circuit established only from single-head ablations may be one of several redundant implementations.

Silencing and representational similarity

Instead of measuring the effect on a logit, measure the effect on the representation. Ablate a head, recompute the hidden states, and compare their similarity structure to the unablated version using RSA.

This detects heads that reorganise the representation without changing the immediate prediction — heads doing work whose consequences appear later or elsewhere. A head with near-zero logit effect but a large RSA effect is genuinely doing something, and a purely output-based sweep would have discarded it.

Measuring representational impact rather than logit impact
import torch, numpy as np

@torch.no_grad()
def representational_impact(model, prompt, layer, head, probe_layer=-1):
    """How much does ablating this head reorganise a later layer's structure?"""
    base = model(**prompt, output_hidden_states=True).hidden_states[probe_layer][0].numpy()

    h = model.transformer.h[layer].attn.c_proj.register_forward_pre_hook(
        ablate_head_output(head, model.config.n_head, model.config.n_embd))
    ablated = model(**prompt, output_hidden_states=True).hidden_states[probe_layer][0].numpy()
    h.remove()

    return {
        # 1 - RSA: how much the similarity STRUCTURE changed.
        "structure_change": 1 - rsa(base, ablated),
        # Mean per-token cosine displacement: how far vectors MOVED.
        "mean_displacement": float(np.mean([
            1 - np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-12)
            for a, b in zip(base, ablated)])),
    }

# Compare the two views for every head. Heads that score low on logit effect
# and high on structure change are the ones a logit-only sweep would miss.

Per-head patching

Combining patching with head-level granularity gives the sharpest tool in the set. Rather than patching a whole layer's residual stream, patch a single head's output from the clean run into the corrupted run.

The result identifies not just which layers carry the information but which heads move it, and patching per head and per position simultaneously shows where each head reads from and writes to. That combination is how the IOI circuit was resolved into named components: duplicate-token heads that detect the repeated name, S-inhibition heads that suppress it, and name-mover heads that carry the remaining name to the output position.

Per-head, per-position patching
import torch, numpy as np

@torch.no_grad()
def capture_head_input(model, prompt, layer):
    """The concatenated per-head output, before the output projection."""
    box = {}
    h = model.transformer.h[layer].attn.c_proj.register_forward_pre_hook(
        lambda m, inputs: box.__setitem__("x", inputs[0].detach().clone()))
    model(**prompt)
    h.remove()
    return box["x"]              # [B, T, dim]

def patch_head_slice(donor, head, n_heads, dim, position=None):
    head_dim = dim // n_heads
    lo, hi = head * head_dim, (head + 1) * head_dim

    def pre_hook(module, inputs):
        x = inputs[0].clone()
        if position is None:
            x[..., lo:hi] = donor[..., lo:hi]
        else:
            x[:, position, lo:hi] = donor[:, position, lo:hi]
        return (x,)
    return pre_hook

@torch.no_grad()
def head_patch_map(model, clean, corrupted, answer_id, wrong_id):
    n_layers, n_heads = model.config.n_layer, model.config.n_head
    dim = model.config.n_embd
    ld = lambda p: (lambda lg: (lg[answer_id] - lg[wrong_id]).item())(
        model(**p).logits[0, -1])

    base_clean, base_corr = ld(clean), ld(corrupted)
    out = np.zeros((n_layers, n_heads))

    for L in range(n_layers):
        donor = capture_head_input(model, clean, L)
        for H in range(n_heads):
            h = model.transformer.h[L].attn.c_proj.register_forward_pre_hook(
                patch_head_slice(donor, H, n_heads, dim))
            out[L, H] = (ld(corrupted) - base_corr) / (base_clean - base_corr + 1e-9)
            h.remove()
    return out

# Add a position loop for the full (layer, head, position) tensor. Slower, and
# it is what turns 'these heads matter' into 'this head reads from position i
# and writes to position j' — an actual mechanism.

Worth remembering

  • Zeroing a head's output slice is the clean ablation; forcing its attention pattern uniform is a different, weaker intervention.
  • Most single-head ablations have negligible effect — redundancy is the norm, not the exception.
  • Per-head patching localises a behaviour to specific heads far more sharply than layer-level patching.