Skip to content
GKgkml.dev
← LLM mechanisms

Part 5 · Observational mech interp · module 5 of 5 · 21 min

Identifying circuits and components

From individual units to mechanisms — attention heads, sparse probing, latent vs. manifest variables, sparse autoencoders and generalised eigendecomposition.

The essence

A circuit is a small set of components that together implement a behaviour — a few attention heads and MLP directions whose composition explains something the model does. Finding one means moving past 'this unit correlates with X' to 'these units, in this order, compute X', and the tools here exist because superposition means the units you want are not the neurons you have.

Why it matters

Circuits are the field's unit of real explanation, and sparse autoencoders are its current best answer to superposition — the reason the frontier labs invest in them. This module is also where the honest limits show most clearly: the methods work, they do not fully scale, and knowing precisely where they stop is part of knowing the subject.

Topics covered (13)
  1. 01What a circuit is in a deep network
  2. 02Isolating and investigating individual attention heads
  3. 03Laminar profiles of attention head weights
  4. 04Whether circuits are clustered in low-dimensional space
  5. 05Sparse probing: theory and code
  6. 06Challenges with sparse logistic regression on large data
  7. 07Latent vs. manifest variables
  8. 08Sparse autoencoders: theory and code
  9. 09What an SAE trained on GPT-2 actually finds
  10. 10Laminar profiles of autoencoder sparsity
  11. 11Non-orthogonal latent components via eigendecomposition
  12. 12Generalised eigendecomposition to separate categories in the MLP
  13. 13GED for category isolation across layers

What a circuit is

A circuit is a subgraph of the model — specific heads, specific MLP directions, in specific layers — plus an account of how information flows through them to produce a behaviour. The canonical example is indirect object identification: a set of heads that identify the repeated name, a set that move information about it, and a set that suppress the duplicate, composing into an algorithm you can state in words.

The bar for calling something a circuit is that the account makes predictions. If you claim head 9.6 moves the subject's identity to the final position, then ablating it should break exactly that, patching it from a different prompt should transfer exactly that, and nothing else should change much. An account that does not generate such predictions is a description, not a circuit.

Isolating attention heads
import torch, numpy as np

@torch.no_grad()
def attention_patterns(model, tok, text):
    """Attention weights for every layer and head: [layers][B, heads, T, T]."""
    ids = tok(text, return_tensors="pt")
    out = model(**ids, output_attentions=True)
    pieces = tok.convert_ids_to_tokens(ids["input_ids"][0])
    return out.attentions, pieces

def classify_heads(attentions):
    """Cheap structural summaries that identify well-known head types."""
    rows = []
    for L, A in enumerate(attentions):
        a = A[0]                                  # [heads, T, T]
        T = a.size(-1)
        for H in range(a.size(0)):
            m = a[H]
            rows.append({
                "layer": L, "head": H,
                # Attends to itself: often a no-op / null head.
                "self": float(m.diagonal().mean()),
                # Attends one position back: 'previous token' head.
                "prev": float(m.diagonal(-1).mean()) if T > 1 else 0.0,
                # Attends to position 0: attention sink, extremely common.
                "first": float(m[:, 0].mean()),
                # Entropy: low = focused on few positions, high = diffuse.
                "entropy": float(-(m * m.clamp_min(1e-12).log()).sum(-1).mean()),
            })
    return rows

