Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Mechanistic interpretability investigates how a neural network computes its outputs. Sparse autoencoders (SAEs) offer one set of candidate units: reconstruct a dense activation vector as a sparse combination of learned directions, then test how those directions participate in computation. A dictionary is a starting point for an explanation, not a complete algorithm recovered from the model.
Imagine investigating a code assistant that refuses a legitimate password-reset script. Was it responding to credentials, urgent wording, or something else? Suppose neuron 412 activates on credential resets, SQL queries, and urgent instructions, while neuron 789 responds to secret-manager docs and shell syntax. These are invented observations to frame the investigation; neither establishes why the assistant refused.
You have millions of activation numbers, but no clean unit that corresponds to a human concept.
The previous chapter showed how layer normalization changes the scale read by transformer branches. It doesn't tell you what individual coordinate directions compute. Here we'll look for more interpretable candidate directions, connect them into circuit hypotheses, and test edits to the residual stream.
This entanglement is the problem of polysemanticity: an individual neuron responds to multiple unrelated kinds of context. A raw coordinate needn't map cleanly to a single semantic idea.
To explain why polysemanticity happens, researchers rely on the superposition hypothesis: models represent far more features than they have dimensions by packing them into overlapping, non-orthogonal directions.
Why can one neuron fire for credential reset, malicious intent, and urgent tone at the same time?
Answer
Under the superposition hypothesis, useful concepts share overlapping directions instead of separate coordinate axes. A neuron can respond to several of those directions at once, which makes it a weak unit for assigning human labels. The next step is to find more useful candidate units and test them experimentally.
Mechanistic interpretability researchers train sparse autoencoders (SAEs), networks that reconstruct language model activations through a wide, sparse bottleneck, to search for these underlying feature directions.[1][2]
Bricken et al. and Cunningham et al. found features with clearer activation patterns than comparison directions in their experiments.[1][2] A candidate might respond to credential-related instructions or urgent wording. Its label and its computational role are separate hypotheses: an intervention can establish an effect without proving that the label captures every context in which the feature fires.
Reconstruction and causality are distinct claims. A feature can help reconstruct activations while tracking a grammatical correlate of the behavior you care about. Controlled patching, ablation, and steering test the consequences of a specified edit. Held-out prompts, control edits, and collateral-capability measurements determine how far that result supports your explanation.
The problem: polysemantic neurons and superposition
Inside a transformer, residual-stream vectors and MLP hidden activations carry dense mixtures of information. If a model wants to track useful concepts, but its residual stream dimension is with , how can it possibly store them?
An ambient space holds at most mutually orthogonal unit vectors. That limits an orthogonal linear dictionary at one activation site. It does not limit a model to concepts over its lifetime: other layers, nonlinear computations, and context-dependent representations also matter.
The Johnson-Lindenstrauss lemma concerns a finite set of points: a suitable linear map into dimensions preserves all pairwise squared Euclidean distances within factors .[3] Related concentration arguments let high-dimensional spaces hold many unit directions with small pairwise inner products. For example, draw each direction independently with coordinates . A concentration bound and a union bound give
Keeping the right side below a chosen failure probability lets grow exponentially with for fixed . The bound can be loose or exceed one. It describes this random construction, not the exact capacity of a 4,096-dimensional residual stream or proof that a trained transformer uses such a packing.
Feature sparsity reduces interference in toy models of superposition.[4] It doesn't guarantee interference disappears. For unit directions with coherence and , an inactive direction's readout obeys
The contributions accumulate with active magnitudes and support size. A threshold must suppress that leakage while retaining weak real signals. Active-latent counts are properties of a particular SAE recipe, not a measured universal count of concepts in each token.
Predict whether a threshold of rejects the inactive direction below. Each active direction overlaps it by only .
1import math
2
3def dot(left, right):
4 return sum(a * b for a, b in zip(left, right))
5
6inactive = [1.0, 0.0, 0.0]
7active_directions = [[0.1, math.sqrt(0.99), 0.0],
8 [0.1, 0.0, math.sqrt(0.99)]]
9magnitudes = [10.0, 10.0]
10x = [sum(a * d[index] for a, d in zip(magnitudes, active_directions))
11 for index in range(3)]
12leakage = dot(inactive, x)
13bound = 0.1 * sum(magnitudes)
14print(f"inactive readout: {leakage:.2f}; coherence bound: {bound:.2f}")
15print("threshold 1 falsely activates:", leakage > 1.0)
16assert math.isclose(leakage, bound)1inactive readout: 2.00; coherence bound: 2.00
2threshold 1 falsely activates: TrueSuch packing can produce polysemantic coordinates: several directions contribute to one axis. Polysemanticity alone doesn't establish superposition, however; even a rotated orthogonal dictionary can give mixed coordinates without having more features than dimensions. Residual coordinates and MLP neurons also differ: MLP activation functions introduce a coordinate-specific nonlinearity.
![Two non-orthogonal unit directions share a two-dimensional activation space. Credential is [1,0]; urgency is [0.8,0.6]. Their first-coordinate contributions, 1.5 and 0.8, add to a mixed raw-axis reading of 2.3.](/cdn/content-image/fundamentals/mechanistic-interpretability-sparse-autoencoders/illustrations/_generated/superposition_geometry_dark.png?v=7c41ba06ed94)
To make that geometric interference measurable, compute the cosine similarity between two candidate directions and construct their combined activation. If the urgency direction isn't orthogonal to the credential direction, predict how much coordinate A increases when both features fire simultaneously.
1import math
2
3credential = [1.0, 0.0]
4urgency = [0.8, 0.6]
5
6def cosine(left, right):
7 dot = sum(a * b for a, b in zip(left, right))
8 norm = math.sqrt(sum(a * a for a in left) * sum(b * b for b in right))
9 return dot / norm
10
11mixed_activation = [1.5 * credential[0] + urgency[0], 1.5 * credential[1] + urgency[1]]
12print(f"credential/urgency cosine: {cosine(credential, urgency):.2f}")
13print(f"axis A reading after both fire: {mixed_activation[0]:.2f}")
14
15assert cosine(credential, urgency) > 0
16assert mixed_activation[0] > 1.51credential/urgency cosine: 0.80
2axis A reading after both fire: 2.30How sparse autoencoders produce candidate features
A sparse autoencoder attacks the superposition problem with overcomplete dictionary learning. It treats an activation vector as a sparse linear combination of directions drawn from a much wider learned dictionary with . If the model packed features into non-orthogonal directions, the SAE's job is to unroll those directions onto separate latent axes.
An SAE consists of an encoder that projects the activation into a high-dimensional sparse code , and a decoder that reconstructs the activation from that code:
- Encoder: where with , and is an activation function such as ReLU or a TopK operator.
- Decoder: where . Each column represents a candidate feature direction in the model's activation space.
In this formulation, subtracting the learned decoder bias gives encoder and decoder a shared reference point.[1] That bias need not equal the exact dataset mean. Other SAE formulations omit pre-encoder subtraction, including the deliberately simple NumPy lab below.
Tied vs untied decoders
Weight tying () reduces matrix storage. Untied weights let detection and reconstruction directions differ, as in Bricken's SAE and Gao's TopK recipe. Cunningham's study used tied weights and reported no performance reduction on its residual-stream data, with some reduction on MLP data.[1][2][5]
The reason comes down to an asymmetry in what the two layers do:
- The encoder solves a feature detection problem under noise. Because other features in superposition create non-zero dot-product interference, the encoder needs sharp geometric selectivity and negative bias margins to keep false positives low.
- The decoder adds its learned candidate direction to the reconstructed activation, weighted by . The reconstruction lives at the chosen hook, which may be a branch output rather than the residual stream.
Tying restricts that independence, but its effect depends on the data and recipe. Tied initialization is a different choice: Gao initializes encoder weights from the decoder transpose, then allows the two matrices to train independently.[5]
The L1 shrinkage problem
The traditional training loss combines a mean squared error (MSE) reconstruction term with an L1 sparsity penalty:
For a positive latent, this mean-normalized sparsity term contributes to . The optimizer updates encoder parameters through the chain rule, so it doesn't simply subtract from every latent each step. Nevertheless, the penalty favors smaller magnitudes even when support stays unchanged. With a fixed unit decoder in one dimension, minimizing over gives . This is L1 shrinkage. Sum/mean conventions change the coefficient. Unconstrained decoder norms introduce a separate scaling loophole discussed below.
Three widely studied designs address magnitude shrinkage in different ways:
- TopK SAEs: A nonnegative variant keeps at most nonzero values: . It removes the L1 term from the main reconstruction objective, not all reasons an activation could decrease. Selection boundaries are non-smooth, and Gao's full recipe adds AuxK for dead latents. That study trained a 16-million-latent dictionary on GPT-4 activations; its main encoder omits .[5]
- Gated SAEs: A binary gate selects a nonnegative magnitude estimate. The original recipe shares projection directions, with separate row scales and biases. L1 applies to rectified gate pre-activations. An auxiliary reconstruction through those gate pre-activations supplies a learning signal, with decoder parameters detached in that path. Thus the magnitude estimate has no direct L1 penalty, although shared parameters can still change.[6]
- JumpReLU SAEs: keeps the full pre-activation above a learned positive threshold. An L0 penalty controls support. Naively differentiating the threshold gives zero almost everywhere; the paper uses custom windowed straight-through estimators, whose bandwidth affects bias and variance.[7]
At low sparsity penalty , expect dense mixtures to win; at high , expect sparse codes to win. The small calculation below makes that objective switch visible.
1def mse(target, reconstructed):
2 return sum((a - b) ** 2 for a, b in zip(target, reconstructed)) / len(target)
3
4def objective(target, reconstructed, code, sparsity_weight):
5 mean_magnitude = sum(abs(value) for value in code) / len(code)
6 return mse(target, reconstructed) + sparsity_weight * mean_magnitude
7
8target = [1.0, 0.0]
9sparse_code = [0.75, 0.0]
10sparse_reconstruction = [0.75, 0.0]
11dense_code = [0.75, 0.50]
12dense_reconstruction = [1.0, 0.0]
13
14for weight in [0.02, 0.20]:
15 sparse_loss = objective(target, sparse_reconstruction, sparse_code, weight)
16 dense_loss = objective(target, dense_reconstruction, dense_code, weight)
17 chosen = "sparse" if sparse_loss < dense_loss else "dense"
18 print(f"lambda={weight:.2f}: sparse={sparse_loss:.4f}, dense={dense_loss:.4f} -> {chosen}")
19
20assert objective(target, dense_reconstruction, dense_code, 0.02) < objective(target, sparse_reconstruction, sparse_code, 0.02)
21assert objective(target, sparse_reconstruction, sparse_code, 0.20) < objective(target, dense_reconstruction, dense_code, 0.20)1lambda=0.02: sparse=0.0387, dense=0.0125 -> dense
2lambda=0.20: sparse=0.1063, dense=0.1250 -> sparseThe original "Towards Monosemanticity" study (2023) trained an SAE on a one-layer transformer, decomposing a 512-neuron MLP into 4,096 features in run A/1. Inspected features showed clean specialization for DNA sequences, Hebrew script, base64 text, and legal formatting.[1]
A year later, "Scaling Monosemanticity" applied dictionary learning to a middle-layer residual stream of Claude 3 Sonnet (the March 2024 production model). It scaled dictionary widths to 1 million, 4 million, and 34 million features, isolating abstract concepts like sycophancy, code vulnerabilities, and multilingual entity representations.[8]
Why is an SAE overcomplete for interpretability?
Answer
An overcomplete SAE gives the dictionary more latent slots than the activation space has axes, which lets it search for cleaner candidate directions under the superposition hypothesis. A sparsity objective rewards selective use of those slots; a hard support constraint such as TopK enforces a limit directly.
1def top_k_sparse(values, k):
2 if type(k) is not int or not 0 <= k <= len(values):
3 raise ValueError("k must be an integer between zero and the latent width")
4 positive = [max(0.0, value) for value in values]
5 # Stable sorting makes ties deterministic: earlier indices win.
6 ranked = sorted(range(len(values)), key=lambda index: positive[index], reverse=True)
7 keep = set(ranked[:k])
8 return [value if index in keep else 0.0 for index, value in enumerate(positive)]
9
10latent = [0.1, 2.4, 0.3, 1.7, 0.05]
11sparse = top_k_sparse(latent, k=2)
12
13assert sparse == [0.0, 2.4, 0.0, 1.7, 0.0]
14assert sum(value != 0.0 for value in sparse) == 2
15assert top_k_sparse([-2.0, 0.0, 1.0], 2) == [0.0, 0.0, 1.0]
16assert top_k_sparse([2.0, 2.0], 1) == [2.0, 0.0]
17assert top_k_sparse(latent, 0) == [0.0] * len(latent)
18for invalid in [-1, 6, 1.5]:
19 try:
20 top_k_sparse(latent, invalid)
21 except ValueError:
22 pass
23 else:
24 raise AssertionError("invalid k accepted")
This pipeline separates two distinct questions:
- Does the dictionary reconstruct the activation accurately while activating only a small handful of coordinates?
- Do those learned directions correspond to true computational features that predict and control model behavior?
A low reconstruction error and high sparsity answer the first question. They don't automatically answer the second. That distinction determines how you choose hook points and how you validate features causally.
Where to attach SAEs inside a transformer
Researchers attach SAEs at several hook points across a transformer block:
- Residual stream after attention or MLP: The residual stream is the model's shared trunk communication channel. Hooking here captures information accumulated across all preceding layers.
- MLP output update: Captures the specific non-linear update written by that sublayer into the residual stream.
- Attention layer output: Decomposes the cross-token context routing information before it merges into the residual stream.
These sites can use the same SAE architecture, but a pretrained dictionary is tied to its exact checkpoint and hook. Record the layer, token position, pre/post normalization, attention output projection, activation dtype, and any centering or rescaling. Sites can share a width while differing in distribution. Joint dictionaries are possible too, but require an explicit multi-site training design.

