Activation Patching › Code Implementation

Michael BrenndoerferJanuary 16, 202613 min read
Language AI HandbookAll chapters
Part 59 of 67Interpretability
Chapter 451Activation Patching › Code Implementation
Reading section 7 of 11 · Code Implementation · 13 minSection 7 of 11 · 13 min

Book contents

Section 7 of 11 · 13 min read

Activation Patching › Code ImplementationOne section · Sections in this chapter7 / 11 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's implement activation patching from scratch using a small transformer model. We'll work with GPT-2 and a factual recall task to demonstrate the core concepts.

Setup and Model Loading

We start by importing the necessary libraries and loading a pretrained model. GPT-2 is small enough to run on a laptop but large enough to exhibit interesting internal structure.

In[3]:
Code
import torch
from transformers import GPT2LMHeadModel, GPT2Tokenizer

# Load model and tokenizer
model_name = "gpt2"
tokenizer = GPT2Tokenizer.from_pretrained(model_name)
model = GPT2LMHeadModel.from_pretrained(model_name)
model.eval()

# Disable gradient computation for inference
torch.set_grad_enabled(False)

n_layers = model.config.n_layer
n_heads = model.config.n_head
d_model = model.config.n_embd
Out[4]:
Console
Model: gpt2
Layers: 12
Attention heads: 12
Hidden dimension: 768

GPT-2 small has 12 layers, 12 attention heads per layer, and a hidden dimension of 768. This gives us a reasonable model to experiment with while keeping computation tractable.

Capturing Activations with Hooks

PyTorch hooks allow us to intercept and record activations during a forward pass without modifying the model code. We register forward hooks on each transformer block to capture the residual stream after each layer. The hook fires after the block's computation and receives the output, which we clone and store.

In[5]:
Code
class ActivationCache:
    """Captures and stores activations from transformer blocks."""

    def __init__(self):
        self.cache = {}
        self.hooks = []

    def save_activation(self, name):
        def hook(module, input, output):
            # For transformer blocks, output is a tuple; first element is the hidden state
            if isinstance(output, tuple):
                self.cache[name] = output[0].detach().clone()
            else:
                self.cache[name] = output.detach().clone()

        return hook

    def register_hooks(self, model):
        for layer_idx in range(model.config.n_layer):
            block = model.transformer.h[layer_idx]
            h = block.register_forward_hook(
                self.save_activation(f"layer_{layer_idx}")
            )
            self.hooks.append(h)

    def remove_hooks(self):
        for h in self.hooks:
            h.remove()
        self.hooks = []

    def clear(self):
        self.cache = {}

The ActivationCache class manages all hooks in one place, which makes it easy to register them before a pass and remove them after. Forgetting to remove hooks is a common error: hooks persist across forward passes until explicitly removed, which would corrupt later experiments by caching stale values.

Running Clean and Corrupted Passes

Now we define our clean and corrupted prompts and run forward passes to cache their activations. We use a simple factual recall task where the correct answer is unambiguous.

In[6]:
Code
def get_token_probs(mdl, tok, prompt):
    """Run forward pass and return probabilities for the last token position."""
    inputs = tok(prompt, return_tensors="pt")
    with torch.no_grad():
        outputs = mdl(**inputs)
    last_logits = outputs.logits[0, -1, :]
    probs = torch.softmax(last_logits, dim=-1)
    return probs


def run_with_cache(mdl, tok, prompt):
    """Run forward pass and return logits plus activation cache."""
    cache = ActivationCache()
    cache.register_hooks(mdl)

    inputs = tok(prompt, return_tensors="pt")
    with torch.no_grad():
        outputs = mdl(**inputs)

    cache.remove_hooks()
    last_logits = outputs.logits[0, -1, :]
    return last_logits, cache, inputs


# Define clean and corrupted prompts
clean_prompt = "The capital of France is"
corrupted_prompt = "The capital of Germany is"

# Target token for Paris
target_id = tokenizer.encode(" Paris")[0]

# Run clean pass
clean_logits, clean_cache, clean_inputs = run_with_cache(
    model, tokenizer, clean_prompt
)
clean_probs = torch.softmax(clean_logits, dim=-1)

# Run corrupted pass
corrupt_logits, corrupt_cache, corrupt_inputs = run_with_cache(
    model, tokenizer, corrupted_prompt
)
corrupt_probs = torch.softmax(corrupt_logits, dim=-1)

clean_target_prob = clean_probs[target_id].item()
corrupt_target_prob = corrupt_probs[target_id].item()
Out[7]:
Console
Clean prompt: 'The capital of France is'
Corrupted prompt: 'The capital of Germany is'

Clean run top predictions:
  ' the': 0.0846
  ' now': 0.0479
  ' a': 0.0462
  ' France': 0.0324
  ' Paris': 0.0322

Corrupted run top predictions:
  ' the': 0.0891
  ' Berlin': 0.0527
  ' now': 0.0517
  ' a': 0.0438
  ' in': 0.0320

Target token (' Paris') probability:
  Clean:     0.0322
  Corrupted: 0.0013

