Part 6 · Causal mech interp · module 1 of 4 · 14 min
How to modify activations
The mechanics of causal interpretability — hooks that write rather than read, and the four intervention types with their different confounds.
The essence
Causal interpretability is one move performed carefully: change something inside the model, run the forward pass, and measure what changed in the output. Observation tells you a component correlates with a behaviour; intervention tells you whether the behaviour depends on it. Everything else in this part is a variation on where you intervene and what you replace the activation with.
Why it matters
This is the step that converts interpretability from description into evidence. It is also where the methodology is most easily botched: every intervention pushes the model off its training distribution, so a degraded output may reflect that rather than the component's role. Knowing which replacement value to use, and what each choice confounds, is the whole skill.
Topics covered (3)
- 01Introduction to causal mechanistic interpretability
- 02Activation editing: code implementations
- 03Replacing attention, MLP and hidden-state activations
From observation to intervention
In the previous part every hook returned nothing, so it only recorded. If a forward hook returns a tensor, PyTorch substitutes that tensor for the module's output and the rest of the forward pass proceeds with the replacement. That single mechanism is the entirety of activation editing.
The logic of the experiment is the logic of a lesion study. If a component is necessary for a behaviour, removing it should degrade that behaviour specifically. The 'specifically' carries the weight: a change that degrades everything tells you only that you broke the model.
The four ways to replace an activation
| Method | Replacement | Confound |
|---|---|---|
| Zero ablation | All zeros | Furthest off-distribution; a zero vector is not a plausible activation |
| Mean ablation | The mean over a corpus | Removes information while keeping scale — usually the better default |
| Resample ablation | The activation from a random other input | Stays on-distribution; noisier, so average over many samples |
| Patching | The activation from a specific chosen input | Not an ablation — tests whether that information suffices |
Why zero ablation is the weakest choice
A trained model never sees an all-zero activation at a middle layer. Setting one to zero moves the input to every downstream layer far outside anything in training, so the model may fail for reasons that have nothing to do with the component's function.
Mean ablation replaces the activation with its corpus average, which removes the input-specific information while keeping the scale and rough direction the downstream layers expect. Resample ablation goes further by substituting a real activation from a different input — genuinely on-distribution, at the cost of variance that requires averaging over many samples.
The disciplined practice is to run at least two methods. When they agree, the conclusion is robust; when they disagree, the difference itself is informative about how much of the effect was an off-distribution artefact.
import torch
from contextlib import contextmanager
@contextmanager
def intervene(module, fn):
"""Attach an editing hook for the duration of the block, then remove it.
A context manager because a forgotten hook silently corrupts every
subsequent forward pass."""
def hook(mod, inputs, output):
if isinstance(output, tuple):
return (fn(output[0]),) + output[1:] # attention returns a tuple
return fn(output)
handle = module.register_forward_hook(hook)
try:
yield
finally:
handle.remove()
# --- the four replacements, as editing functions -----------------------------
def zero(position=None):
def fn(x):
x = x.clone() # never edit in place
if position is None:
return torch.zeros_like(x)
x[:, position] = 0
return x
return fn
def mean_ablate(mean_vector, position=None):
def fn(x):
x = x.clone()
if position is None:
x[:] = mean_vector
else:
x[:, position] = mean_vector
return x
return fn
def patch_from(donor, position=None):
"""donor: activation captured from a different input, same shape."""
def fn(x):
x = x.clone()
if position is None:
return donor.clone()
x[:, position] = donor[:, position]
return x
return fn
def scale(factor, position=None):
def fn(x):
x = x.clone()
if position is None:
return x * factor
x[:, position] *= factor
return x
return fn
# --- usage ------------------------------------------------------------------
prompt = tok("The Eiffel Tower is in", return_tensors="pt")
with torch.no_grad():
baseline = model(**prompt).logits
with intervene(model.transformer.h[8].mlp, zero()):
with torch.no_grad():
ablated = model(**prompt).logits
# Assert the intervention actually fired. Silently-inert hooks — wrong module
# path, edit not returned, no clone — are the most common failure here.
assert not torch.allclose(baseline, ablated), "intervention had no effect"Note: Clone before editing. Modifying an activation in place can corrupt a tensor another part of the graph still holds, and the resulting bug is extremely hard to trace.
import torch, torch.nn.functional as F
@torch.no_grad()
def effect(model, prompt, target_id, module, editor, position=-1):
"""Change in the target token's logit — the standard effect size."""
base = model(**prompt).logits[0, position]
with intervene(module, editor):
edited = model(**prompt).logits[0, position]
return {
# Logit difference is preferred over probability difference: it is not
# squashed near 0 or 1, so effects stay comparable across prompts.
"logit_delta": float(edited[target_id] - base[target_id]),
"prob_before": float(F.softmax(base, -1)[target_id]),
"prob_after": float(F.softmax(edited, -1)[target_id]),
# How much did EVERYTHING change? A large KL with a small target delta
# means you damaged the model generally rather than the behaviour.
"kl": float(F.kl_div(F.log_softmax(edited, -1),
F.log_softmax(base, -1),
log_target=True, reduction="sum")),
}
# Sweep every layer to localise, and always run a control prompt where the
# component should NOT matter. An effect present in both is not specific.
for L in range(model.config.n_layer):
e = effect(model, prompt, paris_id, model.transformer.h[L].mlp, zero())
c = effect(model, control, control_id, model.transformer.h[L].mlp, zero())
print(f"layer {L:2} target {e['logit_delta']:+6.2f} "
f"control {c['logit_delta']:+6.2f} kl {e['kl']:.3f}")Worth remembering
- A forward hook that returns a value replaces the module's output — that one fact enables every intervention here.
- Zero ablation is the most off-distribution choice; mean and resample ablation are usually better controls.
- Always verify your intervention actually fired, by asserting the output changed on a case where it must.