Grimoire
Sheet
pathinterpretability/features
difficultyadvanced
timeO(d·m)
described2023
revised4d ago

Sparse Autoencoders

Fit an overcomplete dictionary to a layer’s activations under a sparsity penalty, on the hypothesis that features are more numerous than dimensions and superposed.

Theory

The superposition hypothesis holds that a network represents more features than it has dimensions by placing them in almost-orthogonal directions and relying on their sparsity to keep interference low. If true, the activations are sparse combinations of an overcomplete dictionary, and that dictionary is recoverable.

xb+ifi(x)di,f(x)=ReLU(We(xb)+be)x \approx b + \sum_i f_i(x)\, d_i, \qquad f(x) = \mathrm{ReLU}\bigl( W_e (x - b) + b_e \bigr)
eq. 1 — encode, then reconstruct from the dictionary
L=xx^22+λifi(x)di2L = \bigl\| x - \hat{x} \bigr\|_2^2 + \lambda \sum_i f_i(x)\, \| d_i \|_2
eq. 2 — L1 scaled by decoder normBricken et al.

Weighting the L1 by the decoder column norm is not cosmetic: without it the penalty is trivially gamed by shrinking ff and growing dd proportionally. The alternative is to constrain the columns to unit norm and project after every step.

Shrinkage

An L1 penalty biases every active feature toward zero, so reconstructions are systematically too small. Top-k and JumpReLU encoders replace the penalty with a hard sparsity constraint, which removes the bias at the cost of a non-differentiable activation that needs a straight-through estimator.

Implementation
python · torch ≥ 2.1
import torch
from torch import Tensor, nn


class TopKSAE(nn.Module):
    """Top-k encoder: sparsity is a constraint, not a penalty,
    so there is no shrinkage to correct for."""

    def __init__(self, d_model: int, d_hidden: int, k: int = 32):
        super().__init__()
        self.enc = nn.Linear(d_model, d_hidden)
        self.dec = nn.Linear(d_hidden, d_model, bias=False)
        self.pre_bias = nn.Parameter(torch.zeros(d_model))
        self.k = k

    def forward(self, x: Tensor) -> tuple[Tensor, Tensor]:
        centred = x - self.pre_bias
        acts = self.enc(centred)
        vals, idx = acts.topk(self.k, dim=-1)
        f = torch.zeros_like(acts).scatter_(-1, idx, vals.relu())
        return self.dec(f) + self.pre_bias, f

    @torch.no_grad()
    def normalise_decoder_(self) -> None:
        self.dec.weight /= self.dec.weight.norm(dim=0, keepdim=True)

Subtracting a learned pre-bias before encoding and adding it back after decoding lets the dictionary model deviations from the mean activation rather than the mean itself — without it the first few features are spent representing a constant. Call normalise_decoder_ after each optimiser step, and remove the component of the gradient parallel to each column beforehand if you want the step to be exact rather than approximately tangent.

Dictionary
16 384
L0
32
Dead latents
3.1%
residual stream, layer 12 of 24, d = 1024, 400M tokens
Related
References
[1]Bricken et al. — Towards Monosemanticity: Decomposing Language Models With Dictionary Learning (2023)transformer-circuits
[2]Elhage et al. — Toy Models of Superposition (2022)transformer-circuits
[3]Gao et al. — Scaling and Evaluating Sparse Autoencoders (2024)arXiv:2406.04093
[4]Rajamanoharan et al. — Jumping Ahead: Improving Reconstruction Fidelity with JumpReLU SAEs (2024)arXiv:2407.14435