Sparse Autoencoders › Code Implementation

Michael BrenndoerferFebruary 16, 20269 min read
Language AI HandbookAll chapters
Part 59 of 67Interpretability
Chapter 453Sparse Autoencoders › Code Implementation
Reading section 8 of 15 · Code Implementation · 9 minSection 8 of 15 · 9 min

Book contents

Section 8 of 15 · 9 min read

Sparse Autoencoders › Code ImplementationOne section · Sections in this chapter8 / 15 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's implement a minimal SAE and train it on synthetic activation data that simulates the superposition of independent features. This implementation follows the design used in Anthropic's open-source SAE research, simplified for clarity.

Setup and Data Generation

In[5]:
Code
import numpy as np
import torch

# Simulation parameters
d_model = 16  # Dimensionality of model activations (small for illustration)
n_features_true = (
    64  # True number of underlying features (much larger than d_model)
)
n_samples = 50000  # Number of activation vectors to generate
feature_prob = 0.05  # Probability each feature is active on a given token

# Generate random "ground truth" feature directions in d_model space
# These simulate the directions that the language model uses to represent features
feature_directions = torch.randn(d_model, n_features_true)
feature_directions = feature_directions / feature_directions.norm(
    dim=0, keepdim=True
)

# Generate sparse feature activations
# Each row is a token; each column is a feature (mostly zeros)
feature_acts = torch.zeros(n_samples, n_features_true)
active_mask = torch.rand(n_samples, n_features_true) < feature_prob
feature_acts[active_mask] = (
    torch.rand(active_mask.sum()) * 2 + 0.5
)  # Positive magnitudes

# Construct "model activations" as sum of active feature directions + noise
activations = feature_acts @ feature_directions.T  # (n_samples, d_model)
activations = activations + 0.05 * torch.randn_like(activations)
Out[6]:
Console
Activation tensor shape: torch.Size([50000, 16])
True features: 64, Model dimension: 16
Average features active per token: 3.20
Sparsity: 95% of features zero per token

We generate 50,000 synthetic activation vectors where each is a superposition of a small number of the 64 true features, compressed into a 16-dimensional space. This simulates the superposition hypothesis at small scale: more features than dimensions, with each token using only a few of those features at once. Each true feature has a probability of 0.05 of being active on any given token, so on average about 3.2 features are active simultaneously (roughly 5% of 64). This is deliberately a challenging setting: the 64 features are packed into 16 dimensions, and the interference between simultaneously active features creates overlapping activation patterns.

SAE Model Definition

In[7]:
Code
import torch
import torch.nn as nn


class SparseAutoencoder(nn.Module):
    def __init__(self, d_model: int, d_sae: int):
        """
        d_model: dimension of model activations (input/output)
        d_sae: dictionary size (> d_model for overcomplete dictionary)
        """
        super().__init__()
        self.d_model = d_model
        self.d_sae = d_sae

        # Pre-encoder bias to center activations
        self.b_pre = nn.Parameter(torch.zeros(d_model))

        # Encoder: maps from model space to sparse feature space
        self.W_enc = nn.Parameter(torch.randn(d_model, d_sae) * 0.01)
        self.b_enc = nn.Parameter(torch.zeros(d_sae))

        # Decoder: maps from sparse feature space back to model space
        self.W_dec = nn.Parameter(torch.randn(d_sae, d_model) * 0.01)

    def encode(self, x: torch.Tensor) -> torch.Tensor:
        """Compute sparse feature activations via ReLU encoder."""
        x_centered = x - self.b_pre
        pre_activation = x_centered @ self.W_enc + self.b_enc
        return torch.relu(pre_activation)

    def decode(self, z: torch.Tensor) -> torch.Tensor:
        """Reconstruct model activations from sparse features."""
        return z @ self.W_dec + self.b_pre

    def forward(self, x: torch.Tensor):
        z = self.encode(x)
        x_hat = self.decode(z)
        return x_hat, z

    def normalize_decoder(self):
        """Enforce unit norm on decoder columns (feature dictionary vectors)."""
        with torch.no_grad():
            norms = self.W_dec.norm(dim=1, keepdim=True).clamp(min=1.0)
            self.W_dec.data /= norms
Out[8]:
Console
SAE architecture: 16d -> 128d -> 16d
Expansion factor: 8x
Total parameters: 4,240
  W_enc: torch.Size([16, 128]) = 2,048 params
  W_dec: torch.Size([128, 16]) = 2,048 params
  b_enc: 128 params, b_pre: 16 params

The expansion factor of 8x gives us 128 dictionary features to explain patterns in 16-dimensional activation space. Notice that the encoder and decoder are separate matrices (not tied weights), which allows the encoder to learn a different geometry for feature detection than the decoder uses for reconstruction. The encoder rows are optimized to respond strongly to their target feature direction and weakly to others, while the decoder columns are optimized to reconstruct activations accurately when linearly combined. In real language models, expansion factors of 4x to 64x are common; larger factors trade computational cost for the ability to represent more fine-grained features.

Training Loop

In[9]:
Code
import torch.optim as optim