Cunningham et al. reported mixed success on MLP data, with many inactive dictionary features in some middle and later layers. Untied weights retained more active features in those experiments. These results don't establish a universal ranking of activation sites: compare reconstruction, sparsity, and downstream behavior at the particular hook you plan to use.[2]
Marks et al. trained sublayer SAEs in Pythia-70M and used existing Gemma Scope dictionaries for Gemma-2-2B.[9] The hook conventions differ: Gemma's attention dictionary in that study reads before the output projection, and its MLP dictionary reads after the branch output norm. Choosing a hook defines what your directions describe; matching a layer number alone isn't enough.
Why might a residual-stream SAE and an MLP-output SAE reveal different features?
Answer
The residual stream is the shared communication channel that carries mixed information forward through the model. MLP outputs are the updates written by one sublayer. SAEs trained at either point offer different candidate descriptions, and experiments must establish which candidates carry behaviorally relevant information.
Feature circuits: from features to algorithms
Once you've trained SAEs across multiple layers, you can move from cataloging isolated features to tracing feature circuits: sparse subgraphs that show how earlier features feed downstream computations to produce the final token prediction.
Attribution methods suggest candidate dependencies across layers, but correlation along an edge isn't causal proof. A circuit hypothesis becomes credible only when intervention experiments (ablation, feature clamping, or activation patching) shift behavior in the exact direction the circuit predicts.[9]
Circuit studies investigate model- and task-specific mechanisms, with varying completeness:
- Induction heads that complete patterns such as
[A][B] … [A] → [B]. Olsson's study gives causal evidence in small attention-only models and correlational evidence in larger models with MLPs.[10] - The indirect-object identification (IOI) circuit in GPT-2 small.[11]
- The greater-than comparison circuit in GPT-2 small.[12]
- Sparse feature circuits that explain complex behaviors in terms of human-interpretable feature nodes rather than uninterpretable neuron indices.[9]
Attribution graphs and cross-layer transcoders
Anthropic's 2025 method uses cross-layer transcoders (CLTs). A feature reads at its associated residual-stream layer and has separate decoder vectors contributing to MLP-output reconstructions at that layer and later ones. Each MLP output is reconstructed from features at its own and earlier layers. The model's attention operations remain; CLTs don't replace the entire transformer.[13]
For a chosen prompt, its local replacement model fixes attention patterns and normalization denominators to the original forward pass and adds back reconstruction errors as constants. Baseline activations and logits then match, without guaranteeing matching responses to edits. Its graph includes feature, token-embedding, unexplained-error, and selected output-logit nodes. Feature edges measure direct contributions to target pre-activations, with intervening MLP outputs stopped in the backward pass; a schematic edge is
where is the target encoder pre-activation. This isn't the full model's unrestricted total derivative through every intermediate feature. Graph pruning selects a manageable hypothesis.[13]
The graph explains information flow given attention, not how Q/K computations formed that attention. Error nodes and inactive suppressed features can hide essential mechanisms. Check reconstruction fidelity, graph completeness, and interventions in the original model. The companion Biology study investigates particular behaviors of Claude 3.5 Haiku.[13][14]
How does an attribution graph differ from running activation patching one component at a time?
Answer
Activation patching tests selected edits. Anthropic's local attribution graph uses a replacement model with frozen attention and normalization denominators, plus error corrections, to surface direct contribution paths. It leaves parts of the computation unexplained. Validate important predictions in the original model, rather than treating baseline agreement or a graph edge as a complete explanation.

