Part 6 · Causal mech interp · module 4 of 4 · 13 min
Modifying the MLP
Interventions on per-position computation — median replacement, statistically targeted lesions and removing whole subspaces.
The essence
The MLP does per-position computation and holds roughly two thirds of a transformer's parameters, so it is where most learned content lives. Intervening on it means either replacing individual neurons with a neutral value or removing an entire direction from the layer's output — and the second is usually the more meaningful operation, because superposition means the thing you want to remove is a direction rather than a unit.
Why it matters
MLP interventions are how you test claims about stored knowledge and about features found observationally. They are also the clearest demonstration of why subspace removal beats neuron ablation: a feature spread across a hundred neurons cannot be removed by zeroing any of them, but can be removed by projecting out its direction.
Topics covered (4)
- 01Successive median-replacement of MLP neurons
- 02Statistics-based lesioning of MLP neurons
- 03Laminar profiles of statistically targeted lesions
- 04Explorations in subspace removal
Median replacement
To neutralise a neuron without pushing the model off-distribution, replace its activation with a typical value rather than zero. The median over a corpus is the natural choice — robust to the heavy right tails that post-GELU activations usually have, and guaranteed to be a value the downstream layers have actually seen.
Doing this successively — replace one neuron, measure, replace two, measure — produces a degradation curve. Its shape is the finding. A sharp initial drop means a few neurons carry the behaviour; a slow linear decline means it is distributed across many, and no small set is privileged.
The ordering matters and should be stated. Replacing in order of statistical selectivity tests whether selective neurons are causally used. Replacing in random order gives the baseline curve you compare that against.
import torch, numpy as np, torch.nn.functional as F
@torch.no_grad()
def neuron_medians(model, tok, corpus, layer):
"""Per-neuron median activation over a corpus — the neutral value."""
collected = []
box = {}
h = model.transformer.h[layer].mlp.register_forward_hook(
lambda m, i, o: box.__setitem__("a", o.detach()))
for text in corpus:
model(**tok(text, return_tensors="pt", truncation=True, max_length=128))
collected.append(box["a"][0])
h.remove()
return torch.cat(collected, dim=0).median(dim=0).values # [d_mlp]
def replace_neurons(indices, medians):
idx = torch.as_tensor(indices, dtype=torch.long)
def fn(x):
x = x.clone()
x[..., idx] = medians[idx]
return x
return fn
@torch.no_grad()
def degradation_curve(model, prompt, target_id, layer, order, medians, steps=None):
steps = steps or [0, 1, 2, 5, 10, 20, 50, 100, 200, 500]
rows = []
for n in steps:
if n == 0:
loss = -F.log_softmax(model(**prompt).logits[0, -1], -1)[target_id].item()
else:
with intervene(model.transformer.h[layer].mlp,
replace_neurons(order[:n], medians)):
loss = -F.log_softmax(model(**prompt).logits[0, -1], -1)[target_id].item()
rows.append((n, loss))
return rows
med = neuron_medians(model, tok, corpus, layer=8)
# The comparison that means something: selective ordering vs. random ordering.
selective = degradation_curve(model, prompt, target_id, 8, tuned_order, med)
rng = np.random.default_rng(0)
random_order = rng.permutation(med.numel())
baseline = degradation_curve(model, prompt, target_id, 8, random_order, med)
for (n, a), (_, b) in zip(selective, baseline):
print(f"n={n:4} selective {a:6.3f} random {b:6.3f} gap {a - b:+.3f}")
# A gap means selectivity predicts causal importance. No gap means the tuned
# neurons were not privileged — a genuinely useful negative result.Statistically targeted lesions
This closes the loop between the two halves of this curriculum. In the observational part you found neurons whose activation differs between conditions, corrected for multiple comparisons. Now you lesion exactly those neurons and measure whether the corresponding behaviour degrades.
This is the experiment that tests whether observational tuning means anything. Selective neurons frequently turn out not to be causally necessary — because the information is redundantly represented, or because selectivity was a side effect of something else the neuron does. Both outcomes are informative, and the negative one is more common than the literature's publication pattern suggests.
Running it per layer gives a laminar profile of causal importance, which you can compare directly against the laminar profile of selectivity. Where those two curves diverge is the interesting part.
import numpy as np
from scipy import stats
from statsmodels.stats.multitest import multipletests
def select_by_ttest(acts_a, acts_b, alpha=0.05, min_d=0.5):
t, p = stats.ttest_ind(acts_a, acts_b, axis=0, equal_var=False)
pooled = np.sqrt((acts_a.var(0, ddof=1) + acts_b.var(0, ddof=1)) / 2)
d = (acts_a.mean(0) - acts_b.mean(0)) / np.maximum(pooled, 1e-9)
reject, _, *_ = multipletests(p, alpha=alpha, method="fdr_bh")
hits = np.flatnonzero(reject & (np.abs(d) > min_d))
return hits[np.argsort(-np.abs(d[hits]))] # strongest first
def laminar_lesion_profile(model, tok, layers, stimuli, behaviour_metric):
rows = []
for L in layers:
a = collect_activations(model, tok, stimuli.condition_a, L)
b = collect_activations(model, tok, stimuli.condition_b, L)
selected = select_by_ttest(a, b)
med = neuron_medians(model, tok, corpus, L)
before = behaviour_metric(model)
with intervene(model.transformer.h[L].mlp, replace_neurons(selected, med)):
after = behaviour_metric(model)
# Matched control: the same NUMBER of random neurons. Without this,
# any lesion of 200 units looks impressive.
rng = np.random.default_rng(L)
control_idx = rng.choice(med.numel(), len(selected), replace=False)
with intervene(model.transformer.h[L].mlp, replace_neurons(control_idx, med)):
control = behaviour_metric(model)
rows.append({"layer": L, "n_selected": len(selected),
"effect": before - after,
"control_effect": before - control})
return rows
for r in laminar_lesion_profile(model, tok, range(12), stimuli, metric):
print(f"layer {r['layer']:2} n={r['n_selected']:4} "
f"effect {r['effect']:+.3f} control {r['control_effect']:+.3f}")Subspace removal
Because features are directions rather than neurons, the intervention that matches the representation is removing a direction. Project the MLP's output onto the orthogonal complement of the direction and that component is gone, regardless of how many neurons contributed to it.
This is how you causally test a feature found by any of the observational methods: a GED direction, an SAE feature's decoder column, a probe's weight vector, a bias direction. Remove it and measure the specific behaviour it should govern, alongside a control behaviour it should not.
Removing a subspace rather than a single direction generalises this — orthonormalise a set of directions and project out the whole span. That is the right operation when a concept plausibly needs several dimensions, and it is also where the specificity control matters most, because removing a large subspace damages a lot.
import torch, numpy as np
def remove_directions(directions):
"""directions: [k, dim]. Projects the activation onto the orthogonal
complement of their span, removing a distributed feature that no single
neuron ablation could touch."""
D = torch.as_tensor(np.atleast_2d(directions), dtype=torch.float32)
# Orthonormalise: the directions need not be orthogonal to begin with.
Q, _ = torch.linalg.qr(D.T) # [dim, k]
P = Q @ Q.T # projection onto the span
def fn(x):
return x - x @ P.T # keep the complement
return fn
# Test a feature found observationally, with the specificity control.
for name, direction in candidate_features.items():
with intervene(model.transformer.h[8].mlp, remove_directions(direction)):
target = target_behaviour(model)
control = control_behaviour(model)
print(f"{name:24} target {target:.3f} control {control:.3f}")
# A real feature: target drops, control barely moves. If both drop, you removed
# something the model needs generally.
# Sweep the dimensionality: how many directions must go before the behaviour
# breaks? A sharp threshold means the feature is genuinely low-dimensional.
for k in [1, 2, 4, 8, 16, 32]:
with intervene(model.transformer.h[8].mlp, remove_directions(top_directions[:k])):
print(k, round(target_behaviour(model), 3), round(control_behaviour(model), 3))Worth remembering
- Median replacement is a better neutral value than zero: it stays inside the activation's observed range.
- Lesioning in order of statistical selectivity, then measuring, is the way to test whether tuned neurons are causally used.
- Projecting out a direction removes a distributed feature that no single-neuron ablation could touch.