def train_sae(
    sae: SparseAutoencoder,
    activations: torch.Tensor,
    lambda_l1: float = 0.001,
    n_epochs: int = 20,
    batch_size: int = 512,
    lr: float = 2e-4,
):
    optimizer = optim.Adam(sae.parameters(), lr=lr)
    n_batches = len(activations) // batch_size

    history = {"total_loss": [], "recon_loss": [], "l1_loss": [], "mean_l0": []}

    for epoch in range(n_epochs):
        # Shuffle data each epoch
        perm = torch.randperm(len(activations))
        epoch_total, epoch_recon, epoch_l1, epoch_l0 = 0.0, 0.0, 0.0, 0.0

        for i in range(n_batches):
            batch = activations[perm[i * batch_size : (i + 1) * batch_size]]

            optimizer.zero_grad()
            x_hat, z = sae(batch)

            # Reconstruction loss: mean squared error per sample
            recon_loss = ((batch - x_hat) ** 2).sum(dim=-1).mean()

            # L1 sparsity penalty on feature activations
            l1_loss = z.abs().sum(dim=-1).mean()

            loss = recon_loss + lambda_l1 * l1_loss
            loss.backward()
            optimizer.step()

            # Normalize decoder after each step to maintain unit-norm columns
            sae.normalize_decoder()

            # Track effective L0: count of non-zero features per token
            l0 = (z > 0).float().sum(dim=-1).mean().item()
            epoch_total += loss.item()
            epoch_recon += recon_loss.item()
            epoch_l1 += l1_loss.item()
            epoch_l0 += l0

        history["total_loss"].append(epoch_total / n_batches)
        history["recon_loss"].append(epoch_recon / n_batches)
        history["l1_loss"].append(epoch_l1 / n_batches)
        history["mean_l0"].append(epoch_l0 / n_batches)

    return history


history = train_sae(
    sae, activations, lambda_l1=0.01, n_epochs=30, batch_size=512
)
Out[10]:
Console
Final reconstruction loss: 0.0421
Final mean L0 (active features): 69.00
Sparsity: 46.1% of features zero per token
Variance explained (R^2): 0.9948

The SAE learns to reconstruct activation vectors with high fidelity, but this minimal setup does not yet achieve a sparse code. The R2R^2 score above 0.95 indicates that the learned dictionary captures most of the activation-space structure, while the mean L0L_0 remains well above the desirable range of 5-15 active features out of 128. This combination is a useful warning: excellent reconstruction alone does not guarantee interpretable sparsity. The 128-feature dictionary is larger than the 64 true features in the synthetic data, giving the SAE extra capacity that the L1L_1 penalty must constrain.

The following figure shows training progress across epochs, tracking reconstruction loss and mean effective L0L_0 simultaneously. In a well-trained SAE, both curves should decrease and then plateau, indicating convergence.

Out[11]:
Visualization
Line plot of reconstruction loss decreasing and plateauing over 30 training epochs.
SAE reconstruction loss over 30 training epochs. The loss drops sharply in early epochs as the decoder learns to approximate activations, then plateaus as the SAE converges to a stable solution. The fast initial learning reflects the encoder quickly orienting toward the strongest feature directions; the slower later phase refines feature boundaries.
Line plot of mean L0 rising early and then gradually decreasing while remaining high after 30 training epochs.
Mean effective L0 (average number of active features per token) over training. Many features activate during the early reconstruction phase, after which the L1 penalty gradually reduces the active count. The curve remains high at epoch 30, showing that this minimal SAE has not yet reached the desired sparse regime despite its strong reconstruction quality.

Evaluating Feature Sparsity and Dictionary Utilization

After training, it is important to check that the learned features are used. A common failure mode is feature collapse: some features become very popular (active for most tokens) while others are never activated at all. The distribution of feature activation frequencies tells you how well the dictionary is utilized.

In[12]:
Code
# Compute activation frequency for each feature across the dataset
with torch.no_grad():
    all_z = []
    for i in range(0, len(activations), 512):
        batch = activations[i : i + 512]
        _, z_batch = sae(batch)
        all_z.append(z_batch)
    all_z = torch.cat(all_z, dim=0)  # (n_samples, d_sae)

# Frequency: fraction of tokens where each feature activates at all
feature_freq = (all_z > 0).float().mean(dim=0)  # (d_sae,)
dead_features = (feature_freq == 0).sum().item()
active_features = (feature_freq > 0).sum().item()
Out[13]:
Console
Total dictionary features: 128
Active features (fire on at least one token): 128
Dead features (never activate): 0
Median activation frequency: 0.5051
Max activation frequency: 0.8601
Features active on >10% of tokens: 128

Dead features represent wasted capacity: they contribute nothing to reconstruction and have not found a useful region of activation space. In practice, dead features are addressed with techniques like "neuron resampling" (reinitializing dead feature vectors toward high-loss data points) or simply using a larger dictionary. Features active on more than 10% of tokens are "ultra-common" features and often correspond to very general properties (e.g., "token is part of English text" rather than anything semantically specific). The ideal feature frequency distribution is a heavy-tailed distribution where most features fire rarely (specific concepts) and a few fire moderately often (general properties).

The histogram below shows the distribution of feature activation frequencies across the dictionary. A healthy SAE has most features firing on a small fraction of tokens, with a long tail of very specific features.

Out[14]:
Visualization
Histogram showing most learned SAE features activating at moderate-to-high frequencies, demonstrating an under-sparse dictionary.
Distribution of activation frequencies across all 128 dictionary features after training. In this minimal run, all features are active and most fire on a moderate-to-large fraction of tokens rather than forming the desired low-frequency long tail. The concentration at higher frequencies is evidence that the learned code remains under-sparse even though reconstruction quality is high.