Part 6 · Causal mech interp · module 2 of 4 · 16 min
Editing hidden states
Intervening on the residual stream — scaling early layers, injecting noise, correcting bias, activation patching and skipping layers entirely.
The essence
The residual stream is the model's shared workspace, so editing it is the most general intervention available. Scaling it tests how much downstream computation depends on magnitude; replacing it at one position with the value from a different prompt localises where a specific piece of information is carried; skipping a layer tests whether that layer was needed at all.
Why it matters
Activation patching is the field's most-used causal method, and this is where it is introduced properly. It is also where you learn that these models are far more robust to internal perturbation than intuition suggests — many layers can be skipped or scaled substantially with little effect, which is itself an important finding about how the computation is organised.
Topics covered (6)
- 01Downstream impact of scaling an early layer
- 02Hidden-state scaling and its effect on target-token loss
- 03Noisy and shuffled predictions in BERT
- 04Measuring and correcting bias in BERT
- 05Activation patching with indirect object identification
- 06Skipping a layer entirely
Scaling the residual stream
Multiplying a layer's hidden state by a factor is the simplest possible perturbation and it answers a real question: is the downstream computation sensitive to magnitude, or only to direction?
The finding is usually that models are surprisingly tolerant. Scaling an early layer by 0.5 or 2.0 often changes the output modestly, because layernorm at the start of every subsequent block re-normalises the scale away. Where scaling does matter, it matters at specific depths — and locating those depths tells you where magnitude carries information rather than just direction.
The right measurement is loss on a specific target token, swept across both layer and scale factor. That gives you a two-dimensional surface, and its structure is more informative than any single number.
import torch, torch.nn.functional as F, numpy as np
@torch.no_grad()
def scale_sweep(model, prompt, target_id, factors=(0.25, 0.5, 0.8, 1.0, 1.25, 2.0, 4.0)):
results = np.zeros((model.config.n_layer, len(factors)))
for L in range(model.config.n_layer):
for j, f in enumerate(factors):
with intervene(model.transformer.h[L], scale(f)):
logits = model(**prompt).logits[0, -1]
# Negative log-probability of the target: lower is better.
results[L, j] = -F.log_softmax(logits, -1)[target_id].item()
return results, factors
surface, factors = scale_sweep(model, prompt, paris_id)
baseline_col = factors.index(1.0)
delta = surface - surface[:, [baseline_col]] # change relative to no edit
for L, row in enumerate(delta):
worst = np.abs(row).max()
print(f"layer {L:2} max |Δloss| {worst:5.2f} "
f"{'SENSITIVE' if worst > 0.5 else ''}")
# Note that h[L] returns a tuple, so `scale` must edit output[0] — the
# intervene() helper above already handles that.Noise and shuffling as controls
Adding Gaussian noise scaled to the activation's own standard deviation gives a graded degradation, which is useful for asking how much precision the representation needs. Sweeping the noise level produces a robustness curve, and the level at which performance collapses is a meaningful quantity.
Shuffling is a sharper control. Permute the hidden states across positions and you destroy positional binding while preserving the exact set of values — so any degradation is attributable to which position held what, not to the values themselves. Permuting across the feature dimension instead destroys the directional structure while preserving the norm. Comparing the two isolates what the representation's structure is doing.
BERT is a good testbed because masked prediction gives a clean, unambiguous target. Noise the hidden states at layer L and measure how often the masked token is still recovered.
import torch
def add_noise(sigma_ratio=0.5, seed=0):
"""Noise scaled to the activation's own std, so it is comparable across layers."""
def fn(x):
g = torch.Generator(device=x.device).manual_seed(seed)
noise = torch.randn(x.shape, generator=g, device=x.device, dtype=x.dtype)
return x + sigma_ratio * x.std() * noise
return fn
def shuffle_positions(seed=0):
"""Same values, different positions: tests positional binding only."""
def fn(x):
g = torch.Generator(device=x.device).manual_seed(seed)
perm = torch.randperm(x.size(1), generator=g, device=x.device)
return x[:, perm]
return fn
def shuffle_features(seed=0):
"""Same norm, scrambled direction: tests directional structure only."""
def fn(x):
g = torch.Generator(device=x.device).manual_seed(seed)
perm = torch.randperm(x.size(-1), generator=g, device=x.device)
return x[..., perm]
return fn
# Robustness curve on BERT's masked prediction:
for sigma in [0.0, 0.1, 0.25, 0.5, 1.0, 2.0]:
with intervene(bert.encoder.layer[6], add_noise(sigma)):
acc = masked_accuracy(bert, tok, sentences)
print(f"sigma {sigma:4.2f} accuracy {acc:.3f}")
# The level at which accuracy collapses is the model's precision requirement
# at that depth — and it is usually much higher than people expect.Measuring and correcting a bias
Bias measurement and bias correction are the same experiment run in two directions. First find the direction: encode matched sentence pairs differing only in the attribute of interest, and take the mean difference of their hidden states. That vector is the attribute's linear encoding at that layer, if it has one.
Then subtract its component from the hidden state during a forward pass and re-measure the biased behaviour. If the bias drops, the attribute was linearly encoded and you have identified where. If it does not, either the encoding is non-linear or you found the wrong direction.
The essential control is a matched neutral test that should not change. A direction that reduces the bias and also degrades unrelated predictions is not a bias direction — it is a direction that carries something the model needs generally.
import torch, numpy as np
@torch.no_grad()
def bias_direction(model, tok, pairs, layer):
"""pairs: [(sentence_a, sentence_b), ...] differing only in the attribute."""
diffs = []
for a, b in pairs:
ha = hidden_at(model, tok, a, layer) # [dim], pooled or [CLS]
hb = hidden_at(model, tok, b, layer)
diffs.append(ha - hb)
d = torch.stack(diffs).mean(0)
return d / d.norm()
def project_out(direction):
"""Remove the component along `direction` from every position."""
d = direction / direction.norm()
def fn(x):
# x: [B, T, dim]; subtract the projection onto d.
return x - (x @ d).unsqueeze(-1) * d
return fn
d = bias_direction(bert, tok, gender_pairs, layer=8)
before = bias_score(bert, tok, probe_sentences)
with intervene(bert.encoder.layer[8], project_out(d)):
after = bias_score(bert, tok, probe_sentences)
# The control that makes it a finding rather than damage:
control_after = general_accuracy(bert, tok, neutral_sentences)
print(f"bias {before:.3f} -> {after:.3f} "
f"unrelated accuracy {control_after:.3f}")
# Repeat per layer: where in depth is the attribute linearly available, and
# where does removing it actually change behaviour? Those two depths need not
# be the same, and the gap between them is interesting.Activation patching
Patching is the sharpest localisation tool available. Construct two prompts differing in exactly one respect — a clean prompt and a corrupted one. Run the corrupted prompt, but copy in the clean prompt's activation at one specific layer and position. If the output flips toward the clean answer, that location carried the information.
Sweeping over every (layer, position) pair produces a two-dimensional map showing where the distinguishing information lives and how it moves through the network. That map is the standard figure in circuit-analysis papers, and it is genuinely legible: you can watch information start at the token that differs and get moved toward the final position by specific layers.
Indirect object identification is the canonical test case. 'When John and Mary went to the shop, John gave a drink to ___' requires tracking which name was repeated and suppressing it. Patching localises where that computation happens, and the resulting circuit was among the first fully characterised in a real model.
import torch, numpy as np
@torch.no_grad()
def capture_all_layers(model, prompt):
out = model(**prompt, output_hidden_states=True)
return [h.clone() for h in out.hidden_states]
@torch.no_grad()
def patching_map(model, clean, corrupted, answer_id, wrong_id):
"""For every (layer, position): patch clean -> corrupted and measure recovery."""
clean_acts = capture_all_layers(model, clean)
def logit_diff(logits):
# The correct metric: difference between the two candidate answers.
# It isolates the decision and cancels prompt-level shifts.
return (logits[0, -1, answer_id] - logits[0, -1, wrong_id]).item()
base_clean = logit_diff(model(**clean).logits)
base_corr = logit_diff(model(**corrupted).logits)
n_layers = model.config.n_layer
n_pos = corrupted["input_ids"].size(1)
recovery = np.zeros((n_layers, n_pos))
for L in range(n_layers):
for p in range(n_pos):
donor = clean_acts[L + 1] # +1: index 0 is the embeddings
with intervene(model.transformer.h[L], patch_from(donor, position=p)):
got = logit_diff(model(**corrupted).logits)
# 0 = no recovery, 1 = fully restored to the clean behaviour.
recovery[L, p] = (got - base_corr) / (base_clean - base_corr + 1e-9)
return recovery
# Indirect object identification.
clean = tok("When John and Mary went to the shop, John gave a drink to",
return_tensors="pt")
corrupted = tok("When John and Mary went to the shop, Mary gave a drink to",
return_tensors="pt")
heat = patching_map(model, clean, corrupted,
answer_id=tok(" Mary")["input_ids"][0],
wrong_id=tok(" John")["input_ids"][0])
# Read the map: high values early at the differing token, then moving to the
# final position in middle layers. That trace IS the information flow.Note: Use the logit difference between the two candidate answers, not the absolute logit. The difference cancels prompt-level effects and is the reason patching results are as clean as they are.
Skipping a layer
The most blunt intervention: make a block the identity function, so its output is exactly its input and nothing is added to the residual stream. This asks whether the layer contributed anything at all for this input.
The result is one of the more striking findings in the area. Many middle layers of a large transformer can be skipped individually with modest degradation, and some with almost none. Early layers and the last few are far less dispensable. This suggests the middle of the network performs incremental refinement with substantial redundancy rather than a strict sequential pipeline — which is consistent with the residual stream's additive design and is the basis of practical layer-pruning methods.
import torch, torch.nn.functional as F
def identity(x):
"""Return the input unchanged — the block adds nothing to the stream."""
return x
@torch.no_grad()
def skip_profile(model, prompt, target_id):
base = -F.log_softmax(model(**prompt).logits[0, -1], -1)[target_id].item()
rows = []
for L in range(model.config.n_layer):
# Skipping means the block's contribution is zero, i.e. output = input.
with intervene(model.transformer.h[L], lambda x: x * 0.0 + x):
loss = -F.log_softmax(model(**prompt).logits[0, -1], -1)[target_id].item()
rows.append((L, loss - base))
return base, rows
base, rows = skip_profile(model, prompt, paris_id)
for L, delta in rows:
bar = "#" * int(max(delta, 0) * 10)
print(f"layer {L:2} Δloss {delta:+6.3f} {bar}")
# Cumulative skipping is the harsher test: remove the least-important layers
# one at a time and find where performance finally falls off a cliff. That
# curve is the empirical basis for depth pruning.Note: To skip properly you must zero the block's contribution, not zero its output — those differ, because the block's return value already includes the residual it was given.
Worth remembering
- Activation patching needs a matched pair of prompts differing in exactly one respect.
- Transformers tolerate substantial residual-stream perturbation — many middle layers can be skipped.
- A bias direction found by contrasting conditions can be subtracted, which tests whether the bias was linearly encoded.