Phase C · mechanistic interpretability

Activation patching / causal mediation

Vig et al. 2020; ROME, Meng et al. 2022

← back to the method catalogue

What it does?

Activation patching, also called causal mediation, is a core mechanistic-interpretability method. It runs the system once, records an internal value, and then, on a second run, overwrites, or 'patches', that internal value with the one recorded from a different input. The change in the output measures how much that internal value causally mediates the behaviour. In language-model work this is how researchers localise where a fact or a computation lives inside the network. The idea is genuinely causal: it does not correlate, it substitutes a value and observes the effect. We apply it to the VCS by patching a recorded state value, a RAM cell or register, from one run into another and measuring the causal effect on the output. On this machine the operation is special. Clamping a cell to a donor value and re-running the real ROM is literally the same operation the intervention oracle performs. So activation patching cannot disagree with the oracle: it recovers the exact causal-effect table by construction. This makes it the clearest demonstration that where a method's operation is itself a real intervention on a fully observable machine, faithfulness is automatic. The catch, taken up elsewhere, is that recovering the exact effect table is still not the same as recovering what the patched variables mean.

Activation patching / causal mediation result

Each point is one intervention site: its exact causal effect from the oracle (x) against the method's recovered/approximate effect (y). Points on the dashed diagonal mean the method recovered the true effect. max_abs_recovered_minus_exact = 0.000 — this example only (pong, state gameplay(seed=0,prefix=90)+15); the audit aggregate is below.

Mechanistic family · the core causal tool

Learn the method — a beginner's guide

New to explainable AI? This block explains the method as it is used on neural networks, then how we run the very same method on an Atari 2600, where — unlike for a network — we know the right answer. Every implementation step links to the code. See also the XAI primer for the shared vocabulary.

The idea in plain words

Gradient methods ask “what if I nudged this a tiny bit?”. Activation patching asks a bolder, causal question: what if this internal value had been the one from a different input? You run the network twice. The clean run uses the input you care about (“The Eiffel Tower is in” → “Paris”). The corrupted run uses a slightly different input that gives a different answer. Then you run a third time, and at one chosen place inside the network you overwrite (“patch”) the internal value with the one recorded in the other run. If the answer flips, that place carries the information that decides the answer.

An internal value of a network is called an activation: the output of one neuron, one layer or one attention head. Statisticians call a variable that sits on the causal path from cause to effect a mediator, so the method is also called causal mediation analysis. It is the workhorse of mechanistic interpretability, the attempt to reverse-engineer the algorithm a network runs. Unlike a correlation, a patch is an intervention: it sets a value and watches what happens. On the VCS we can do exactly the same thing to the machine's memory, and it turns out to be identical to our ground-truth oracle.

How it works on a neural network

Let \(x\) be the clean input and \(\tilde x\) the corrupted one. Let \(a_\ell(x)\) be the activation of component \(\ell\) (a neuron, an attention head, or a position in the residual stream: the running vector that every transformer layer reads from and adds to). Write \(y(x \mid a_\ell := v)\) for the output when we run on \(x\) but force \(a_\ell\) to the value \(v\). The patch effect of component \(\ell\) is

\[ \Delta_\ell \;=\; y\big(x \,\big|\, a_\ell := a_\ell(\tilde x)\big) \;-\; y(x). \]

This direction (corrupted value into the clean run) is called noising; the reverse (clean value into the corrupted run) is denoising. In ROME’s “causal tracing” the corruption is Gaussian noise on the subject tokens, and the patched-back clean state shows where a fact is recalled. Each \(\Delta_\ell\) costs one extra forward pass.

