Skip to content
GKgkml.dev
← LLM mechanisms

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)
  1. 01Introduction to causal mechanistic interpretability
  2. 02Activation editing: code implementations
  3. 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

MethodReplacementConfound
Zero ablationAll zerosFurthest off-distribution; a zero vector is not a plausible activation
Mean ablationThe mean over a corpusRemoves information while keeping scale — usually the better default
Resample ablationThe activation from a random other inputStays on-distribution; noisier, so average over many samples
PatchingThe activation from a specific chosen inputNot 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.

An intervention toolkit
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.

Measuring the effect, with the right metric
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.