Harvest a language model's activations once, train a grid of TopK sparse autoencoders in parallel on GPUs, and have Claude label and score the features they find.

Find features in a language model with sparse autoencoders

Code available on GitHub.

A language model’s neurons rarely mean one thing each. A single neuron fires on Korean text, on HTTP headers, and on the word “however”, because the model packs more concepts into its activations than it has dimensions. Interpretability teams undo this packing with a sparse autoencoder (SAE): a wide, one-layer autoencoder trained to rewrite each activation vector as a sparse sum of learned directions. Each direction is a feature, and many of them turn out to mean one thing a person can name.

Anthropic used SAEs to find millions of such features in Claude 3 Sonnet (Scaling Monosemanticity), OpenAI trained a 16-million-feature SAE on GPT-4 and introduced the TopK SAE this tutorial uses (Gao et al., 2024), and Google DeepMind released SAEs for every layer of Gemma 2 (Gemma Scope). The work has the same shape at every lab:

  • Harvest activations from a model over a large text corpus, at the layers you care about.
  • Train one SAE per layer and per size, a grid of GPU jobs that read the same activations.
  • Evaluate every SAE on how much of the activation it reconstructs, how sparse it is, how many of its features never fire, and how much worse the model gets with the SAE spliced in.
  • Label features automatically with an LLM, and score the labels, since nobody can read millions of features by hand (Bills et al., 2023; Paulo et al., 2024).

At scale, the harvest is the expensive part. It is many terabytes of activations, and you do not want to recompute it each time someone tries another SAE width. The grid is wide: one SAE per (layer, width, sparsity). And the labeling is hundreds of thousands of LLM calls that you do not want to pay for twice when a job retries.

This tutorial runs the whole loop on Pythia-70m, a small open model, with Claude as the labeler. By the end you will have:

  • a cached activation harvest, stored as one directory of shards per layer;
  • a grid of TopK SAEs, one GPU task per (layer, width), each training three sparsities at once;
  • variance explained, L0, dead features, and loss recovered for every SAE;
  • Claude’s explanation of a random sample of the features that fire often enough to test, each scored on held-out text;
  • a feature browser in the run’s report, sorted so the most convincing features come first;
  • the most interpretable SAE published as an artifact with a model card.
            flowchart LR
    H["harvest_activations<br/>GPU, cached"] -- "Dir per layer" --> T1["train_sae<br/>layer 1, 4096"]
    H --> T2["train_sae<br/>layer 1, 16384"]
    H --> T3["..."]
    H --> T6["train_sae<br/>layer 5, 16384"]
    T2 -- "sampled features" --> L1["label_features<br/>Claude, traced"]
    T6 --> L3["label_features<br/>Claude, traced"]
    T1 & T3 & L1 & L3 --> D["find_features<br/>report"]
    D --> A["artifact:<br/>best SAE"]

    classDef gpu fill:#2a78d633,stroke:#2a78d6,stroke-width:1.5px
    classDef llm fill:#e8790033,stroke:#e87900,stroke-width:1.5px
    classDef cpu fill:#94a3b826,stroke:#94a3b8,stroke-width:1px
    class H,T1,T2,T3,T6 gpu
    class L1,L3 llm
    class D,A cpu
        

What Flyte adds

Need Construct
Compute the activations once, and keep them cache="auto" on the harvest task, which returns a flyte.io.Dir per layer
Train a grid of SAEs at once One GPU task per (layer, width), launched together with asyncio.gather
Add a width without redoing the rest Caching on every task, salted with a version for each helper module: a re-run only trains the new grid cells
Never pay twice for the same Claude call @flyte.trace on each call, so a retried task replays the recorded answer
Watch training, then read the features A live report per trainer, and a feature browser tab in the driver’s report
Hand the result to other teams flyte.artifacts with a model card

Getting started

You need a Union cluster with NVIDIA L4 GPUs (or any CUDA GPU with 24 GB of memory), and an Anthropic API key stored as a secret. The code reads a secret named anthropic-api-key. To use a secret with another name, set ANTHROPIC_SECRET when you launch.

flyte create secret anthropic-api-key
git clone https://github.com/unionai/unionai-examples
cd unionai-examples/v2/tutorials/sparse_autoencoders
python find_features.py            # 3 layers x 2 widths, 8M tokens
python find_features.py --smoke    # 1 layer x 1 width, 0.5M tokens

The image and environments

One image serves every task: PyTorch, Transformers and Datasets for the model, and the Anthropic SDK for the labeler. The harvest and the trainers each take one GPU. Autointerp is API calls, so it runs on CPU, and its environment is the only one that holds the API key.

find_features.py
image = flyte.Image.from_debian_base(name="sae-features").with_pip_packages(
    "torch==2.9.1", "transformers==4.57.3", "datasets==4.4.2", "numpy<3", "anthropic==2.6.0",
)

# One GPU per task: the harvest, and every SAE trainer. sae.py is imported inside the tasks.
gpu_env = flyte.TaskEnvironment(
    name="sae-gpu",
    image=image,
    resources=flyte.Resources(cpu=6, memory="40Gi", gpu="L4:1", disk="80Gi"),
    cache="auto",
    include=["sae.py"],
)

# Autointerp is API calls, so CPU only. Only this environment holds the API key.
interp_env = flyte.TaskEnvironment(
    name="sae-autointerp",
    image=image,
    resources=flyte.Resources(cpu=2, memory="4Gi"),
    secrets=[flyte.Secret(key=ANTHROPIC_SECRET, as_env_var="ANTHROPIC_API_KEY")],
    cache="auto",
)

driver_env = flyte.TaskEnvironment(
    name="sae-driver",
    image=image,
    resources=flyte.Resources(cpu=2, memory="8Gi"),
    # The driver declares label_features when it calls it, so it needs the same secret name.
    env_vars={"ANTHROPIC_SECRET": ANTHROPIC_SECRET},
    depends_on=[gpu_env, interp_env],
)