Toy example. Take a two-step network \(h = 2x_1\), \(g = x_2 + 1\), \(y = h + 3g\). Clean input \(x = (1, 0)\) gives \(h = 2\), \(g = 1\), \(y = 5\). Corrupted input \(\tilde x = (4, 0)\) gives \(h = 8\), \(g = 1\), \(y = 11\). Patch \(h\): run on \(x\) but set \(h := 8\). Then \(y = 8 + 3 = 11\), so \(\Delta_h = 6\): \(h\) mediates the whole difference. Patch \(g\): its corrupted value is also 1, so \(\Delta_g = 0\). The patch tells us which component carries the change between these two inputs, not which components matter in general. A different \(\tilde x\) (say, changing \(x_2\)) would light up \(g\) instead.

Recipe

  1. Choose a clean input and a corrupted input that differ in the one thing you study, and a scalar metric (a logit difference, a probability).
  2. Run both and cache every activation you might patch.
  3. For each component \(\ell\): re-run the clean input, overwrite \(a_\ell\) with its cached corrupted value, let the rest of the network compute normally.
  4. Record \(\Delta_\ell\), the change in the metric, often normalised by the full clean-vs-corrupted gap.
  5. Plot \(\Delta_\ell\) over layers and positions: the bright cells are the candidate mediators.

From a neural network to the Atari

The network and its forward passThe ROM program running on the bit-exact emulated VCS (jutari). One “layer” is one video frame of 6507 CPU work.
The residual stream at layer \(\ell\)The whole machine state at a frame boundary: the 128 bytes of RAM plus TIA video registers. Every frame reads it and writes it back.
A component (neuron / head)One state site: a single RAM byte that the game uses as a variable (a candidate concept cell such as a ball coordinate), or one TIA register (the background colour COLUBK).
Clean run / corrupted runClean: the seeded random-action gameplay stream, checkpointed at frame \(f^*{=}90\). Donor: the same stream but with LEFT or RIGHT pressed at the last frame before \(f^*\).
The logit / metricOutputs read 15 frames later: the values of the two most causally active candidate bytes, and the number of screen pixels that differ from the clean frame.
Ground truthThe intervention oracle, which pokes a value and re-runs the real ROM. On the VCS that is a patch, so activation patching and the truth coincide.

How we apply it to the VCS

The runner sits on the shared testbed that every method uses: a seeded stream of random joystick actions, a checkpoint at frame \(f^*\), and a horizon of 15 frames. Two fresh boots are first asserted to be byte-identical in RAM and screen, so any later difference is caused by our patch and nothing else.

  1. Outputs. The runner picks the two candidate RAM bytes whose own value moves most under the oracle’s interventions, and adds a whole-screen output: the count of pixels that differ from the clean final frame.
  2. Donor patches. Two donor runs share the gameplay prefix and press LEFT or RIGHT just before \(f^*\). For every candidate byte the donor’s value is written into a copy of the clean checkpoint. The record notes whether the donor actually differed there; equal values are honest no-op patches.
  3. Directed patches. Because a one-frame donor leaves many bytes unchanged, every candidate byte is also patched to base+17 (the same value the oracle uses), plus one TIA patch that sets the background colour to white.
  4. Recovered vs exact. Each patch is re-run on the clean checkpoint (the method). The same write is then replayed from an independent fresh boot (the oracle). The two effect tables are compared cell by cell. They are equal, because both are the same single-site write followed by the same deterministic program.
  5. Edge precision/recall. A (patch, output) pair “fires” if its effect is non-zero. The recovered firing pattern is scored against the exact one. An honest extra audit lists transient sites: bytes the program overwrites on the next frame, so their patch never reaches the outputs.

The difference from LLM practice is worth stating plainly. In a transformer, patching and “the truth” are different things, and we argue about which metric to trust. On the VCS, patching a byte and re-running is the oracle’s own definition of a cause. So this method is the reference the other Phase C methods approximate.

Read the code

XAI the interpretability method   substrate the bit-exact emulator it runs on (jutari, Julia).

Reading the result