What extra evidence do you need before trusting an SAE feature as causal?
Answer
Specify the edit, hook, positions, and predicted behavioral change, then test held-out prompts and matched controls while preserving reconstruction error. An intervention establishes consequences of that edit; replication, semantic counterexamples, and collateral measurements determine whether the proposed feature explanation is useful.
1def refusal_score(features):
2 weights = {"credential_safety": 1.2, "urgency": 0.1, "formatting": 0.0}
3 return sum(features[name] * weights[name] for name in weights)
4
5observed = {"credential_safety": 0.9, "urgency": 0.8, "formatting": 0.7}
6credential_ablated = {**observed, "credential_safety": 0.0}
7formatting_ablated = {**observed, "formatting": 0.0}
8
9baseline = refusal_score(observed)
10target_change = baseline - refusal_score(credential_ablated)
11control_change = baseline - refusal_score(formatting_ablated)
12print(f"target ablation change: {target_change:.2f}")
13print(f"negative-control change: {control_change:.2f}")
14
15assert target_change > 1.0
16assert control_change == 0.01target ablation change: 1.08
2negative-control change: 0.00The negative control is unchanged because we assigned it zero weight in the synthetic function. In a real language model, an ablation experiment requires held-out prompts, matched control directions, and measurements of collateral capabilities (like language modeling perplexity or code execution accuracy). A null ablation could also reflect redundant parallel pathways or an underpowered intervention, so negative results require careful verification.
Activation steering with SAE features
Because each SAE feature corresponds to a specific column in the decoder matrix, we can use that direction for activation steering at inference time without modifying model weights:
- Run the forward pass and cache the residual-stream activation vector at the target layer.
- Add a scaled copy of the feature's decoder direction:
- Continue the forward pass using .
To set one coefficient in the cached SAE decomposition to while preserving its reconstruction error, use feature clamping: where is the encoder's computed activation for feature .
This changes a coefficient in the original decomposition. Re-encoding need not return , because encoder and decoder aren't necessarily dual, thresholds can switch, and other feature readouts can move. Here a unit decoder direction still misses the requested re-encoded target:
1def encode_feature(x):
2 return max(0.0, x[0] + 2.0 * x[1])
3
4x = [1.0, 0.5]
5decoder_direction = [0.0, 1.0]
6old_coefficient = encode_feature(x)
7target = 3.0
8edited = [value + (target - old_coefficient) * direction
9 for value, direction in zip(x, decoder_direction)]
10print(f"cached coefficient: {old_coefficient:.1f}; requested: {target:.1f}")
11print(f"re-encoded coefficient: {encode_feature(edited):.1f}")
12print(f"overlapping readout: {sum(x):.1f} -> {sum(edited):.1f}")
13assert encode_feature(edited) != target1cached coefficient: 2.0; requested: 3.0
2re-encoded coefficient: 4.0
3overlapping readout: 1.5 -> 2.5Whether a hypothetical credential-labeled feature increases refusal, and at what scale, must be measured. Anthropic's 2024 Sonnet experiments amplified a Golden Gate Bridge feature to induce bridge-related responses, with effects varying by feature and prompt. Their edits preserved error and ran at every token position; extreme scales could produce nonsense.[8] Arditi et al. studied difference-of-means refusal directions across 13 open chat models, rather than SAE-derived safety classifiers. Their results don't establish one universal direction or semantic safety boundary for every model.[15]
![Activation steering sweep geometry and readout projections. Left: 2D vector plot shows initial activation x = [0.2, 0.1] steered along direction v = [0, 1] across alpha values from -1 to +2. Right: Line chart shows projection values. Candidate [0, 1] projection rises linearly from -0.9 to +2.1; overlapping [0.6, 0.8] projection drifts from -0.6 to +1.8; while strictly orthogonal [1, 0] readout remains fixed at 0.20.](/cdn/content-image/fundamentals/mechanistic-interpretability-sparse-autoencoders/illustrations/_generated/steering_sweep_dark.png?v=b2e8a62e641b)
Why does steering use the decoder vector for a feature rather than the raw latent activation value alone?
Answer
The latent value tells you how strongly the feature is active. The decoder vector tells you which direction in the model's residual stream represents that feature. To edit the model state, you add or subtract that residual-stream direction.
1def add_scaled_direction(vector, direction, scale):
2 if len(vector) != len(direction):
3 raise ValueError("activation and direction must have the same width")
4 return [value + scale * delta for value, delta in zip(vector, direction)]
5
6activation = [0.2, -0.1, 0.0, 0.4]
7credential_feature_direction = [0.0, 0.5, 0.5, 0.0]
8steered = add_scaled_direction(activation, credential_feature_direction, scale=2.0)
9
10assert steered == [0.2, 0.9, 1.0, 0.4]
11assert steered[1] > activation[1]
12assert steered[2] > activation[2]1def add_scaled(vector, direction, scale):
2 return [value + scale * delta for value, delta in zip(vector, direction)]
3
4def project(vector, direction):
5 return sum(value * delta for value, delta in zip(vector, direction))
6
7activation = [0.2, 0.1]
8candidate_direction = [0.0, 1.0]
9unrelated_direction = [1.0, 0.0]
10overlapping_direction = [0.6, 0.8]
11
12for alpha in [-1.0, 0.0, 1.0, 2.0]:
13 steered = add_scaled(activation, candidate_direction, alpha)
14 score = project(steered, candidate_direction)
15 control = project(steered, unrelated_direction)
16 overlap = project(steered, overlapping_direction)
17 print(f"alpha={alpha:+.1f}: candidate={score:+.1f}, orthogonal={control:+.1f}, overlapping={overlap:+.1f}")
18
19assert project(add_scaled(activation, candidate_direction, 2.0), unrelated_direction) == 0.21alpha=-1.0: candidate=-0.9, orthogonal=+0.2, overlapping=-0.6
2alpha=+0.0: candidate=+0.1, orthogonal=+0.2, overlapping=+0.2
3alpha=+1.0: candidate=+1.1, orthogonal=+0.2, overlapping=+1.0
4alpha=+2.0: candidate=+2.1, orthogonal=+0.2, overlapping=+1.8Keep reconstruction error separate from feature ablation
Any SAE reconstruction leaves an unexplained residual error . The original activation decomposes as:
When testing the causal effect of feature , an error-preserving ablation subtracts only that feature's reconstructed contribution from the clean activation vector:
A dangerous beginner mistake is naive ablation: setting inside the SAE reconstruction, yielding . That naive step removes feature and simultaneously discards the unexplained residual . If downstream attention heads or MLP layers rely on signals inside , the model's performance drops, and researchers falsely attribute that drop to the ablated feature!
A small numerical calculation isolates this trap. Here the SAE has a tiny reconstruction error (), but the downstream readout relies entirely on the second coordinate. Predict whether naive ablation produces a false causal signal before running the check.
1x = [1.0, 0.1]
2x_hat = [1.0, 0.0]
3feature_contribution = [1.0, 0.0] # z_f * decoder_column_f
4
5def readout(vector):
6 return 100.0 * vector[1]
7
8preserved = [a - b for a, b in zip(x, feature_contribution)]
9naive = [a - b for a, b in zip(x_hat, feature_contribution)]
10mse = sum((a - b) ** 2 for a, b in zip(x, x_hat)) / len(x)
11print(f"reconstruction MSE: {mse:.3f}")
12for name, vector in [("original", x), ("reconstructed", x_hat),
13 ("error-preserving ablation", preserved), ("naive ablation", naive)]:
14 print(f"{name}: {readout(vector):.1f}")
15assert readout(preserved) == readout(x) == 10.0
16assert readout(naive) == readout(x_hat) == 0.01reconstruction MSE: 0.005
2original: 10.0
3reconstructed: 0.0
4error-preserving ablation: 10.0
5naive ablation: 0.0![Reconstruction error confound in feature ablation. Left: Vector arithmetic shows original activation x = [1.0, 0.1], SAE reconstruction x_hat = [1.0, 0.0] with MSE 0.005, and feature contribution [1.0, 0.0]. Right: Downstream score equals 100 times dimension 2. Original score is 10. Reconstruction alone drops score to 0. Error-preserving ablation retains score at 10, proving Feature 1 had zero effect. Naive ablation drops score to 0 by discarding the unexplained residual.](/cdn/content-image/fundamentals/mechanistic-interpretability-sparse-autoencoders/illustrations/_generated/feature_visualization_dark.png?v=17c2daf2372f)
For an LLM, measure downstream language-model loss as well as activation MSE. Loss Recovered expresses one baseline-relative comparison:
Here uses original activations, substitutes reconstructions, and uses a specified mean/zero baseline at the same hooks and positions. The ratio is undefined at a zero denominator and unstable near zero. It can be negative or exceed one. A very destructive baseline can make substantial damage look like excellent recovery; Gao explicitly cautions about residual-stream zero-ablation.[5] Report all three losses, , and task effects too.
Same SAE, same loss damage, different baseline. Which number would you trust without seeing the raw losses?
1import math
2
3def loss_recovered(clean, reconstructed, ablated):
4 losses = (clean, reconstructed, ablated)
5 if not all(math.isfinite(value) and value >= 0 for value in losses):
6 raise ValueError("losses must be finite and nonnegative")
7 denominator = ablated - clean
8 if denominator <= 1e-12:
9 raise ValueError("baseline must increase loss by more than the chosen tolerance")
10 return 1.0 - (reconstructed - clean) / denominator
11
12for baseline in [20.0, 2.4]:
13 recovered = loss_recovered(2.0, 2.2, baseline)
14 print(f"baseline={baseline:.1f}; delta loss=0.2; recovered={recovered:.3f}")
15print(f"worse than baseline: {loss_recovered(2.0, 5.0, 4.0):.2f}")
16print(f"better than clean: {loss_recovered(2.0, 1.9, 4.0):.2f}")
17try:
18 loss_recovered(2.0, 2.2, 2.0)
19except ValueError:
20 print("zero denominator: rejected")1baseline=20.0; delta loss=0.2; recovered=0.989
2baseline=2.4; delta loss=0.2; recovered=0.500
3worse than baseline: -0.50
4better than clean: 1.05
5zero denominator: rejectedA toy NumPy SAE you can run today
The lab fits a small SAE on synthetic mixtures of 24 known directions in eight dimensions. Copy the training, summary, and alignment blocks into one file in that order, then run uv run --with numpy python toy_sae.py. This is an uncentered ReLU encoder with a decoder bias, full-batch SGD, and unit-normalized decoder columns. It omits production features such as activation streaming, AuxK, and a learned threshold.
Before running it, keep three separate evaluation criteria in mind:
- Reconstruction MSE should fall.
- Count active latents, rather than assuming the sparsity penalty recovered the generator's 2–4 active features.
- Decoder columns should align with the known generating directions.
Those are three distinct mathematical claims.
1import numpy as np
2
3# --- Synthetic data with known superposition ---
4rng = np.random.default_rng(42)
5d_model = 8
6n_true_features = 24 # more features than dimensions
7n_samples = 768
8
9# Ground-truth feature directions (unit vectors)
10true_features = rng.normal(size=(n_true_features, d_model))
11true_features /= np.linalg.norm(true_features, axis=1, keepdims=True)
12
13# Each sample activates only 2-4 features (sparse)
14activations = np.zeros((n_samples, d_model))
15for i in range(n_samples):
16 active_idx = rng.choice(n_true_features, size=rng.integers(2, 5), replace=False)
17 coeffs = rng.normal(loc=1.5, scale=0.4, size=len(active_idx))
18 activations[i] = (coeffs[:, None] * true_features[active_idx]).sum(axis=0)
19
20# Add a little noise
21activations += 0.03 * rng.normal(size=activations.shape)
22
23# --- Tiny SAE implementation ---
24n_features = 64 # 8x expansion
25learning_rate = 0.03
26lambda_sparsity = 5.0 # L1 coefficient
27epochs = 300
28
29W_enc = rng.normal(scale=0.1, size=(n_features, d_model))
30b_enc = np.zeros(n_features)
31W_dec = rng.normal(scale=0.1, size=(d_model, n_features))
32W_dec /= np.linalg.norm(W_dec, axis=0, keepdims=True) + 1e-12
33initial_W_dec = W_dec.copy()
34b_dec = np.zeros(d_model)
35
36def encode(x: np.ndarray):
37 pre_activation = x @ W_enc.T + b_enc
38 z = np.maximum(0.0, pre_activation)
39 return pre_activation, z
40
41def reconstruct(x: np.ndarray):
42 _, z = encode(x)
43 return z, z @ W_dec.T + b_dec
44
45def reconstruction_loss(x: np.ndarray) -> float:
46 _, x_hat = reconstruct(x)
47 return float(np.mean((x - x_hat) ** 2))
48
49def train_sae(x: np.ndarray) -> None:
50 global W_enc, b_enc, W_dec, b_dec
51 for epoch in range(epochs):
52 # Forward
53 pre_activation, z = encode(x)
54 x_hat = z @ W_dec.T + b_dec
55
56 # Loss
57 recon_loss = np.mean((x - x_hat) ** 2)
58 sparsity_loss = np.mean(np.abs(z))
59 total_loss = recon_loss + lambda_sparsity * sparsity_loss
60
61 # Gradients (manual backprop for clarity)
62 d_recon = 2 * (x_hat - x) / x.size
63 dW_dec = d_recon.T @ z
64 db_dec = d_recon.sum(axis=0)
65
66 dz = d_recon @ W_dec
67 dz += (lambda_sparsity / z.size) * np.sign(z)
68 dz[pre_activation <= 0] = 0.0 # ReLU backward
69 dW_enc = dz.T @ x
70 db_enc = dz.sum(axis=0)
71
72 # SGD step
73 W_dec -= learning_rate * dW_dec
74 b_dec -= learning_rate * db_dec
75 W_enc -= learning_rate * dW_enc
76 b_enc -= learning_rate * db_enc
77
78 # Keep decoder columns normalized so scale can't move from z into W_dec.
79 W_dec /= np.linalg.norm(W_dec, axis=0, keepdims=True) + 1e-12
80
81 if epoch % 100 == 0:
82 print(f"Epoch {epoch:4d} | total={total_loss:.4f} | recon={recon_loss:.4f} | sparsity={sparsity_loss:.4f}")
83
84initial_recon = reconstruction_loss(activations)
85train_sae(activations)1Epoch 0 | total=1.9426 | recon=1.4259 | sparsity=0.1033
2Epoch 100 | total=0.5301 | recon=0.2370 | sparsity=0.0586
3Epoch 200 | total=0.4394 | recon=0.1564 | sparsity=0.0566First summarize the training set, then evaluate fresh held-out mixtures from the same generating dictionary.
1final_recon = reconstruction_loss(activations)
2z, _ = reconstruct(activations)
3active_per_sample = (z > 0).sum(axis=1).mean()
4
5print(f"Training complete: recon {initial_recon:.3f} -> {final_recon:.3f}, active features {active_per_sample:.1f}")
6
7# Fresh samples from the same known dictionary; no fitting on these samples.
8test_rng = np.random.default_rng(123)
9heldout = np.zeros((256, d_model))
10for i in range(len(heldout)):
11 indices = test_rng.choice(n_true_features, size=test_rng.integers(2, 5), replace=False)
12 coefficients = test_rng.normal(1.5, 0.4, size=len(indices))
13 heldout[i] = (coefficients[:, None] * true_features[indices]).sum(axis=0)
14heldout += 0.03 * test_rng.normal(size=heldout.shape)
15test_z, test_reconstruction = reconstruct(heldout)
16test_mse = np.mean((heldout - test_reconstruction) ** 2)
17mean_baseline_mse = np.mean((heldout - activations.mean(axis=0)) ** 2)
18fvu = test_mse / mean_baseline_mse
19print(f"Held-out MSE: {test_mse:.3f}; mean baseline: {mean_baseline_mse:.3f}; FVU: {fvu:.3f}")
20print(f"Held-out active features: {(test_z > 0).sum(axis=1).mean():.1f}")
21assert final_recon < initial_recon
22assert test_mse < mean_baseline_mse1Training complete: recon 1.426 -> 0.128, active features 18.9
2Held-out MSE: 0.128; mean baseline: 0.757; FVU: 0.169
3Held-out active features: 19.5The held-out sample tests generalization to fresh linear mixtures of the 24 ground-truth directions. Its Fraction of Variance Unexplained (FVU) is 0.169, showing solid reconstruction. But does low FVU prove we recovered the true ground-truth feature dictionary?
The alignment diagnostic below measures the maximum absolute cosine similarity between each true feature and all learned decoder columns.
1def direction_alignment(decoder: np.ndarray, ignore_sign=True):
2 columns = decoder.T.copy()
3 columns /= np.linalg.norm(columns, axis=1, keepdims=True) + 1e-12
4 similarities = true_features @ columns.T
5 if ignore_sign:
6 similarities = np.abs(similarities)
7 strongest_per_truth = similarities.max(axis=1)
8 return float(np.median(strongest_per_truth)), int((strongest_per_truth >= 0.90).sum())
9
10initial_median, initial_high_matches = direction_alignment(initial_W_dec)
11trained_median, trained_high_matches = direction_alignment(W_dec)
12print(f"median max |cosine|: {initial_median:.3f} -> {trained_median:.3f}")
13print(f"true directions at >= 0.90: {initial_high_matches} -> {trained_high_matches}")
14initial_signed, _ = direction_alignment(initial_W_dec, ignore_sign=False)
15trained_signed, _ = direction_alignment(W_dec, ignore_sign=False)
16print(f"median max signed cosine: {initial_signed:.3f} -> {trained_signed:.3f}")1median max |cosine|: 0.798 -> 0.799
2true directions at >= 0.90: 1 -> 2
3median max signed cosine: 0.695 -> 0.710Reconstruction MSE dropped by over (), yet median cosine alignment barely budged ()!
Sparse reconstruction is a proxy, not guaranteed recovery of generating features. These metrics alone don't identify a unique dictionary. Recovery can be identifiable under additional assumptions; this run hasn't demonstrated them. The maximum-match diagnostic also permits multiple true directions to match the same decoder column, and absolute cosine ignores the sign that a nonnegative latent can write. Keep the random-initialization baseline and signed diagnostic. In an LLM, ground-truth directions are usually unavailable, so use semantic counterexamples and causal tests together.
Key implementation choices in the NumPy trainer
- The generator combines 2–4 of 24 non-orthogonal directions in eight dimensions. This known construction lets us test recovery separately from reconstruction.
- Nonzero decoder columns are renormalized after each step. Otherwise, shrinking and expanding decoder columns reciprocally can preserve reconstruction while reducing this unweighted L1 penalty. A decoder-norm-weighted penalty is another way to address that reparameterization.[7]
- The subgradient of the L1 term () is added to the backward gradient before the ReLU step.
- About of slots fire on training samples, and on held-out samples. That's less dense than using every slot, but much denser than the generator's 2–4 active directions. It doesn't establish production-level sparsity or recovered concepts.