Harvest the activations once

The harvest streams FineWeb and takes the first 128 tokens of each document as one context. A forward hook on each chosen layer of Pythia-70m-deduped records the residual stream, the output of that transformer block, for every token. The activations are saved as float16 shards, one directory per layer, so each trainer downloads only the layer it trains on. The token ids are saved too, so that every feature can later be shown in the text it fired on.

find_features.py
@gpu_env.task
async def harvest_activations(
    layers: list[int],
    n_tokens: int = 8_000_000,
    ctx_len: int = 128,
    dataset: str = "HuggingFaceFW/fineweb:sample-10BT",
    shard_seqs: int = 4096,
) -> Harvest:
    """Run Pythia over FineWeb and save the residual stream after each layer in ``layers``.

    Each context is the first ``ctx_len`` tokens of one document. Activations are float16 shards,
    one directory per layer, so a trainer downloads only the layer it needs.
    """
    import numpy as np
    import torch
    from datasets import load_dataset
    from transformers import AutoModelForCausalLM, AutoTokenizer

    tok = AutoTokenizer.from_pretrained(MODEL)
    model = AutoModelForCausalLM.from_pretrained(MODEL).to(DEVICE).eval()
    name, _, config = dataset.partition(":")
    stream = load_dataset(name, name=config or None, split="train", streaming=True)

    n_seqs = n_tokens // ctx_len
    rows: list[list[int]] = []
    texts: list[str] = []
    for doc in stream:
        texts.append(doc["text"])
        if len(texts) == 1000:
            enc = tok(texts, truncation=True, max_length=ctx_len)["input_ids"]
            rows += [ids for ids in enc if len(ids) == ctx_len]
            texts = []
            if len(rows) >= n_seqs:
                break
    tokens = np.array(rows[:n_seqs], dtype=np.int32)
    print(f"{len(tokens):,} contexts of {ctx_len} tokens from {dataset}")

    captured: dict[int, torch.Tensor] = {}
    for layer in layers:
        model.gpt_neox.layers[layer].register_forward_hook(
            lambda _m, _i, out, layer=layer: captured.__setitem__(layer, out[0] if isinstance(out, tuple) else out))

    root = Path(tempfile.mkdtemp())
    for layer in layers:
        (root / f"layer_{layer}").mkdir()
    d_model = model.config.hidden_size
    with torch.no_grad():
        for shard, s in enumerate(range(0, len(tokens), shard_seqs)):
            chunk = tokens[s:s + shard_seqs]
            out = {layer: np.empty((len(chunk), ctx_len, d_model), dtype=np.float16) for layer in layers}
            for b in range(0, len(chunk), 256):
                model(input_ids=torch.as_tensor(chunk[b:b + 256], dtype=torch.long, device=DEVICE))
                for layer in layers:
                    out[layer][b:b + 256] = captured[layer].clamp(-6e4, 6e4).half().cpu().numpy()
            for layer in layers:
                np.save(root / f"layer_{layer}" / f"shard_{shard:03d}.npy", out[layer])
    np.save(root / "tokens.npy", tokens)
    print(f"saved {len(layers)} layers x {len(tokens) * ctx_len:,} tokens")

    acts = await asyncio.gather(*[Dir.from_local(str(root / f"layer_{layer}")) for layer in layers])
    return Harvest(model=MODEL, dataset=dataset, layers=layers, ctx_len=ctx_len, d_model=d_model,
                   n_seqs=len(tokens), acts=list(acts), tokens=await File.from_local(str(root / "tokens.npy")))

The environment sets cache="auto", so the harvest’s output is stored under a key made from its inputs and the task function’s own code. Any later run with the same layers, token count, and dataset gets the same Dirs back without starting a GPU.

cache="auto" hashes only the body of the task function, not the modules it calls. train_sae does its work in sae.py, and label_features in autointerp.py, so each of those tasks salts its cache with a version string: flyte.Cache("auto", salt=SAE_VERSION) and flyte.Cache("auto", salt=autointerp.VERSION). Bump the version when you change the module, or a re-run returns results computed with the old code.

The TopK sparse autoencoder

The SAE encodes an activation into a code as wide as the number of features, keeps the k largest entries, and decodes back with one unit-norm direction per feature. Choosing k sets the sparsity directly, where older SAEs tuned an L1 penalty to reach it.