The headline number is the largest gap between the recovered and the exact effect table. Expect it to be zero on every game: this is a positive result by construction, not a discovery. The more informative parts are the firing pattern and the transient list. Many bytes show no effect because the game recomputes them every frame. Others move a score byte but not the screen, or the reverse. Read the page as a map of which variables mediate which outputs over 15 frames, not as a test the method could fail.

Pitfalls

Try it yourself

julia --project=jutari tools/xai_study/phaseC_mechanistic/activation_patching.jl --games core

Writes activation_patching_<game>.json/.npz and a core summary into tools/xai_study/phaseC_mechanistic/out/. Each patch is replayed twice (clean checkpoint and fresh boot), so expect minutes per game on one CPU core.

Original papers & further reading

Read next: Attribution / edge patching · Path patching / IOI circuit · Interchange interventions / DAS · Causal scrubbing

How it's scored

The score is the maximum absolute difference between the recovered patch effect and the exact patch effect from the oracle, taken across every site and every core game. The grading is always against the intervention oracle. This method is exact by construction, so the difference is zero. Clamping a cell to a donor value and re-running the real ROM is literally the same operation the intervention oracle performs, so the recovered effect table cannot disagree with the oracle, and site precision and recall are both perfect. Faithfulness for this method is therefore at the ceiling, level with the oracle-as-method positive control. There is no sprite-position hard-case here: because the method intervenes on the real machine rather than taking a derivative, it works on position outputs exactly as it works on content outputs. The important qualification, which the score does not by itself capture, is that recovering the exact effect table is not the same as recovering what the patched variables mean or how they combine into the computed function. The audit box reports the measured triad. This method is the clearest demonstration that when a method's operation is itself a real intervention on a fully observable machine, faithfulness is automatic.

The score is measured against the §1 intervention oracle — never against another interpretability method. F (faithful) is always vs the oracle; see the execution stack. How each of F / S / M is actually computed for this method (and whether it matches the paper) is in the box just below; the numbers are in the In the audit box under it.

How F, S, M are computed here

The exact formula this method uses for each score, read from its runner, and whether it matches the paper's §3 (F ∧ S ∧ M triad) definition. From the committed audit fsm_math_phaseC.json.

AxisFormula (as computed) What it measuresMatches §3?
F
faithfulness
\(F = 1 - \max_u|\,\hat{\Delta y}(u)-\Delta y(u)\,| = 1\)The patch literally re-runs the exact single-site intervention, so recovered effect equals the oracle effect and the causal-effect agreement is 1.✓ matches activation_patching.jl main record (metric max_abs_recovered_minus_exact=0 -> F=1.0); jl:693 position record F = _pearson(rec_px, ex_px). Both are the paper's Phase-C patching F = causal-effect-agreement correlation vs Delta_y(u); here exact by construction. Record: extra.triad.F=1.0 (battery mean F=1.0, n=84).
how it's measured — call stack:
  1. main record: max_abs_recovered_minus_exact = 0 ⇒ F = 1 — activation_patching.jl:582
  2. position record: F = Pearson(recovered_px, exact_px) — activation_patching.jl:693
  3. true causal effect Δy(u) by bit-exact re-run — oracle_intervene.jl:242