# Also worth profiling: the norm of each head's OUTPUT contribution, which is
# what actually reaches the residual stream. A head with a sharp pattern but a
# tiny output norm is not doing much, and the pattern alone would mislead you.
def head_output_norms(model, tok, text, layer):
    captured = {}
    h = model.transformer.h[layer].attn.register_forward_hook(
        lambda m, i, o: captured.__setitem__("o", (o[0] if isinstance(o, tuple) else o).detach()))
    model(**tok(text, return_tensors="pt"))
    h.remove()
    dim = model.config.n_embd
    n_heads = model.config.n_head
    per_head = captured["o"][0].view(-1, n_heads, dim // n_heads)
    return per_head.norm(dim=-1).mean(0)          # one number per head

Latent and manifest variables

This distinction, borrowed from psychometrics, is the conceptual key to everything else in this module. A manifest variable is something you can measure directly — a neuron's activation. A latent variable is something you posit to explain the pattern among manifest variables — a feature the model represents.

The relationship is many-to-many. One feature is spread across many neurons; one neuron participates in many features. So neurons are the wrong unit of analysis, which is precisely why single-neuron interpretability keeps producing polysemantic results. Every method that follows — sparse probing, autoencoders, eigendecomposition — is an attempt to recover latents from manifests.

Sparse probing

An ordinary linear probe uses all the neurons, which tells you the information is present but not where. An L1-penalised probe is pushed toward using few, so the surviving non-zero weights localise it.

The practical difficulties are real. L1 selection is unstable when features are correlated — and neurons are heavily correlated — so small changes in the data change which neurons are selected while accuracy barely moves. The remedy is stability selection: refit on many bootstrap subsamples and keep only neurons chosen in most of them. Elastic net, which mixes L1 and L2, is also better behaved than pure L1 on correlated features.

And the interpretation must stay careful. A sparse probe finding five sufficient neurons does not mean the model uses only those five. It means five suffice to decode, which is a claim about information availability, not about mechanism.

Sparse probing with stability selection
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score

def sparse_probe(acts, labels, C=0.01):
    """L1 penalty drives most coefficients to exactly zero."""
    clf = LogisticRegression(penalty="l1", C=C, solver="liblinear", max_iter=5000)
    clf.fit(acts, labels)
    nz = np.flatnonzero(clf.coef_[0])
    return nz, clf.coef_[0][nz], cross_val_score(clf, acts, labels, cv=5).mean()

def stability_selection(acts, labels, C=0.01, n_boot=100, frac=0.7):
    """Which neurons are selected consistently, not just once?"""
    rng = np.random.default_rng(0)
    counts = np.zeros(acts.shape[1])
    n = int(frac * len(labels))
    for _ in range(n_boot):
        idx = rng.choice(len(labels), n, replace=False)
        nz, _, _ = sparse_probe(acts[idx], labels[idx], C)
        counts[nz] += 1
    return counts / n_boot

freq = stability_selection(activations, labels)
stable = np.flatnonzero(freq > 0.8)
print(f"stable neurons: {stable.tolist()}")

# The accuracy-vs-sparsity curve is the informative output: sweep C and plot
# cross-validated accuracy against the number of non-zero weights. A sharp
# elbow means the information really is concentrated; a gradual slope means it
# is distributed and no small set is privileged.
for C in [0.001, 0.01, 0.1, 1.0]:
    nz, _, acc = sparse_probe(activations, labels, C)
    print(f"C={C:<6} neurons={len(nz):4}  acc={acc:.3f}")

Sparse autoencoders

An SAE is trained to reconstruct a layer's activations through a much wider bottleneck — often 8 to 64 times the layer width — with a sparsity penalty forcing only a few of those hidden units to be active for any input.

The rationale follows directly from superposition. If the model packs many sparse features into d dimensions, then an overcomplete dictionary with a sparsity constraint has exactly the right inductive bias to unpack them. Empirically the resulting features are dramatically more monosemantic than neurons: instead of a unit that fires for DNA sequences and legal text and Python, you get separate features for each.

The loss is reconstruction error plus a sparsity term. Modern variants replace the L1 penalty with a top-k constraint, which sidesteps the shrinkage L1 induces and removes a hyperparameter. Two failure modes are worth knowing: dead features that never activate, which waste dictionary capacity and need resampling, and feature splitting, where a wider SAE divides one coherent feature into several near-duplicates — so a bigger dictionary is not automatically better.

A sparse autoencoder, trained on activations
import torch, torch.nn as nn, torch.nn.functional as F

class SparseAutoencoder(nn.Module):
    def __init__(self, d_in, expansion=16, l1=1e-3):
        super().__init__()
        d_hidden = d_in * expansion            # overcomplete: the whole point
        self.enc = nn.Linear(d_in, d_hidden)
        self.dec = nn.Linear(d_hidden, d_in, bias=False)
        self.pre_bias = nn.Parameter(torch.zeros(d_in))
        self.l1 = l1
        # Unit-norm decoder columns, or the model shrinks features and inflates
        # decoder weights to cheat the sparsity penalty.
        with torch.no_grad():
            self.dec.weight /= self.dec.weight.norm(dim=0, keepdim=True)

    def forward(self, x):
        centred = x - self.pre_bias
        f = F.relu(self.enc(centred))          # sparse feature activations
        recon = self.dec(f) + self.pre_bias
        return recon, f

    def loss(self, x):
        recon, f = self(x)
        mse = (recon - x).pow(2).sum(-1).mean()
        sparsity = f.abs().sum(-1).mean()
        return mse + self.l1 * sparsity, mse, (f > 0).float().sum(-1).mean()

sae = SparseAutoencoder(d_in=768, expansion=16)
opt = torch.optim.Adam(sae.parameters(), lr=1e-3)

for batch in activation_loader:                # batch: [B, 768]
    total, mse, l0 = sae.loss(batch)
    opt.zero_grad(); total.backward()
    # Re-normalise decoder columns after every step.
    with torch.no_grad():
        sae.dec.weight /= sae.dec.weight.norm(dim=0, keepdim=True)
    opt.step()

print(f"mse {mse.item():.4f}  active features per input {l0.item():.1f}")

# Interpreting a feature: find its top-activating corpus examples, exactly as
# with a neuron — but now they usually share one clear property. Resample any
# feature that never fires, or that dictionary slot is wasted.

Generalised eigendecomposition

PCA finds directions of maximum variance, which is often not what you want — the dominant variance in a layer is usually frequency or the shared mean direction, not your contrast of interest. And PCA forces orthogonality, which is exactly wrong when the underlying features are only near-orthogonal.

Generalised eigendecomposition solves a better-posed problem: find the directions that maximise the ratio of variance under condition A to variance under condition B. It is the multivariate version of a t-test, and it returns a full ranked set of discriminating directions rather than one.

Two properties make it well suited here. The directions need not be orthogonal, matching superposition. And because it optimises a ratio, it automatically ignores directions with high variance in both conditions — the frequency and mean-direction components that swamp PCA. Regularising the covariance matrices is essential when you have fewer samples than dimensions, which is almost always.

GED to separate two categories, with a laminar profile
import numpy as np
from scipy.linalg import eigh

def ged(A, B, reg=0.05):
    """Directions maximising var(A)/var(B). A, B: [n_samples, n_dims]."""
    Ca = np.cov(A - A.mean(0), rowvar=False)
    Cb = np.cov(B - B.mean(0), rowvar=False)

    # Shrinkage regularisation — required when n_samples < n_dims, and it also
    # stops near-singular Cb producing enormous meaningless eigenvalues.
    Cb = (1 - reg) * Cb + reg * np.trace(Cb) / Cb.shape[0] * np.eye(Cb.shape[0])

    vals, vecs = eigh(Ca, Cb)              # generalised, not standard
    order = np.argsort(vals)[::-1]
    return vals[order], vecs[:, order]     # eigenvalue = variance ratio

vals, vecs = ged(acts_category_a, acts_category_b)
print("top variance ratios:", vals[:5].round(2))

top = vecs[:, 0]
proj_a, proj_b = acts_category_a @ top, acts_category_b @ top
print(f"separation: {abs(proj_a.mean() - proj_b.mean()) / (proj_a.std() + proj_b.std()):.2f}")

# The control that decides whether it is real: fit the direction on a training
# split and evaluate the separation OUT OF SAMPLE. GED will always find a
# separating direction in-sample when dimensions exceed samples.
def ged_out_of_sample(A, B, folds=5):
    scores = []
    for tr_a, te_a, tr_b, te_b in split_folds(A, B, folds):
        _, vecs = ged(tr_a, tr_b)
        w = vecs[:, 0]
        scores.append(abs((te_a @ w).mean() - (te_b @ w).mean()) /
                      ((te_a @ w).std() + (te_b @ w).std() + 1e-9))
    return float(np.mean(scores))

# Run it per layer for a laminar profile of where the category is separable.
for L in range(n_layers):
    print(L, round(ged_out_of_sample(a_by_layer[L], b_by_layer[L]), 3))

Note: scipy.linalg.eigh with two arguments solves the generalised problem Av = λBv. Passing one matrix silently gives you ordinary PCA instead.

Are circuits low-dimensional?

A recurring question is whether the components implementing a behaviour cluster in some low-dimensional subspace, or are scattered. The way to ask it is to collect the components you have identified — head output directions, SAE feature directions, GED directions — and examine the dimensionality of their span.

The answer so far is mixed and worth holding loosely. Some behaviours do concentrate in a few directions, which is what makes steering vectors work at all. Others are genuinely distributed, and reports of tidy low-dimensional circuits are partly a selection effect: the tidy cases are the publishable ones. Treating this as an open empirical question rather than a settled fact is the correct stance.

Worth remembering

  • Superposition means features are directions, not neurons — which is why sparse autoencoders are needed.
  • An SAE learns an overcomplete sparse dictionary of activation space; its features are far more monosemantic than neurons.
  • Generalised eigendecomposition finds directions maximising a variance ratio between two conditions, without requiring orthogonality.