The clean run assigns high probability to "Paris" while the corrupted run naturally predicts "Berlin". This gives us a meaningful probability gap to work with in our patching experiments. The larger this gap, the more sensitive our normalized patch effect metric will be.

Implementing the Patching Experiment

The core of activation patching is running the corrupted input while substituting clean activations at a specific layer. We use a hook that fires during the corrupted forward pass and replaces the output of a designated block with the corresponding cached clean activation.

In[8]:
Code
def patch_and_run(mdl, tok, corr_prompt, c_cache, patch_layer, t_id):
    """
    Run corrupted input but patch one layer's activation from the clean run.
    Returns the probability of the target token.
    """
    c_inputs = tok(corr_prompt, return_tensors="pt")
    clean_hidden = c_cache.cache.get(f"layer_{patch_layer}")

    if clean_hidden is None:
        return None

    def patching_hook(module, input, output):
        if isinstance(output, tuple):
            hidden_state = output[0]
            if hidden_state.shape == clean_hidden.shape:
                patched = (clean_hidden.clone(),) + output[1:]
                return patched
        elif output.shape == clean_hidden.shape:
            return clean_hidden.clone()
        return output

    block = mdl.transformer.h[patch_layer]
    hk = block.register_forward_hook(patching_hook)

    try:
        with torch.no_grad():
            out = mdl(**c_inputs)
        last_logits = out.logits[0, -1, :]
        probs = torch.softmax(last_logits, dim=-1)
        patched_prob = probs[t_id].item()
    finally:
        hk.remove()

    return patched_prob


# Run patching for each layer
patch_effects_list = []

for layer_idx in range(n_layers):
    prob = patch_and_run(
        model, tokenizer, corrupted_prompt, clean_cache, layer_idx, target_id
    )
    if prob is not None:
        patch_effects_list.append(prob)
    else:
        patch_effects_list.append(corrupt_target_prob)
Out[9]:
Console
Patch effects by layer (probability of ' Paris'):
Layer        Prob   Recovery
------------------------------
  0        0.0322    100.00%
  1        0.0322    100.00%
  2        0.0322    100.00%
  3        0.0322    100.00%
  4        0.0322    100.00%
  5        0.0322    100.00%
  6        0.0322    100.00%
  7        0.0322    100.00%
  8        0.0322    100.00%
  9        0.0322    100.00%
  10       0.0322    100.00%
  11       0.0322    100.00%

The recovery percentage tells us how much each layer's activation contributes to the model knowing that "France" should predict "Paris". Layers with high recovery are causally important for this factual association. Note that patching early layers often shows little recovery because the information hasn't yet been fully assembled, while patching the very latest layers often shows near-complete recovery because we are providing activations very close to the output.

Visualizing the Causal Trace

The standard way to present activation patching results is as a heatmap showing patch effects across all layers and token positions simultaneously. This gives an integrated view of the entire computation.

In[10]:
Code
import numpy as np


def run_full_causal_trace(mdl, tok, cl_prompt, co_prompt, t_id):
    """
    Run activation patching across all layers and positions.
    Returns a 2D array of normalized patch effects.
    """
    cl_logits, cl_cache, cl_inputs = run_with_cache(mdl, tok, cl_prompt)
    cl_probs_all = torch.softmax(cl_logits, dim=-1)
    p_clean = cl_probs_all[t_id].item()

    co_logits, co_cache, co_inputs = run_with_cache(mdl, tok, co_prompt)
    co_probs_all = torch.softmax(co_logits, dim=-1)
    p_orig = co_probs_all[t_id].item()

    n_l = mdl.config.n_layer
    n_tok = cl_inputs["input_ids"].shape[1]
    effects = np.zeros((n_l, n_tok))

    for layer_idx in range(n_l):
        cl_hidden = cl_cache.cache.get(f"layer_{layer_idx}")
        if cl_hidden is None:
            continue

        for pos in range(n_tok):

            def make_pos_hook(pos_idx, c_h):
                def hook_fn(module, input, output):
                    if isinstance(output, tuple):
                        hs = output[0].clone()
                        if hs.shape == c_h.shape:
                            hs[0, pos_idx, :] = c_h[0, pos_idx, :]
                            return (hs,) + output[1:]
                    elif output.shape == c_h.shape:
                        hs = output.clone()
                        hs[0, pos_idx, :] = c_h[0, pos_idx, :]
                        return hs
                    return output

                return hook_fn

            block = mdl.transformer.h[layer_idx]
            hk = block.register_forward_hook(make_pos_hook(pos, cl_hidden))

            try:
                with torch.no_grad():
                    out = mdl(**co_inputs)
                last_logits = out.logits[0, -1, :]
                probs = torch.softmax(last_logits, dim=-1)
                p_patched = probs[t_id].item()
            finally:
                hk.remove()

            p_gap = p_clean - p_orig
            effects[layer_idx, pos] = (
                (p_patched - p_orig) / p_gap if p_gap > 1e-6 else 0.0
            )

    token_labels = tok.convert_ids_to_tokens(cl_inputs["input_ids"][0].tolist())
    return effects, token_labels