sae.py
class TopKSAE(nn.Module):
    def __init__(self, d_model: int, width: int, k: int):
        super().__init__()
        self.d_model, self.width, self.k = d_model, width, k
        w_dec = torch.randn(width, d_model)
        w_dec /= w_dec.norm(dim=1, keepdim=True)
        self.W_dec = nn.Parameter(w_dec)  # one unit-norm direction per feature
        self.W_enc = nn.Parameter(w_dec.T.clone())  # tied at init, trained separately
        self.b_enc = nn.Parameter(torch.zeros(width))
        self.b_dec = nn.Parameter(torch.zeros(d_model))

    def pre_acts(self, x: torch.Tensor) -> torch.Tensor:
        return (x - self.b_dec) @ self.W_enc + self.b_enc

    def encode(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
        """The k active features per token: their indices, their values, and every pre-activation."""
        pre = self.pre_acts(x)
        vals, idx = pre.topk(self.k, dim=-1)
        return idx, F.relu(vals), pre

    def decode(self, idx: torch.Tensor, vals: torch.Tensor) -> torch.Tensor:
        # A sum of k decoder rows per token, without materializing the width-sized code.
        return F.embedding_bag(idx, self.W_dec, per_sample_weights=vals, mode="sum") + self.b_dec

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        idx, vals, _ = self.encode(x)
        return self.decode(idx, vals)

    @torch.no_grad()
    def normalize_decoder(self) -> None:
        self.W_dec.data /= self.W_dec.data.norm(dim=1, keepdim=True).clamp_min(1e-8)

Training follows Gao et al.: Adam with a learning rate that scales as one over the square root of the width, the decoder bias started at the median activation, and an auxiliary loss that asks the dead features to explain what the live ones missed, so fewer features stay dead. Each trainer trains one SAE per k on the same batches, so the three SAEs share one download, one copy of the activations in GPU memory, and one GPU.

sae.py
def train(
    acts: torch.Tensor,  # [sequences, positions, d_model] float16, on the GPU
    width: int,
    ks: list[int],
    steps: int,
    batch_size: int = 4096,
    lr: float | None = None,
    seed: int = 0,
    log_every: int = 250,
    on_log=None,
) -> tuple[list[TopKSAE], float, list[dict]]:
    """Train one TopK SAE per k on the same batches. Position 0 is skipped (see ``token_index``)."""
    torch.manual_seed(seed)
    device = acts.device
    n_seq, ctx, d = acts.shape
    scale = activation_scale(acts)
    # Gao et al. scale the learning rate as 1/sqrt(width).
    lr = lr or 2e-4 * math.sqrt(16384 / width)
    saes = [TopKSAE(d, width, k).to(device) for k in ks]
    sample = gather(acts, token_index(n_seq, ctx, 32_768, device), scale)
    for sae in saes:
        sae.b_dec.data = sample.median(dim=0).values  # a robust center for the decoder bias
    opts = [torch.optim.Adam(s.parameters(), lr=lr, betas=(0.9, 0.999)) for s in saes]
    decay_from = int(steps * 0.8)
    sched = lambda step: min(1.0, (step + 1) / 100) * min(1.0, (steps - step) / max(steps - decay_from, 1))
    last_fired = [torch.zeros(width, dtype=torch.long, device=device) for _ in saes]
    dead_after = max(1, 1_000_000 // batch_size)  # a feature is dead after 1M tokens without firing
    curve: list[dict] = []
    for step in range(steps):
        x = gather(acts, token_index(n_seq, ctx, batch_size, device), scale)
        point = {"step": step + 1}
        for i, (sae, opt) in enumerate(zip(saes, opts)):
            for g in opt.param_groups:
                g["lr"] = lr * sched(step)
            idx, vals, pre = sae.encode(x)
            x_hat = sae.decode(idx, vals)
            err = x - x_hat
            nmse = err.pow(2).sum(-1).mean() / (x - x.mean(0)).pow(2).sum(-1).mean()
            dead = (step - last_fired[i]) > dead_after
            loss = nmse + aux_loss(sae, pre, err.detach(), dead) / 32
            opt.zero_grad(set_to_none=True)
            loss.backward()
            opt.step()
            sae.normalize_decoder()
            last_fired[i][idx[vals > 0]] = step
            point[f"k{sae.k}"] = nmse.item()
            point[f"dead_k{sae.k}"] = float(dead.float().mean())
        if step % log_every == 0 or step == steps - 1:
            curve.append(point)
            if on_log:
                on_log(point)
    return saes, scale, curve

Train and evaluate the grid

train_sae is one GPU task per (layer, width). It loads its layer onto the GPU, trains, and streams the training curves into its own report. Then it evaluates every SAE on held-out contexts and, for the width that gets labeled, samples features for Claude.

find_features.py
@gpu_env.task(report=True, cache=flyte.Cache("auto", salt=SAE_VERSION))
async def train_sae(
    harvest: Harvest,
    layer: int,
    width: int,
    ks: list[int],
    steps: int = 8000,
    batch_size: int = 4096,
    label_k: int = 32,
    n_features: int = 40,
    n_top: int = 12,
    n_sampled: int = 8,
    held_out: float = 0.05,
    seed: int = 0,
) -> Trained:
    """Train one TopK SAE per k on one layer, evaluate each on held-out text, and sample features to label."""
    import sae as S
    import torch
    from transformers import AutoModelForCausalLM, AutoTokenizer

    torch.backends.cuda.matmul.allow_tf32 = True
    acts, tokens = await load_layer(harvest, layer)
    n_train = int(len(acts) * (1 - held_out))
    print(f"layer {layer}: {n_train:,} training and {len(acts) - n_train:,} held-out contexts, width {width}, k {ks}")

    started = time.time()
    curve_so_far: list[dict] = []

    def on_log(point: dict) -> None:  # stream the training curves into this task's report
        curve_so_far.append(point)
        flyte.report.replace(reports.training(layer, width, ks, steps, curve_so_far, time.time() - started))
        flyte.report.flush()

    # Training runs in a worker thread, so the report can stream from the callback.
    saes, scale, curve = await asyncio.to_thread(S.train, acts[:n_train], width, ks, steps, batch_size,
                                                 seed=seed, on_log=on_log)
    train_s = time.time() - started
    print(f"trained {len(ks)} SAEs for {steps} steps in {train_s / 60:.1f} min")

    # Evaluate on held-out contexts: reconstruction, sparsity, and what the model loses with the SAE in place.
    model = AutoModelForCausalLM.from_pretrained(harvest.model).to(DEVICE).eval()
    eval_tokens = torch.as_tensor(tokens[n_train:][:512], dtype=torch.long, device=DEVICE)
    ce = S.spliced_losses(model, eval_tokens, layer, saes, scale)
    stats = []
    for sae in saes:
        m = S.reconstruction_metrics(sae, acts[n_train:], scale)
        st = SAEStats(layer=layer, width=width, k=sae.k, fve=m["fve"], l0=m["l0"], dead_frac=m["dead_frac"],
                      ce_clean=ce["clean"], ce_sae=ce[f"k{sae.k}"], ce_zero=ce["zero"],
                      loss_recovered=S.loss_recovered(ce["clean"], ce[f"k{sae.k}"], ce["zero"]),
                      density_hist=S.density_histogram(m["density"]))
        print(f"  k={sae.k}: FVE {st.fve:.3f}, L0 {st.l0:.1f}, dead {st.dead_frac:.1%}, "
              f"loss {st.ce_clean:.3f} -> {st.ce_sae:.3f} (zero {st.ce_zero:.3f}), recovered {st.loss_recovered:.1%}")
        stats.append(st)

    out = Path(tempfile.mkdtemp())
    weights = []
    for sae in saes:
        path = out / f"sae_L{layer}_w{width}_k{sae.k}.pt"
        torch.save({"state_dict": sae.state_dict(), "scale": scale, "layer": layer, "width": width, "k": sae.k,
                    "d_model": sae.d_model, "model": harvest.model, "hook": f"gpt_neox.layers.{layer} output"}, path)
        weights.append(await File.from_local(str(path)))

    examples = None
    if n_features and label_k in ks:
        tok = AutoTokenizer.from_pretrained(harvest.model)
        feats = S.feature_examples(saes[ks.index(label_k)], acts, tokens, scale, lambda t: tok.decode([t]),
                                   n_features=n_features, n_top=n_top, n_sampled=n_sampled, seed=seed + layer)
        path = out / "examples.json"
        path.write_text(json.dumps(feats))
        examples = await File.from_local(str(path))
        print(f"  sampled {len(feats)} features for autointerp")
    return Trained(layer=layer, width=width, ks=ks, stats=stats, weights=weights, curve=curve,
                   train_s=round(train_s, 1), examples=examples, label_k=label_k if examples else 0)

Reconstruction alone can mislead: an SAE can reconstruct the large, unimportant directions of an activation well and miss the small ones the model relies on. So the evaluation also splices the reconstruction back into the model and measures next-token loss. Loss recovered compares that loss with the clean model’s and with the model whose layer output is zeroed: 100% means the SAE loses nothing the model needs, 0% means it is no better than nothing.

sae.py
@torch.no_grad()
def reconstruction_metrics(sae: TopKSAE, acts: torch.Tensor, scale: float, chunk: int = 128) -> dict:
    """Fraction of variance explained, L0, dead features, and each feature's firing rate, on held-out tokens."""
    x_all = acts[:, 1:]
    mean = x_all.reshape(-1, x_all.shape[-1]).float().mean(0) * scale
    sse = sst = l0 = 0.0
    fires = torch.zeros(sae.width, device=acts.device)
    n = 0
    for s in range(0, x_all.shape[0], chunk):
        x = x_all[s:s + chunk].reshape(-1, x_all.shape[-1]).float() * scale
        idx, vals, _ = sae.encode(x)
        x_hat = sae.decode(idx, vals)
        sse += float((x - x_hat).pow(2).sum())
        sst += float((x - mean).pow(2).sum())
        on = vals > 0
        l0 += float(on.sum())
        fires.index_add_(0, idx[on], torch.ones(int(on.sum()), device=acts.device))
        n += x.shape[0]
    density = (fires / n).cpu().numpy()
    return {"fve": 1 - sse / sst, "l0": l0 / n, "dead_frac": float((density == 0).mean()),
            "density": density, "tokens": n}

@torch.no_grad()
def spliced_losses(model, tokens: torch.Tensor, layer: int, saes: list[TopKSAE], scale: float, batch: int = 64) -> dict:
    """Next-token loss with layer ``layer``'s output left alone, replaced by each SAE's reconstruction, or zeroed.

    Position 0 is never touched, the same as in training.
    """
    block = model.gpt_neox.layers[layer]
    state: dict = {"replace": None}

    def hook(_module, _inputs, output):
        hidden = output[0] if isinstance(output, tuple) else output
        if state["replace"] is None:
            return output
        new = hidden.clone()
        new[:, 1:] = state["replace"](hidden[:, 1:])
        return (new, *output[1:]) if isinstance(output, tuple) else new

    def through(sae):
        def f(h):
            x = h.reshape(-1, h.shape[-1]).float() * scale
            return (sae(x) / scale).reshape(h.shape).to(h.dtype)
        return f

    def loss() -> float:
        total = 0.0
        for s in range(0, tokens.shape[0], batch):
            t = tokens[s:s + batch]
            total += float(model(input_ids=t, labels=t).loss) * t.shape[0]
        return total / tokens.shape[0]

    handle = block.register_forward_hook(hook)
    try:
        out = {"clean": loss()}
        state["replace"] = torch.zeros_like
        out["zero"] = loss()
        for sae in saes:
            state["replace"] = through(sae)
            out[f"k{sae.k}"] = loss()
    finally:
        handle.remove()
    return out

def loss_recovered(clean: float, spliced: float, zero: float) -> float:
    """1.0 when the reconstruction is as good as the real activations, 0.0 when it is no better than zeros."""
    return (zero - spliced) / (zero - clean)

Label the features with Claude

feature_examples samples features at random among those that fire in at least 56 contexts (enough to fill every example set below) and stay silent in at least 16, so very rare and very dense features are left out of the sample. For each one it collects what Claude explains the feature from: the 12 contexts where it fires hardest, and 8 more drawn at random from the contexts where it fires clearly, at a quarter of its peak or more. If a feature has too few clear firings, the 8 come from its next-strongest contexts instead, and the prompt says so. It also builds a held-out test set: 8 other clear firings, and 8 random contexts where the feature does not fire at all. Every excerpt is the same length, so length gives nothing away.

sae.py
@torch.no_grad()
def feature_examples(
    sae: TopKSAE,
    acts: torch.Tensor,
    tokens: np.ndarray,
    scale: float,
    decode_token,
    n_features: int = 40,
    n_top: int = 12,
    n_sampled: int = 8,
    n_detect: int = 8,
    before: int = 24,
    after: int = 8,
    seed: int = 0,
    chunk: int = 128,
) -> list[dict]:
    """For a random sample of live features: contexts to explain it from, a held-out test set, and a histogram.

    Claude explains a feature from its ``n_top`` strongest contexts plus ``n_sampled`` contexts drawn
    from the rest of its clear firings. Top contexts alone hide what a feature does at moderate
    strength, which is most of its firings (Paulo et al., 2024). The test set for each feature mixes ``n_detect`` contexts where it fires clearly (none of them
    among its top contexts) with ``n_detect`` random contexts where it does not fire at all. Autointerp scores an
    explanation by how well it predicts which is which.
    """
    rng = np.random.default_rng(seed)
    n_seq, ctx, _ = acts.shape
    # Pass 1: how many contexts each feature fires in.
    seq_hits = torch.zeros(sae.width, device=acts.device)
    for s in range(0, n_seq, chunk):
        x = acts[s:s + chunk, 1:].reshape(-1, acts.shape[-1]).float() * scale
        idx, vals, _ = sae.encode(x)
        per_seq = torch.zeros(min(chunk, n_seq - s), sae.width, device=acts.device)
        rows = torch.arange(per_seq.shape[0], device=acts.device).repeat_interleave((ctx - 1) * sae.k)
        per_seq.index_put_((rows, idx.flatten()), (vals.flatten() > 0).float(), accumulate=True)
        seq_hits += (per_seq > 0).float().sum(0)
    # Sample uniformly among features that fire in enough contexts to fill both example sets.
    enough = (seq_hits >= 2 * (n_top + n_sampled + n_detect)) & (seq_hits <= n_seq - 2 * n_detect)
    eligible = torch.nonzero(enough).flatten().cpu().numpy()
    feats = np.sort(rng.choice(eligible, size=min(n_features, len(eligible)), replace=False))
    # Pass 2: every activation of the sampled features, at every position.
    col = torch.full((sae.width,), -1, dtype=torch.long, device=acts.device)
    col[torch.as_tensor(feats, device=acts.device)] = torch.arange(len(feats), device=acts.device)
    f_acts = torch.zeros(n_seq, ctx, len(feats), dtype=torch.float16, device=acts.device)
    for s in range(0, n_seq, chunk):
        block = acts[s:s + chunk, 1:]
        x = block.reshape(-1, acts.shape[-1]).float() * scale
        idx, vals, _ = sae.encode(x)
        c = col[idx]
        keep = (c >= 0) & (vals > 0)
        rows = torch.arange(x.shape[0], device=acts.device).unsqueeze(1).expand_as(idx)[keep]
        flat = torch.zeros(x.shape[0], len(feats), dtype=torch.float16, device=acts.device)
        flat[rows, c[keep]] = vals[keep].half()
        f_acts[s:s + block.shape[0], 1:] = flat.reshape(block.shape[0], ctx - 1, len(feats))

    def window(seq: int, center: int, values: np.ndarray) -> dict:
        # Every excerpt has the same length, shifted inward at the context's edges, so that
        # length never tells the detection test which excerpts are positives.
        size = before + after + 1
        lo = min(max(1, center - before), ctx - size)
        hi = lo + size
        return {"tokens": [decode_token(int(t)) for t in tokens[seq, lo:hi]],
                "acts": [round(float(v), 3) for v in values[seq, lo:hi]]}

    out = []
    for j, f in enumerate(feats):
        a = f_acts[:, :, j].float()
        seq_max, seq_arg = a.max(dim=1)
        order = torch.argsort(seq_max, descending=True).cpu().numpy()
        seq_max, seq_arg, a_np = seq_max.cpu().numpy(), seq_arg.cpu().numpy(), a.cpu().numpy()
        active = order[seq_max[order] > 0]
        top = active[:n_top]
        rest = active[n_top:]
        # Held-out positives: contexts outside the top where the feature still fires clearly, at
        # least a quarter of its peak. Weaker firings are near the SAE's noise floor.
        clear = rest[seq_max[rest] >= 0.25 * seq_max[top[0]]]
        enough_clear = len(clear) >= n_sampled + n_detect
        pool = clear if enough_clear else rest[:4 * (n_sampled + n_detect)]
        picked = rng.choice(pool, size=min(n_sampled + n_detect, len(pool)), replace=False)
        sampled, test_on = np.sort(picked[:n_sampled]), picked[n_sampled:]
        test_on_set = [{**window(int(sq), int(seq_arg[sq]), a_np), "active": True} for sq in test_on]
        quiet = np.nonzero(seq_max == 0)[0]
        test_off = rng.choice(quiet, size=min(n_detect, len(quiet)), replace=False) if len(quiet) else []
        test_off_set = [{**window(int(sq), int(rng.integers(before + 1, ctx - after)), a_np), "active": False}
                        for sq in test_off]
        nz = a_np[a_np > 0]
        counts, edges = np.histogram(nz, bins=24, range=(0, float(nz.max())))
        out.append({
            "feature": int(f),
            "density": float(len(nz) / (n_seq * (ctx - 1))),
            "context_rate": float(len(active) / n_seq),
            "max_act": float(nz.max()),
            "hist": {"counts": counts.tolist(), "edges": [round(float(e), 3) for e in edges]},
            "top": [window(int(sq), int(seq_arg[sq]), a_np) for sq in top],
            "sampled_clear": bool(enough_clear),  # False: sampled from its next-strongest contexts instead
            "sampled": sorted((window(int(sq), int(seq_arg[sq]), a_np) for sq in sampled), key=lambda w: -max(w["acts"])),
            "test": test_on_set + test_off_set,
        })
    return out

Claude sees those contexts with the activating tokens marked and writes an explanation. A second call gets only that explanation and the shuffled test set, and lists the contexts it predicts the feature fires in. Both calls ask for structured output, so a finished answer is always valid JSON.

autointerp.py
EXPLAIN_SYSTEM = """\
You are an interpretability researcher. You study one feature of a sparse autoencoder trained
on a small language model's residual stream. A feature is a direction that is active on some
tokens and silent on the rest. You will see the text excerpts where the feature is most active,
then a random sample of other excerpts where it fires, with every token where it fires at a tenth of its peak or more marked as <<token>>, and the
peak activation of each excerpt on a 0-10 scale. Find what the marked tokens have in common, in the token itself and in its
context. Be specific: name the words, the syntax, or the topic. If it fires on a token only
in some contexts, say which. Your explanation will be used to predict, for unseen excerpts,
whether the feature fires anywhere in them."""

EXPLAIN_SCHEMA = {
    "type": "object",
    "properties": {
        "notes": {"type": "string", "description": "One or two sentences of reasoning about the pattern."},
        "explanation": {"type": "string", "description": "The explanation, at most 20 words."},
    },
    "required": ["notes", "explanation"],
    "additionalProperties": False,
}

DETECT_SYSTEM = """\
You test an explanation of one feature of a sparse autoencoder trained on a small language
model. You will get the explanation and numbered text excerpts. Some excerpts contain a token
where the feature fires and the rest do not. Using only the explanation, list the numbers of
the excerpts in which the feature fires."""

DETECT_SCHEMA = {
    "type": "object",
    "properties": {"active": {"type": "array", "items": {"type": "integer"}}},
    "required": ["active"],
    "additionalProperties": False,
}

The score is balanced accuracy, so 0.5 is chance whatever the mix:

autointerp.py
def detection_score(answer_json: str, test: list[dict]) -> float:
    """Balanced accuracy of Claude's predictions against where the feature really fired."""
    predicted = {int(i) - 1 for i in json.loads(answer_json)["active"]}
    on = [i for i, ex in enumerate(test) if ex["active"]]
    off = [i for i, ex in enumerate(test) if not ex["active"]]
    hit = sum(i in predicted for i in on) / max(len(on), 1)
    reject = sum(i not in predicted for i in off) / max(len(off), 1)
    return (hit + reject) / 2

An answer can still fail to finish: Claude may refuse, or run out of max_tokens, which also counts any thinking. ask_claude checks stop_reason before it reads the answer, and label_features records a feature whose calls fail as unlabeled, so one bad answer never fails the task. The report counts unlabeled features per SAE.

Each call is a flyte.trace. Its input and output are recorded in the run, so if the task fails partway through and retries, the calls that already finished replay from the record instead of going back to the API.

find_features.py
class Unanswered(Exception):
    """Claude gave no complete JSON answer: a refusal, or output cut off at max_tokens."""

async def ask_claude(system: str, prompt: str, schema: dict, max_tokens: int = 4096) -> str:
    import anthropic

    client = anthropic.AsyncAnthropic(max_retries=8)
    message = await client.messages.create(
        model=CLAUDE,
        max_tokens=max_tokens,
        system=system,
        output_config={"effort": "low", "format": {"type": "json_schema", "schema": schema}},
        messages=[{"role": "user", "content": prompt}],
    )
    # Any thinking counts toward max_tokens, so check that the answer finished before reading it.
    if message.stop_reason != "end_turn":
        raise Unanswered(f"stop_reason={message.stop_reason}")
    text = next((b.text for b in message.content if b.type == "text"), None)
    if text is None:
        raise Unanswered("no text block")
    return text

@flyte.trace
async def explain_feature(layer: int, feature: int, prompt: str) -> str:
    """Claude's explanation of one feature. Traced, so a retried task replays it instead of paying again."""
    return await ask_claude(autointerp.EXPLAIN_SYSTEM, prompt, autointerp.EXPLAIN_SCHEMA)

@flyte.trace
async def detect_feature(layer: int, feature: int, prompt: str) -> str:
    """Claude's guess of which held-out excerpts the explanation predicts. Traced, like the explanation."""
    return await ask_claude(autointerp.DETECT_SYSTEM, prompt, autointerp.DETECT_SCHEMA)

label_features runs every explanation, then scores each one twice: once on its own feature’s test set, and once with another feature’s explanation. The second score is a control. A good explanation should beat a fluent but wrong one by a wide margin.

find_features.py
@interp_env.task(retries=1, cache=flyte.Cache("auto", salt=autointerp.VERSION))
async def label_features(layer: int, width: int, k: int, examples: File, concurrency: int = 8) -> str:
    """Explain each sampled feature, then score every explanation on the feature's held-out contexts.

    Each feature is also scored with another feature's explanation. That control shows what the
    test gives an explanation that is fluent but wrong. A feature whose calls fail is recorded
    as unlabeled, so one bad answer never fails the whole task.
    """
    with open(await examples.download()) as f:
        features = json.load(f)
    gate = asyncio.Semaphore(concurrency)
    unlabeled: list[dict] = []

    async def explain(feat: dict) -> dict | None:
        try:
            async with gate:
                answer = json.loads(await explain_feature(layer, feat["feature"], autointerp.explain_prompt(feat)))
            return {**feat, **answer}
        except Exception as exc:  # Unanswered, malformed JSON, or an API error that outlived the retries
            unlabeled.append({"feature": feat["feature"], "error": f"explain: {exc}"})
            return None

    labeled = [f for f in await asyncio.gather(*[explain(f) for f in features]) if f is not None]
    shuffled = [labeled[(i + 1) % len(labeled)]["explanation"] for i in range(len(labeled))]

    async def score(feat: dict, explanation: str) -> float | None:
        test = autointerp.detect_set(feat, seed=feat["feature"])
        try:
            async with gate:
                answer = await detect_feature(layer, feat["feature"], autointerp.detect_prompt(explanation, test))
            return autointerp.detection_score(answer, test)
        except Exception as exc:
            unlabeled.append({"feature": feat["feature"], "error": f"detect: {exc}"})
            return None

    real = await asyncio.gather(*[score(f, f["explanation"]) for f in labeled])
    control = await asyncio.gather(*[score(f, e) for f, e in zip(labeled, shuffled)])
    scored = []
    for feat, s, c in zip(labeled, real, control):
        if s is None or c is None:
            continue
        feat.update(score=s, control=c)
        scored.append(feat)
        print(f"  #{feat['feature']}: {s:.2f} (control {c:.2f})  {feat['explanation']}")
    if scored:
        print(f"layer {layer}: mean detection {sum(f['score'] for f in scored) / len(scored):.2f}, "
              f"mismatched control {sum(f['control'] for f in scored) / len(scored):.2f}")
    unlabeled = list({u["feature"]: u for u in unlabeled}.values())  # one entry per feature
    for u in unlabeled:
        print(f"  #{u['feature']} unlabeled: {u['error']}")
    return json.dumps({"layer": layer, "width": width, "k": k, "features": scored, "unlabeled": unlabeled})

The driver

The driver harvests (or reuses the cached harvest), trains the whole grid at once, labels the sampled features of each layer in parallel, writes the report, and publishes the SAE whose features Claude explained best.

find_features.py
@driver_env.task(report=True, produces_artifacts=True)
async def find_features(
    layers: list[int] = [1, 3, 5],
    widths: list[int] = [4096, 16384],
    ks: list[int] = [16, 32, 64],
    n_tokens: int = 8_000_000,
    steps: int = 8000,
    label_k: int = 32,
    label_width: int = 16384,
    features_per_sae: int = 40,
) -> File:
    """Harvest activations, train an SAE grid in parallel, label features with Claude, and publish the best SAE."""
    if label_width not in widths:
        label_width = max(widths)
    started = time.time()
    await flyte.report.replace.aio(reports.page_start(layers, widths, ks, n_tokens), do_flush=True)

    harvest = await harvest_activations(layers=layers, n_tokens=n_tokens)
    trained: list[Trained] = await asyncio.gather(*[
        train_sae(harvest, layer, width, ks, steps=steps, label_k=label_k,
                  n_features=features_per_sae if width == label_width else 0)
        for layer in layers for width in widths
    ])
    await flyte.report.replace.aio(reports.overview(trained, [], harvest, time.time() - started), do_flush=True)

    labeled = [json.loads(s) for s in await asyncio.gather(*[
        label_features(t.layer, t.width, t.label_k, t.examples) for t in trained if t.examples is not None
    ])]
    await flyte.report.replace.aio(reports.overview(trained, labeled, harvest, time.time() - started))
    flyte.report.get_tab("Feature browser").replace(reports.browser(labeled))
    await flyte.report.flush.aio()

    # The SAE whose features Claude explained best, by mean detection score.
    mean = lambda lab: sum(f["score"] for f in lab["features"]) / len(lab["features"])
    best_labels = max((lab for lab in labeled if lab["features"]), key=mean)
    best = next(t for t in trained if (t.layer, t.width) == (best_labels["layer"], best_labels["width"]))
    stats = next(s for s in best.stats if s.k == best_labels["k"])
    return await publish(best, stats, best_labels, flyte.ctx().action.run_name)

The report’s second tab is the feature browser. It sorts every labeled feature by its detection score, so the reader meets the convincing features first, and the weak ones at the end:

reports.py
def browser(labeled: list[dict]) -> str:
    """Every labeled feature, most convincing first: highest detection score, then the widest margin over the control."""
    feats = [(f, lab) for lab in labeled for f in lab["features"]]
    feats.sort(key=lambda p: (-p[0]["score"], -(p[0]["score"] - p[0]["control"]), -p[0]["context_rate"]))
    good = sum(f["score"] >= SCORE_GOOD for f, _ in feats)
    return viz.page(
        "Feature browser",
        f"{len(feats)} features sampled at random from the live features of each SAE, sorted by how well Claude's "
        f"explanation predicts held-out contexts. {good} score at least {SCORE_GOOD}. Highlight strength is the "
        "feature's activation; hover a token for its value.",
        BROWSER_STYLE,
        "".join(feature_html(f, lab["layer"], lab["width"], lab["k"]) for f, lab in feats),
    )

The model card goes with the weights:

find_features.py
async def publish(best: Trained, stats: SAEStats, labeled: dict, run_name: str) -> File:
    card = await artifacts.Card.create_from.aio(content=reports.card(best, stats, labeled, run_name), format="html",
                                                card_type="model")
    scores = [f["score"] for f in labeled["features"]]
    metadata = artifacts.Metadata.create_model_metadata(
        name="pythia-70m-topk-sae",
        description=(f"TopK SAE on Pythia-70m-deduped layer {stats.layer}: {stats.width} features, k={stats.k}, "
                     f"{stats.fve:.0%} variance explained, {stats.loss_recovered:.0%} loss recovered"),
        framework="PyTorch",
        model_type="TopK sparse autoencoder",
        architecture=f"d_model 512 -> {stats.width} -> 512, TopK k={stats.k}",
        task="interpretability",
        modality=("text",),
        serial_format="pt",
        card=card,
        attrs={"layer": str(stats.layer), "width": str(stats.width), "k": str(stats.k), "fve": f"{stats.fve:.4f}",
               "loss_recovered": f"{stats.loss_recovered:.4f}", "dead_frac": f"{stats.dead_frac:.4f}",
               "detection": f"{sum(scores) / len(scores):.3f}"},
    )
    return artifacts.new(best.weights[best.ks.index(stats.k)], metadata)

Results

A run with the defaults trains 18 SAEs (3 layers, 2 widths, 3 values of k) on 8 million tokens of FineWeb. An earlier run of the same harvest and training code took 18 minutes from an empty cache: 3 minutes for the harvest, 3 to 6 minutes for each 4,096-feature trainer, 11 to 14 minutes for each 16,384-feature trainer, all six running at once, and about a minute for the 360 Claude calls of autointerp. The numbers and screenshots below are from a run of the code as published here, which reused that cached harvest, retrained every SAE, and took 16 minutes.

The run’s overview: loss recovered, SAEs trained, features labeled, mean detection score, and the metrics of every SAE

Every SAE recovers most of the model’s loss. With the 16,384-feature SAE at k = 32 spliced in:

Layer Variance explained Dead features Loss clean Loss with SAE Loss zero-ablated Loss recovered
1 0.878 0.0% 4.163 4.298 8.66 97.0%
3 0.957 0.1% 4.163 4.411 9.43 95.3%
5 0.885 0.0% 4.163 4.476 8.72 93.1%

The sparsity plots show the trade-off the SAE literature reports. More active features per token (a larger k) reconstruct better, and so do more features in the dictionary at the same k. Later layers are harder: at layer 5, even the best SAE loses 5% of the loss. Variance explained alone would rank the layers differently, since layer 3 has the highest variance explained and only the middle loss recovered. That is why the evaluation splices the SAE into the model. Almost no feature is dead: the AuxK loss keeps them alive.

Variance explained and loss recovered against L0 for every SAE, and the autointerp scores per layer

Claude explained 40 features from each layer’s 16,384-feature SAE, 120 in all, sampled at random among those that fire in at least 56 contexts:

Layer Mean detection score Mismatched explanation Features scoring 0.85 or more
1 0.77 0.50 15 of 40
3 0.70 0.50 9 of 40
5 0.72 0.53 13 of 40

The mean is 0.73 over all 120, against 0.51 for the control, and every call returned a finished answer, so no feature was left unlabeled. The control stays at chance, so the score measures the explanation, not the test. The 12 features that score 1.0 are the ones a reader believes at a glance: the word “iPhone”, the plural “systems”, “covered”, “billion” and “trillion” in money amounts, “use” as a noun, sentence-initial “These”, and “especially” after a comma adding a condition. Close behind, at 0.94, are “Learn” opening a call to action, “out of” and the phrase after it, and closing quotation marks. In this sample, layer 1 has more single-word features, which are easy to predict, and the later layers have more features about syntax and position, which are harder to explain in a sentence. The ones at the bottom of the browser are mostly fragments of split words and diffuse features that fire on function words, which is also what the papers find.

The feature browser: features sorted by detection score, with Claude’s explanation and the activating tokens highlighted

The SAE whose features scored best, layer 1 with 16,384 features at k = 32, is published as the pythia-70m-topk-sae artifact, with a card that shows its metrics and its five most interpretable sampled features.

What the first run taught us

The first full run showed Claude only each feature’s 20 strongest contexts, and the mean detection score was 0.64. The explanations read well, but they failed the test. One feature’s 20 strongest contexts were all the word “iPhone”, and Claude’s explanation, the word “iPhone”, scored 0.62. Its held-out contexts showed why: at a third of its peak, the same feature fires on “Angela”, “Explorer”, and other names. The strongest contexts were not the whole feature. Showing Claude 12 top contexts and 8 sampled from the rest of its clear firings, as Paulo et al. recommend, raised the mean score to 0.72 on the same SAEs. Only the sample that Claude explained changed. The iPhone feature’s new explanation also named a weaker meaning, and scored 1.0. A review then made every excerpt the same length, so that length could not hint at the answer, and the final run above scored 0.73.

Add a width without redoing the rest

Every task is cached, so a run that changes one input only redoes the work that depends on it. Add a third width:

python find_features.py --widths 4096 16384 32768

We ran this demo before the last changes to the autointerp code and the cache salts, so the numbers in this section come from that earlier run; the SAE training code was the same, and its 4,096 and 16,384-feature SAEs matched the ones above to every digit. In that run, the harvest and the six existing trainers came back from the cache in under a second each, and so did the three autointerp tasks, since their inputs had not changed. Only the three 32,768-feature trainers ran, about 25 minutes each on one L4. The driver still writes a fresh report with all 27 SAEs in it.

The new width was not worth it at this data size:

Layer Loss recovered, 16,384 features Loss recovered, 32,768 features
1 97.0% 97.3%
3 95.3% 95.2%
5 93.1% 92.8%

All at k = 32. Doubling the dictionary bought almost nothing here. Two things likely contribute. The data is small: the scaling laws in Gao et al. call for more tokens as the width grows, and 8 million tokens may be too few for 32,768 features. And the training budget did not grow with the width: every trainer runs the same 8,000 steps, while the learning rate shrinks as one over the square root of the width, so the wider SAE also takes smaller steps for the same time. This run does not separate the two. A larger harvest (a change to n_tokens, which makes a new cached harvest) and a longer schedule for the wide SAEs are the next experiments.

At real scale

  • The harvest is the dataset. Gemma Scope trained on 4 to 16 billion tokens per SAE. At that size the activations do not fit on one disk, and most teams either stream them from object storage in shards, as this tutorial does at small scale, or recompute them on the fly next to the trainer. The cached Dir keeps the first option cheap to repeat.
  • The grid is the experiment. Gemma Scope released more than 400 SAEs: every layer, several widths, several sparsities. Each is an independent GPU job reading the same harvest, which is the shape of train_sae.
  • Autointerp is the long tail. Labeling every feature of a 16-million-feature SAE is many millions of LLM calls. Tracing the calls makes the labeling job restartable without paying twice, and the detection score lets you sort and filter features without reading them.