Dictionary learning with NMF or PCA is a simpler cousin of the sparse autoencoder. It decomposes the recorded state into a small dictionary of components: PCA finds orthogonal directions of largest variance, NMF finds additive, non-negative parts. Each component is then matched to a known variable, on the hope that the decomposition recovers the machine's true internal factors without supervision. This is a classic, cheap way to look for structure in high-dimensional activity, and it predates the neural sparse-autoencoder approach. We run it on the VCS state and measure the fraction of dictionary components that match a known, verified variable. It does partially well: NMF's non-negativity separates the additive register basis better than PCA, which tends to mix several variables into one component. So NMF recovers cleaner named components while PCA blurs them. The deeper limit is the same one that defeats the sparse autoencoder. A component can match a variable by correlation while playing little causal role, so a good matched fraction does not guarantee the component is causally used. The VCS lets us check both the match and the causal use, and the two often disagree. Dictionary learning is thus a useful, interpretable baseline whose named components must still be tested by intervention before they can be trusted.
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.
Suppose you record a network's activations for thousands of inputs and stack them into one big table: one row per input, one column per neuron. A dictionary is a short list of “building-block” patterns, called atoms or components, such that every row can be written as a weighted sum of a few atoms. If the atoms line up with real concepts, you have found the concepts without any labels. This is dictionary learning, and it is much older than deep learning: neuroscientists used it in the 1990s to explain how the visual cortex codes images.
Two classical recipes are fast and need no training loop. PCA (principal component analysis) finds the directions along which the data varies most; they are orthogonal and may have negative entries. NMF (non-negative matrix factorisation) only allows non-negative atoms and weights, so everything is built by adding parts, never by cancelling them. That often gives “parts” a human can recognise. The sparse autoencoder is the modern, learned, sparse member of the same family. Running the classical ones first shows how much the extra machinery buys.
Put the data in a matrix \(X\) with \(T\) rows (samples) and \(n\) columns (neurons). Both methods approximate it by a product of a thin matrix of activations \(A\) (\(T\times r\)) and a short matrix of atoms \(H\) (\(r\times n\)), with \(r \ll n\):
\[ X \;\approx\; \mu + A H \quad(\text{PCA, } H H^\top = I), \qquad X \;\approx\; A H \quad(\text{NMF, } A \ge 0,\ H \ge 0). \]PCA takes \(\mu\) = the column means and \(H\) = the top \(r\) right singular vectors of the centred data. NMF minimises \(\|X - AH\|^2\) with the Lee–Seung multiplicative updates, which keep every entry non-negative. An SAE is the same picture with a learned, non-linear, sparse encoder for \(A\).
Toy example. Three RAM-like cells \((a, b, c)\). Cells \(a, b\) move together (a ball's x and a copy of it); cell \(c\) is a score. Four frames: \((4,2,0)\), \((2,1,0)\), \((0,0,5)\), \((4,2,5)\). NMF with \(r=2\) finds the exact answer \(H_1=(2,1,0)\) (“ball”), \(H_2=(0,0,1)\) (“score”), with weights \((2,0), (1,0), (0,5), (2,5)\). Each atom is one variable. PCA on the same data gives a first component of roughly \(0.92\cdot\)score \(-\,0.38\cdot\)ball: both variables vary a lot, so the direction of largest variance mixes them, with a sign. Same data, same rank; only the constraint differs.
| Activation table \(X\) | The RAM tape: one row per video frame, 128 columns (one per RAM byte). |
|---|---|
| Atom / component | A pattern over the 128 bytes. A clean atom loads on the bytes of one game variable. |
| Concept label | The verified concept↔cell map (for Pong: scores, paddle and ball positions), grouped into families such as “ball” or “score”. |
| Structural signals (no network analogue) | Known machine-level signals: the frame clock, how many bytes change per frame, and a fitted periodic phase. |
| Removing an atom | Zero its weight, rebuild the 128 bytes, round them, write them into the real machine, replay 15 frames. |
| Ground truth | The bit-exact re-run itself: an atom is causally used only if removing it changes the screen. |
Our runner dictionaries.jl applies PCA, NMF and a small sparse autoencoder to the
same RAM tape and scores all three with the same tests. That makes it a fair three-way
comparison.
The triad: F is the Spearman correlation between how strongly each atom matches a variable and its causal effect (“are the named atoms the used ones?”). S correlates the effect with the held-out-tail effect. M is one minus the share of matched atoms whose removal does nothing: naming a causally dead atom is an over-claim.
Why NMF tends to win on the VCS: RAM is literally a list of separate bytes that the program adds to and overwrites. That is an additive, non-negative “parts” structure, which is what NMF assumes. PCA's orthogonality has no reason to hold, so its components blend variables that happen to vary together.
XAI the interpretability method substrate the bit-exact emulator it runs on (jutari, Julia).
dictionaries.jl:409 — PCA: SVD of the centred RAM tape; rows of \(V^\top\) are the atoms.dictionaries.jl:430 — NMF: Lee–Seung multiplicative updates that keep both factors non-negative.dictionaries.jl:487 — The small L1 sparse autoencoder used as the third, learned dictionary.dictionaries.jl:599 — Matched fraction: loading purity on one concept family, or correlation with a structural signal.dictionaries.jl:680 — Ablate one atom, decode, write the state, replay, count pixels against the reconstruction control.dictionaries.jl:671 — Writes a full 128-byte state into a copy of the machine and replays the tail.dictionaries.jl:842 — One game: record the tape, then PCA / NMF / SAE at each rank, matching, causal use, triad.jutari_oracle.jl:176 — The single-byte write into emulated RIOT RAM that the decoded state is built from.StellaEnvironment.jl:220 — Boots the cartridge with the xitari-parity reset before any recording.StellaEnvironment.jl:259 — One bit-exact video frame: the step behind every recorded row and every re-run.The figure compares the matched fraction of PCA, NMF and the SAE. Expect a partial result: NMF recovers more cleanly named atoms than PCA, because its atoms are additive parts, while PCA folds several variables into one signed direction. Then look at causal use. Several matched atoms do little or nothing when removed, while some unmatched ones matter. So a good matched fraction is evidence that a variable is present in the data, not that the program uses that atom. This is the same gap the SAE page shows.
julia --project=jutari tools/xai_study/phaseC_mechanistic/dictionaries.jl --games core
Writes dictionaries_<game>.{json,npz} and dictionaries_core_summary.json into tools/xai_study/phaseC_mechanistic/out/. Use --rank 8 for the headline rank only.
Read next: Sparse autoencoders · A7 · Dimensionality reduction (NMF/PCA) · Linear probing + control tasks · Interchange interventions / DAS
The score is the fraction of NMF or PCA dictionary components that match a known, verified variable. We decompose the state into a small dictionary, match each component to a known variable, and count the matched fraction. The grading is always against the intervention oracle. Faithfulness for this method is that matched fraction, and it is partial: NMF's non-negativity separates the additive register basis better than PCA, so NMF yields cleaner named components while PCA blurs several variables into one component. The deeper limit, which the matched fraction alone does not expose, is causal use. A component can match a variable by correlation while playing little causal role, so a good matched fraction sits beside near-zero causal use, exactly as it does for the sparse autoencoder. There is no sprite-position hard-case here; the output is a set of components. The grading is always against the oracle, never against another method. The audit box reports the measured triad across the scored games, so the matched fraction is an average over the scored set. Dictionary learning is a useful interpretable baseline, but its named components must be tested by intervention before they can be trusted, and the score reflects that gap.
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.
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.
| Axis | Formula (as computed) | What it measures | Matches §3? |
|---|---|---|---|
| F faithfulness | \(F = \rho_{\mathrm{Spearman}}\!\left(\mathrm{attr}_{\mathrm{atom}},\ \Delta y(u)\right)\) | Spearman correlation between each matched NMF atom's strength and its true ablation causal effect: do matched dictionary atoms causally drive the output? | ✓ matches dictionaries.jl now emits top-level metric_name=nmf_causal_effect_agreement_spearman with extra.triad.F = Spearman(matched-strength, ablation causal effect); it replaced the matched-component NAMING fraction. This is exactly §3's causal-effect-agreement correlation vs {Delta_y(u)}. Record: dictionaries_pong.json extra.triad.F=0.3 (F_note '...causal-agreement F, not the correlational matched-component fraction'). how it's measured — call stack:
|
| S sufficiency | \(S = \rho_{\mathrm{Pearson}}\!\left(\Delta y(u),\ \Delta y_{\mathrm{held\text{-}out}}(u)\right) \in [-1,1]\) | Held-out sufficiency: Pearson correlation of each atom's ablation causal effect with its effect on an unseen continuation (bit-exact re-run); can be negative. | ✓ matches dictionaries.jl (S = _pearson(cu.effect, cu.held_out_effect)); a held-out predictive correlation in [-1,1], the §3 (sec:triad) form. Now surfaced in extra.triad.S (dictionaries_pong.json S=0.932). how it's measured — call stack:
|
| 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\}\) | Oracle-mover count over the number of matched atoms the method names above threshold. | ✓ matches dictionaries.jl now emits M = |U*|/|U_hat| in extra.triad.M (M_note '|U*|=8 (oracle movers) / |U_hat|=5 (method above-threshold)'), the paper's set-size ratio. Record: dictionaries_pong.json extra.triad.M=1.0. Replaced the former 1 − over-claim rate. how it's measured — call stack:
|
✓ 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).
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 (44 committed §R records), not the single example shown above. Tradition: dim_reduction. The example figure (Pong) is one of those records.
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.
This method's faithfulness on each of the 44 scored games (all-regime F; this method has no content-vs-position split (see the note below)). Click a header to
sort. Every number is read from site_data.json
(methods.dictionaries.per_game).
content-F and position-F are n/a for this method. The content-vs-position split applies only to pixel-attribution methods — those that produce an attribution map over the framebuffer, whose score can be read separately on the smooth content outputs and the discrete position outputs (where the naive gradient is provably zero). This method does not attribute to a pixel output; it is graded by its own fidelity metric (nmf_causal_effect_agreement_spearman), reported as the F column, so there is no content or position regime to separate.
S is negative on 8 of these games. Sufficiency asks whether the explanation can predict the outcome of held-out interventions it was not fit on; for this method S is the held-out predictive correlation between its claim and the true effect, so it lies in [−1, 1]. A value near 0 means the explanation predicts no better than the unperturbed output; a negative value means it predicts worse — its held-out claim is anti-correlated with the truth (named yet causally unused). The aggregate S is the mean over the scored games.
| Game | F (all) | content-F | position-F | S | M | Note |
|---|---|---|---|---|---|---|
| Krull | 0.733 | n/a | n/a | 1.000 | 1.000 | |
| Frostbite | 0.659 | n/a | n/a | -0.864 | 1.000 | |
| Enduro | 0.657 | n/a | n/a | -0.553 | 1.000 | |
| Boxing | 0.502 | n/a | n/a | 0.830 | 1.000 | |
| Yars' Revenge | 0.464 | n/a | n/a | -0.355 | 1.000 | |
| Demon Attack | 0.432 | n/a | n/a | 0.923 | 1.000 | |
| Ice Hockey | 0.353 | n/a | n/a | 0.953 | 1.000 | |
| Assault | 0.312 | n/a | n/a | 0.908 | 1.000 | |
| Pong | 0.300 | n/a | n/a | 0.932 | 1.000 | |
| Private Eye | 0.291 | n/a | n/a | 0.999 | 1.000 | |
| Centipede | 0.273 | n/a | n/a | 0.989 | 1.000 | |
| Pitfall | 0.203 | n/a | n/a | 0.967 | 1.000 | |
| Pacman | 0.156 | n/a | n/a | 0.997 | 1.000 | |
| Q*bert | 0.127 | n/a | n/a | 0.132 | 1.000 | |
| Breakout | 0.109 | n/a | n/a | 0.937 | 1.000 | |
| Kung-Fu Master | 0.058 | n/a | n/a | 0.997 | 1.000 | |
| Riverraid | 0.038 | n/a | n/a | -0.051 | 1.000 | no true-cause signal |
| Air Raid | 0.000 | n/a | n/a | -0.834 | 1.000 | no true-cause signal |
| Alien | 0.000 | n/a | n/a | 1.000 | 0.333 | no true-cause signal |
| Asterix | 0.000 | n/a | n/a | 0.988 | 1.000 | no true-cause signal |
| Atlantis | 0.000 | n/a | n/a | 0.992 | 1.000 | no true-cause signal |
| Bank Heist | 0.000 | n/a | n/a | 0.627 | 1.000 | no true-cause signal |
| Berzerk | 0.000 | n/a | n/a | 0.989 | 1.000 | no true-cause signal |
| Bowling | 0.000 | n/a | n/a | 0.800 | 1.000 | no true-cause signal |
| Carnival | 0.000 | n/a | n/a | 0.985 | 1.000 | no true-cause signal |
| Chopper Command | 0.000 | n/a | n/a | 0.995 | 1.000 | no true-cause signal |
| Double Dunk | 0.000 | n/a | n/a | 0.998 | 1.000 | no true-cause signal |
| Fishing Derby | 0.000 | n/a | n/a | 0.957 | 1.000 | no true-cause signal |
| Freeway | 0.000 | n/a | n/a | -0.291 | 1.000 | no true-cause signal |
| Gopher | 0.000 | n/a | n/a | 0.961 | 1.000 | no true-cause signal |
| Hero | 0.000 | n/a | n/a | 0.939 | 1.000 | no true-cause signal |
| Jamesbond | 0.000 | n/a | n/a | 0.915 | 1.000 | no true-cause signal |
| Kangaroo | 0.000 | n/a | n/a | -0.759 | 1.000 | no true-cause signal |
| Montezuma's Revenge | 0.000 | n/a | n/a | 0.524 | 1.000 | no true-cause signal |
| Ms. Pac-Man | 0.000 | n/a | n/a | 0.835 | 1.000 | no true-cause signal |
| Name This Game | 0.000 | n/a | n/a | 0.977 | 1.000 | no true-cause signal |
| Phoenix | 0.000 | n/a | n/a | -0.928 | 1.000 | no true-cause signal |
| Road Runner | 0.000 | n/a | n/a | 0.769 | 1.000 | no true-cause signal |
| Seaquest | 0.000 | n/a | n/a | 0.652 | 1.000 | no true-cause signal |
| Skiing | 0.000 | n/a | n/a | 0.854 | 1.000 | no true-cause signal |
| Space Invaders | 0.000 | n/a | n/a | 0.881 | 1.000 | no true-cause signal |
| Tennis | 0.000 | n/a | n/a | 0.967 | 1.000 | no true-cause signal |
| Venture | 0.000 | n/a | n/a | 1.000 | 1.000 | no true-cause signal |
| Video Pinball | 0.000 | n/a | n/a | 0.711 | 1.000 | no true-cause signal |
The path from this method's runner (compute_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.
env_reset!(env; boot_noop_steps = 60, boot_reset_steps = 4) jutarienv_step!(env::StellaEnvironment, action::Integer) jutaribuild_shared_testbed(game; ... intervene_ram = J.intervene_ram!, run_intervention = O.run_intervention, soft_ram_peek = soft_ram_peek, ...) runnersnapshot(env::StellaEnvironment, frame::Integer) jutarirecord_ram(game, frames, ...) -> env_step! runnerrun_pca(tape, rank) / run_nmf(tape, rank) / run_sae(tape, hidden) runner run_nmf @ :464, run_sae @ :487causal_use(m, checkpoint, at_target, tail, ...) -> rerun_with_ram(checkpoint, bk, tail) runner rerun_with_ram writes the decoded RAM over all cells via intervene_ram! (dictionaries.jl:671 -> :674)Scored against the exact intervention oracle Δy(u): run_intervention(checkpoint, actions, target_frame, horizon, cause).
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.