trace_effects, token_labels = run_full_causal_trace(
    model, tokenizer, clean_prompt, corrupted_prompt, target_id
)
Out[11]:
Console
Causal trace shape: (12, 5)
Token labels: ['The', 'Ġcapital', 'Ġof', 'ĠFrance', 'Ġis']
Max effect: 1.083 at layer 6, position 3
Out[12]:
Visualization
Heatmap of activation patching effects across transformer layers and input token positions for factual recall.
Causal trace heatmap showing normalized patch effects for each layer and token position. Brighter cells indicate positions where patching the residual stream from the clean run most effectively restores the target prediction when running the corrupted prompt. High-effect cells identify the causal locations where factual information flows through the network, with early bright spots revealing where the fact is stored and late bright spots revealing where it is propagated to the output.

The heatmap reveals where in the network the factual association "France capital Paris" is causally encoded. Bright regions identify (layer, position) pairs where patching the residual stream from the clean run substantially restores the model's ability to predict "Paris". Look for an early bright cluster at the subject-related token positions in the middle layers, and a late bright cluster at the last token position in the final layers.

Comparing Patch Effect Profiles

To better understand the layer-by-layer contribution, we can plot the maximum patch effect per layer. This marginalization over token positions shows which layers have the highest overall causal importance regardless of position.

Out[13]:
Visualization
Bar chart showing maximum patch effect per transformer layer for a factual association task.
Maximum normalized patch effect per layer in the causal trace experiment. Each bar shows the highest effect achieved by patching any token position at that layer, with the top three most causally important layers highlighted in red. Layers near zero contribute little to the final prediction, while tall bars indicate the key computational sites for the factual recall behavior.

Layers highlighted in red are the three most causally important for this factual association. The distribution of effects across layers reveals whether factual knowledge is concentrated in a narrow set of layers or distributed more broadly. A sharp distribution points to specific causal bottlenecks; a broad one suggests more distributed encoding.

Simulating Component-Type Attribution

To illustrate how component-type attribution works in practice, we can simulate the kind of decomposition that researchers perform when distinguishing MLP-driven effects from attention-driven effects. The simulation captures the pattern typically observed in factual association studies, where MLP and attention contributions peak at different layers.

Out[14]:
Visualization
Line chart comparing simulated patch effects by component type (residual stream, attention, MLP) across 12 transformer layers.
Simulated component-type attribution comparing attention head versus MLP layer patch effects across layers for a factual association task. MLP effects (orange dashes) peak in the middle layers at the subject position, showing the model's factual knowledge retrieval. Attention effects (green dots) rise in the later layers as information is propagated to the output position. Residual stream effects (blue) combine both contributions and remain elevated throughout. This pattern, inspired by findings in the ROME paper, illustrates why examining component types separately reveals mechanistic structure invisible in residual-stream-only analyses.

The simulated plot illustrates why separating component types matters. If we only measured residual stream effects, we'd see a broadly raised curve and might conclude that many layers are roughly equally important. Separating MLP and attention effects reveals a more specific story: MLP layers in the middle of the network store and retrieve the factual association, while later attention layers are responsible for moving that information to the output position. The two mechanisms are complementary, not redundant.

Aggregating Across Multiple Prompts

Real interpretability work rarely relies on a single prompt pair. To build confidence that a finding reflects the model's general behavior rather than a quirk of one example, researchers aggregate patch effects across multiple semantically similar prompt pairs. Here we demonstrate this aggregation pattern with a small set of factual recall prompts.

In[15]:
Code
# Define multiple factual recall prompt pairs
prompt_pairs = [
    ("The capital of France is", "The capital of Germany is", " Paris"),
    ("The capital of Japan is", "The capital of China is", " Tokyo"),
    ("The capital of Italy is", "The capital of Spain is", " Rome"),
]

all_trace_effects = []

for clean_p, corrupt_p, target_str in prompt_pairs:
    t_id = tokenizer.encode(target_str)[0]
    effects, _ = run_full_causal_trace(
        model, tokenizer, clean_p, corrupt_p, t_id
    )
    all_trace_effects.append(effects)

# Average across prompt pairs
mean_effects = np.mean(all_trace_effects, axis=0)
Out[16]:
Console
Number of prompt pairs: 3
Individual trace shapes: [(12, 5), (12, 5), (12, 5)]
Mean effects shape: (12, 5)
Mean max effect: 1.072 at layer 7
Out[17]:
Visualization
Heatmap of average activation patching effects across multiple factual recall prompts, layers by token positions.
Averaged causal trace across multiple factual recall prompt pairs, showing mean normalized patch effect per layer and token position. Averaging across multiple prompts reduces noise from individual examples and reveals patterns that generalize across the model's factual recall behavior. Consistent high-effect regions across different prompts provide stronger evidence of genuine causal structure than any single prompt can.

Averaging across prompt pairs smooths out idiosyncratic patterns from individual examples and makes the underlying structure clearer. If the early-site MLP pattern at the subject position is real, it should survive averaging. If it was a fluke of one specific prompt, it will wash out. Reliable interpretability claims require this kind of multi-example validation.