Practical training details and pitfalls
These failure modes can appear in small toys as well as large-model training. Their prevalence depends on data, architecture, and optimization:
| Architecture / Mechanism | How it works | Why it matters |
|---|---|---|
| TopK SAEs | Keeps at most nonzero candidates; main objective omits L1.[5] | Removes direct L1 magnitude shrinkage; improves the tested reconstruction/sparsity tradeoff, without guaranteed feature purity. |
| Gated SAEs | Binary detection and nonnegative magnitude paths share projection directions; gate pre-activations receive L1 and an auxiliary task.[6] | Separates detection from direct magnitude regularization; the hard gate and auxiliary loss are part of the recipe. |
| JumpReLU SAEs | Learned positive thresholds, L0 penalty, and custom windowed threshold gradients.[7] | Better than Gated and comparable to or slightly better than TopK on the paper's Gemma 2 9B sites; not a universal winner. |
| AuxK Loss | Selected inactive latents reconstruct the main reconstruction error.[5] | Supplies gradients to otherwise unused capacity; monitor revival and fidelity rather than assuming success. |
| Decoder column normalization | Enforces after updates. | Closes the scaling loophole where the optimizer inflates decoder weights to artificially depress latent L1 loss. |
Dead latents: tracking and revival
A dead latent is inactive according to a specified monitoring rule. Gao's recipe uses no activation over 10 million tokens; a low but nonzero rate is a different criterion.[5] Four observations with no firing only show "not seen in this window," not permanent death. Check rare-domain coverage, observation length, and the rule before resampling a potentially useful rare feature.
Why do latents die? An unlucky initialization, strong sparsity pressure, or competing latents that consistently fire earlier can leave a feature with negative bias and zero gradients.
Two primary mechanisms revive dead latents:
- Anthropic resampling (2023): At steps 25k/50k/75k/100k, the published recipe checks inactivity over the preceding 12.5k steps. It samples input vectors with probabilities proportional to squared per-input SAE loss. The decoder receives the normalized sampled input, not its reconstruction residual. The encoder uses the same direction at the average alive-row norm; its bias and modified Adam state are reset. Resampling can cause loss spikes and isn't mandatory in every recipe.[1]
- AuxK (Gao, 2024): Select promising dead latents to predict the main error with an auxiliary decoder pass. The paper typically uses weight , alongside tied initialization. Its 16M-latent run reported 7% dead latents; this is a measured recipe result, not a guarantee for any dictionary or dataset.[5]
1latent_batches = [
2 [1.2, 0.0, 0.0, 0.0],
3 [0.7, 0.3, 0.0, 0.0],
4 [0.0, 0.0, 0.0, 0.0],
5 [0.0, 0.0, 0.0, 0.5],
6]
7
8rates = [
9 sum(row[index] > 0 for row in latent_batches) / len(latent_batches)
10 for index in range(len(latent_batches[0]))
11]
12never_observed = [index for index, rate in enumerate(rates) if rate == 0.0]
13print("firing rates:", [round(rate, 2) for rate in rates])
14print("never observed in this window:", never_observed)
15
16assert never_observed == [2]1firing rates: [0.5, 0.25, 0.0, 0.25]
2never observed in this window: [2]Non-identifiability, absorption, and splitting
Evaluating SAEs requires watching out for three subtle structural failure modes:
| Failure mode | What happens | Practical risk |
|---|---|---|
| Metrics don't identify a unique dictionary | Independently trained SAEs can reach similar MSE and L0 with different directions. | Test seed stability, matched features, and circuit effects; don't assume identical indices or labels transfer. |
| Feature absorption | A specific latent absorbs instances of a general concept (e.g. "Paris" latent fires, but generic "capital city" latent stays dark).[16] | Top-activating examples look pure (high precision), but recall across diverse prompts is secretly broken. |
| Feature splitting | A single human concept splits across multiple near-duplicate latents as dictionary width increases. | Steering one latent only moves part of the target behavior; automated dashboards become fragmented. |
Absorption can improve sparsity without changing reconstruction. In a toy hierarchy, a "Paris" instance always includes "capital city." A combined decoder direction can write both while the general detector stays silent:
1parent = [1.0, 0.0]
2child = [0.0, 1.0]
3target = [1.0, 1.0] # general and specific concepts are both present
4interpretable_code = [1.0, 1.0]
5absorbed_code = [0.0, 1.0]
6combined_direction = [a + b for a, b in zip(parent, child)]
7reconstruction = [absorbed_code[0] * parent[i]
8 + absorbed_code[1] * combined_direction[i] for i in range(2)]
9assert reconstruction == target
10print("active latents:", sum(v != 0 for v in interpretable_code),
11 "->", sum(v != 0 for v in absorbed_code))
12print(f"general latent: {absorbed_code[0]:.1f}; general coordinate: {reconstruction[0]:.1f}")1active latents: 2 -> 1
2general latent: 0.0; general coordinate: 1.0This is L0 bookkeeping, not a trained SAE. With unit-normalized combined direction , its coefficient becomes ; support remains one. The child direction now writes general information too. Chanin's study investigates absorption in first-letter tasks and shows why sweeping width or sparsity alone needn't fix it.[16]
Applications to safety and governance
Safety evaluations require the highest standard of evidence. Templeton et al. demonstrated that SAEs can surface safety-relevant features in Claude 3 Sonnet, isolating directions for scam emails, code exploits, bias, and sycophantic agreement.[8]
Some SAE features generalize across languages or modalities in particular studies.[8] Others track token strings, formatting, or only part of a concept. A French translation or base64 encoding is a useful test of a proposed semantic label, not a guarantee of invariant activation. Absorption also makes a silent latent weak evidence that a concept is absent.
Return to the imagined password-reset refusal. Compare benign and harmful credential-related prompts, vary urgency separately, and track proposed features at matched hooks. Test whether edits change a chosen refusal metric and whether legitimate task completion improves. A change in refusal wording alone doesn't establish corrected security judgment or preserved safeguards.
What changed in 2026?
Checked September 22, 2026. Heap et al.'s ICLR 2026 study compared residual-stream SAEs on trained, randomized, and initialization checkpoints from the Pythia family. Aggregate automated interpretability scores and reconstruction metrics often looked similar, especially at larger sizes. This challenges those metrics as stand-alone evidence of learned computation; it doesn't show that every SAE feature is meaningless. Add appropriate null-model baselines to metric-driven claims.[17]
Anthropic's May 7, 2026 natural language autoencoders instead learn an activation → text → activation round trip. They can help generate auditing hypotheses, but their explanations can hallucinate and their verbalizer can infer beyond the stored activation. They don't supply a feature circuit or a verified transcript of reasoning. Corroborate claims with independent methods; readable explanations inherit the same proxy-versus-mechanism problem.[18]
Limits and caution
SAEs provide deep mechanistic visibility, but have clear operational limits. Four fundamental constraints stop us from treating them as a solved map of model cognition:
- Reconstruction error matters: Substituting reconstructions can degrade model performance. Cunningham et al.'s layer-2 Pythia-70M experiment raised Pile perplexity from 25 to 40; it isn't a universal SAE penalty.[2]
- Coverage is incomplete: The Sonnet study explicitly says its dictionary doesn't capture all features and has no reliable total-feature count.[8] Reconstruction errors, rare contexts, and features spread across layers can leave relevant computation unexplained. A large dictionary isn't a coverage certificate.
- Feature splitting can change the units: The Sonnet study observed concepts splitting into narrower sub-features as dictionary size grew.[8] Individual feature indices shouldn't be treated as immutable atomic units.
- Labels are hypotheses: Top activations suggest a name, but lower activations and semantic counterexamples test its precision and recall. Causal experiments test the specified direction's role; they don't certify a universal semantic label, a unique dictionary, or complete understanding.
What to check before moving on
- Distinguish finite-point JL distance preservation from a claim about features learned by a transformer; calculate accumulated interference under a coherence bound.
- Explain how mixed coordinates can arise and why polysemanticity alone doesn't establish superposition.
- Compare tied weights, tied initialization, untied detection/reconstruction, and optional learned-reference subtraction.
- Identify the L1 shrinkage problem and explain how TopK, Gated, and JumpReLU SAEs eliminate or mitigate it.
- Report activation MSE, L0, raw downstream losses, and baseline-relative Loss Recovered without treating any one metric as feature recovery.
- Diagnose dead latents and contrast Anthropic resampling with OpenAI's AuxK auxiliary loss.
- Explain the difference between naive ablation and error-preserving ablation, identifying why discarding the residual creates false causal claims.
- Trace CLT input/output layers and explain local attribution graphs' frozen attention, normalization denominators, error nodes, and omitted mechanisms.
Practice checkpoints
Why can't we just inspect weights or individual neuron activations?
Answer
Under the superposition hypothesis, overlapping feature directions make single neurons respond to several concepts. Toy models demonstrate this mechanism; it isn't a complete explanation of every LLM neuron. SAE latents offer alternative candidate units, but they also require empirical validation.
What is the difference between an L1 SAE and a TopK SAE?
Answer
An L1 SAE penalizes latent magnitude, with sums or means depending on the convention. Nonnegative TopK retains the largest candidates, but fewer than can be positive. It controls support size directly; training still needs a recipe for unused latents and the non-smooth selection step.[5]
How does an SAE enable activation steering?
Answer
An SAE decoder column gives a direction to write at its matching hook. Add a scaled copy and measure the behavioral effect. Its latent coefficient is a detector readout, not a guarantee that a named concept is present; editing that coefficient in a cached decomposition needn't fix the re-encoded value.
What causes dead features?
Answer
Dead features are latents that never activate over a specified monitoring window. Persistent inactivity can result from sparsity pressure, initialization, competition, or missing contexts. Rare activity alone isn't a defect. Large SAE recipes monitor this distinction and use resampling or auxiliary losses to revive unused capacity.[5]
How do feature circuits help explain model algorithms?
Answer
A feature circuit connects human-readable features across layers, then tests whether those edges matter for behavior. The output isn't merely a label like "credential safety." The useful artifact is a candidate causal graph: early features activate, later features combine them, and interventions on selected nodes should shift the model's answer in a predictable way before you trust the explanation.
Common failures and fixes
- Symptom: A raw coordinate responds to unrelated contexts. Possible cause: Mixed representations; this alone doesn't prove an overcomplete dictionary. Check: Compare candidate directions, their activation ranges, and controlled effects.
- Symptom: MSE falls but labels stay messy. Possible causes: Dense codes, absorption, splitting, or poor labels. Check: Sweep width/sparsity with matched data, test recall and seeds, and report downstream loss. No universal rule resolves these problems; tighter sparsity can worsen absorption.
- Symptom: A feature label sounds convincing, but ablation changes nothing. Possible causes: The label is correlational, the edit magnitude is ineffective, or parallel pathways compensate. Fix: Check the hook point, edit magnitude, and alternative pathways before interpreting the null result.
- Symptom: Many slots never fire in the observation window. Possible causes: Competition, initialization, sparsity, or missing rare contexts. Check: State the window and sample coverage before trying a revival recipe; measure its side effects.
- Symptom: Steering works on a demo but damages other prompts. Possible causes: Overlap, scale, wrong hook, or changed attention. Check: Match the checkpoint, hook, and token positions, then sweep edits and controls on held-out tasks. An orthogonal linear probe staying fixed doesn't guarantee downstream capabilities stay fixed.
Practice projects
- CPU first: Sweep the toy's sparsity coefficient and training seed. Record held-out MSE/FVU, L0, signed alignment, and random-initialization alignment. Try to improve recovery rather than only reconstruction.
- Inspect an existing dictionary: Use SAELens with its matching model/hook. Sample top, middle, low, and non-activating contexts; test a label's precision and recall. Its current inference interface supports generic PyTorch activation extraction, not only TransformerLens.
- Train a dictionary if compute allows: Freeze a small model and stream activations from a stated corpus. Plan memory: 200k float32 vectors of width 4,096 alone occupy about 3.05 GiB at one hook, before model, SAE, gradients, and optimizer state. Compare L1/TopK under matched width, support, data, and compute; a few hundred thousand tokens don't guarantee coverage.
- Test an algorithm: Choose a model-specific comparison task such as greater-than year completion. Find candidate SAE components, preserve error, and test both targeted and control edits. Recovery of a known head-level circuit in SAE coordinates is a research goal, not a promised outcome.[12]
- Trace a prompt: Follow circuit-tracer's tutorial with a supported model and pretrained transcoders, then test one graph prediction. A standard SAE alone isn't a CLT. Record the graph's frozen paths, error nodes, edit positions, and original-model behavior.
Tool compatibility checked September 22, 2026: SAELens documents a v6 training refactor. TransformerLens now recommends TransformerBridge; its README states that the legacy HookedTransformer.from_pretrained API was removed in 4.0. Record installed versions and consult their migration guides before copying older notebooks. Weight folding/centering can change activation coordinates, so match a dictionary's training conventions as well as its model name.
Validate one feature claim
Take one proposed SAE feature through a complete causal audit. Inspect top activations on held-out text, state a behavioral prediction, ablate or steer the decoder direction, and run matched negative controls. Save reconstruction damage and downstream behavior in an audit table beside the intervention result.
If the effect doesn't replicate across held-out prompts or the SAE replacement degrades the model too much, keep the label as an unconfirmed hypothesis.