S
sufficiency
\(S = \dfrac{\#\{\,u_{\mathrm{held}}:\ |\hat{y}-y|\le\varepsilon\,\}}{|\mathrm{held\text{-}out}|}\in[0,1]\)Held-out predictive sufficiency: fit the attribution->Δy map on a calibration half of the causes, predict the disjoint held-out half, count hits within an ε band.✓ matches common/triad_sm.jl sufficiency_score, called via triad_extra_dict (activation_patching.jl). Exactly the paper's held-out predictive estimator on the oracle's per-cause bit-exact re-runs. Record: extra.triad.S (activation_patching_pong.json S=1.0; battery mean S=0.996, n=84).
how it's measured — call stack:
  1. sufficiency_score: fit Δy=a·attr+b on a calib half, predict held-out within ε — triad_sm.jl:111
  2. assemble the triad via triad_extra_dict — activation_patching.jl:597
  3. true causal effect Δy(u) by bit-exact re-run — oracle_intervene.jl:242
M
minimality
\(M = |U^{\star}| / |\hat{U}| \in (0,1]\quad U^{\star}=\{u:\Delta y(u)>0\},\ \hat{U}=\{u:|\mathrm{attr}(u)|>\tau\}\)True minimal cause set size over the method-named set size.✓ matches common/triad_sm.jl minimality_score. Exactly the paper's M. Record: extra.triad.M=1.0, M_true_minimal_size / M_named_size (pong |U*|=6/|U_hat|=6; battery mean M=0.973, n=84).
how it's measured — call stack:
  1. minimality_score: |U*| oracle movers / |U_hat| named cells — triad_sm.jl:57
  2. assemble the triad via triad_extra_dict — activation_patching.jl:597
  3. true causal effect Δy(u) by bit-exact re-run — oracle_intervene.jl:242

✓ matches = the same quantity as §3; ◐ partial = the same kind of estimator but a differing detail; ✗ does not match = a different quantity (see the note). Definitions: F = agreement with the oracle's true causal effects Δy(u); S = held-out predictive score in [−1, 1]; M = |U*|/|Û| (true-minimal-set size / named-set size).

In the audit

This is the method's entry in the actual cross-method audit — scored on the paper's correctness triad, each axis a mean over 44 of the 44 scored games (88 committed §R records), not the single example shown above. Tradition: causal. The example figure (Pong) is one of those records.

1.000F — faithfulness vs oracle (mean over 44 games, ±0.000 CI95)
0.996S — sufficiency: held-out predictive (n/a where the paper does not define this axis)
0.964M — minimality: true-minimal-set / named-set (n/a otherwise)
88committed records aggregated
0.00human-plausibility proxy

F faithfulness (scored vs the oracle for every method) · S sufficiency (held-out predictive score in [−1, 1]; a negative value means the explanation predicts held-out interventions worse than the unperturbed baseline; reported for the predictive methods across all three phases where the calibration/held-out split is defined) · M minimality (true-minimal-set / named-set; where the method names a cause set) — n/a otherwise, per the paper's F ∧ S ∧ M triad.

Source: leaderboard.json · the whole leaderboard is on the methods page and the Paper 2 audit.

Results per game

This method's faithfulness on each of the 44 scored games (all-regime F, and the content vs position split). Click a header to sort. Every number is read from site_data.json (methods.activation_patching.per_game).

GameF (all)content-Fposition-FSMNote
Air Raid1.000n/a1.0001.0000.824recovers the true causes
Alien1.000n/a1.0000.8331.000recovers the true causes
Assault1.000n/a1.0001.0001.000recovers the true causes
Asterix1.000n/a1.0001.0000.929recovers the true causes
Atlantis1.000n/a1.0001.0000.944recovers the true causes
Bank Heist1.000n/a1.0001.0001.000recovers the true causes
Berzerk1.000n/a1.0001.0000.944recovers the true causes
Bowling1.000n/a1.0001.0000.955recovers the true causes
Boxing1.000n/a1.0001.0001.000recovers the true causes
Breakout1.000n/a1.0001.0001.000recovers the true causes
Carnival1.000n/a1.0001.0000.975recovers the true causes
Centipede1.000n/a1.0001.0000.958recovers the true causes
Chopper Command1.000n/a1.0001.0000.964recovers the true causes
Demon Attack1.000n/a1.0001.0000.929recovers the true causes
Double Dunk1.000n/a1.0001.0000.967recovers the true causes
Enduro1.000n/a1.0001.0000.900recovers the true causes
Fishing Derby1.000n/a1.0001.0001.000recovers the true causes
Freeway1.000n/a1.0001.0001.000recovers the true causes
Frostbite1.000n/a1.0001.0000.900recovers the true causes
Gopher1.000n/a1.0001.0001.000recovers the true causes
Hero1.000n/a1.0001.0000.955recovers the true causes
Ice Hockey1.000n/a1.0001.0001.000recovers the true causes
Jamesbond1.000n/a1.0001.0000.909recovers the true causes
Kangaroo1.000n/a1.0001.0000.909recovers the true causes
Krull1.000n/a1.0001.0000.923recovers the true causes
Kung-Fu Master1.000n/a1.0001.0001.000recovers the true causes
Montezuma's Revenge1.000n/a1.0001.0000.938recovers the true causes
Ms. Pac-Man1.000n/a1.0001.0001.000recovers the true causes
Name This Game1.000n/a1.0001.0001.000recovers the true causes
Pacman1.000n/a1.0001.0001.000recovers the true causes
Phoenix1.000n/a1.0001.0001.000recovers the true causes
Pitfall1.000n/a1.0001.0001.000recovers the true causes
Pong1.000n/a1.0001.0001.000recovers the true causes
Private Eye1.000n/a1.0001.0001.000recovers the true causes
Q*bert1.000n/a1.0001.0000.944recovers the true causes
Riverraid1.000n/a1.0001.0000.940recovers the true causes
Road Runner1.000n/a1.0001.0000.938recovers the true causes
Seaquest1.000n/a1.0001.0001.000recovers the true causes
Skiing1.000n/a1.0001.0000.909recovers the true causes
Space Invaders1.000n/a1.0001.0000.958recovers the true causes
Tennis1.000n/a1.0001.0000.929recovers the true causes
Venture1.000n/a1.0001.0000.978recovers the true causes
Video Pinball1.000n/a1.0001.0001.000recovers the true causes
Yars' Revenge1.000n/a1.0001.0001.000recovers the true causes

Call stack — how it runs on jutari / jaxtari

The path from this method's runner (run_game) into the bit-exact VCS substrate, and the computation it involves. Every step links to the exact source on main. From callstack_phaseC.json.

Applied on the substrate

  1. Boot the real ROM with the xitari-parity reset (60 NOOP + 4 RESET) — env_reset!(env; boot_noop_steps = 60, boot_reset_steps = 4) jutari
  2. Deterministically step the emulator one action per env-frame — env_step!(env::StellaEnvironment, action::Integer) jutari
  3. Assemble the shared gameplay-state testbed (checkpoint at f*, causes, shared screen output cell, cause-density gate) — build_shared_testbed(game; ... intervene_ram = intervene_ram!, run_intervention = run_intervention, soft_ram_peek = soft_ram_peek, ...) runner
  4. Freeze byte-exact RAM + screen out of the env at a frame — snapshot(env::StellaEnvironment, frame::Integer) jutari

The computation

  1. Single-site activation patch: write the patched value into one RAM cell / TIA register, re-run the tail, snapshot (the TRUE patch) — run_patch(checkpoint, tail, kind, site, value) -> intervene_ram!/intervene_tia! runner intervene_ram! @ activation_patching.jl:285, intervene_tia! @ :287
  2. Patch loop: recovered (rec_snap) vs exact-clean and corrupted-fresh (ex_snap) re-runs per patch site, both via run_patch — rec_snap = run_patch(clean_ckpt, ...); ex_snap = run_patch(fresh_ckpt, ...) inside _run_game_body runner rec_snap @ :460, ex_snap @ :466

Scored against the exact intervention oracle Δy(u): run_intervention(checkpoint, actions, target_frame, horizon, cause).

Implementation
tools/xai_study/phaseC_mechanistic/activation_patching.jl
Reference
Vig et al. 2020; ROME, Meng et al. 2022
Record
activation_patching_pong.json
All records
phaseC_mechanistic/out

The figure is generated from the committed record by docs/gen_method_figures.py; the game frame and each RAM cell's screen footprint are produced by render_scenes.jl / cell_footprints.jl.