Case study · Muhammad Imran

I built a language model from scratch, then the tools to look inside it

A 2.6M-parameter transformer trained on a laptop CPU, a JavaScript engine that runs it in your browser, a sparse autoencoder that splits its internal state into readable features, and a guard that reads those features to stop the model writing names before it writes them.

Open the demo Code on GitHub

2.6Mparameters, every layer written by hand
3.0 hof training on a laptop CPU, no GPU
2,048features found by the sparse autoencoder
2.2e-05largest logit gap, browser vs PyTorch
The microscope explaining why the model wrote Lily after 'a little girl named': the features active on the previous tokens, each linked to the predicted word by an edge whose width is that feature's effect.
Screenshot of the microscope, explaining why the model wrote Lily. Each active feature is removed in turn and the model rerun; a blue edge means the word became less likely without that feature, and its width is how much.

In short

Why I built it

Interpretability research usually happens on large models with large budgets. I wanted to find out how much of it one person can do end to end on a laptop: build the model myself so no part of it is a black box, look inside it with current techniques, and turn one finding into a feature whose value can be measured. It all had to run without a GPU, a server or an API key, so anyone can open it.

What I built

Real model output. The first prompted sample in the evaluation report, not cherry-picked (temperature 0.8, top-k 50, seed 1); the prompt is in grey.

Once upon a time, there was a little girl named Lily. Lily had a friend named Tim. They loved to play together. One day, they saw a big box of copper in the yard. Lily and Tim wanted to go inside the box. They played with the copper and made a big snowman. They had so much fun. Then, Lily said, "Let's go inside the house!" Tim felt happy and said, "Okay, let's go inside and play!" The next day, Lily and Tim went back inside the house. They saw the tall cat and waved her head. "Good job, Lily!" said the cat. Lily smiled and they both played together, feeling happy and happy.

How I kept myself honest

Finding 1: the features are real, and they steer

The SAE the interface uses (k = 8 active features per token) explains 88.8% of the variance of the model's internal state. Swapping that state for the SAE's reconstruction raises the model's loss by 12.7%. 87% of the features put most of their strongest examples on a single token, against at most 42% for the model's own dimensions, random directions or an untrained SAE.

To steer, I add a feature's direction to the internal state while the model writes. I fixed the concepts in advance (ball, dog, cat, tree, cake, car, flower; rain had no matching feature and was dropped) and compared each with random directions of the same size. Strength is the size of the added vector relative to the average size of the internal state.

Stories that mention the steered concept. Mean over 7 concepts, 48 stories per concept and setting, the same prompts and seeds in every setting.
feature direction random direction, same size no steering
no steering
9%
strength 0.25
49%
9%
strength 0.5
83%
9%
strength 1
75%
7%
strength 2
62%
2%
Data table
strength featurerandom
08.9%-
0.2548.5%8.8%
0.582.7%9.2%
174.7%6.6%
261.6%1.8%

The price is fluency. At strength 0.5 the steered stories are harder for the unsteered model to predict (loss 2.18 nats per token, against 1.12 without steering and 1.63 with a random direction). Pushed harder, the text falls apart. The example in the report (not cherry-picked) is the ball feature at strength 1: Once upon a time, there lived a far treasure. It was rolled very high high up in the sky. One rolled down, near up to far. rolled far from…

Finding 2: reading features beats reading text, but not a regex

The guard has to decide before the next token exists, and that is genuinely uncertain: after Lily and the next word may be a name or her. Reading the model's internal features clearly helps: F1 0.46, against 0.37 for a classifier that sees only the previous two tokens. But a regex that checks the finished word scores 0.69. The regex's blind spot is a name at the start of a sentence, which looks like any other capitalized word, and that is where the internal signal adds the most. The combination and the strict mode were added after the first results; their thresholds still come only from the calibration stories.

Names caught, among names never used to fit or tune anything. 1,500 test stories with every name replaced by one of 51 names outside the training lexicon.
regex + name list
52%
0 false blocks per 1,000 tokens
SAE-feature guard
47%
21 false blocks per 1,000 tokens
regex or SAE guard
71%
21 false blocks per 1,000 tokens
regex or SAE guard, strict
94%
106 false blocks per 1,000 tokens
Data table
method precisionrecallF1false blocks / 1,000 tokens
regex + name list0.9980.5220.6860.0
SAE-feature guard0.4540.4730.46321.3
regex or SAE guard0.5560.7150.62621.3
regex or SAE guard, strict0.2490.9380.394105.6

The guard adds 0.65 ms to a 5.08 ms generation step in the JavaScript engine. A follow-up compared SAEs of different sizes and the raw internal state at every layer, choosing on a separate split with yet another set of names. The k = 32 SAE won (test F1 0.47, against 0.45 for k = 8), slightly ahead of the best raw layer (0.46), so the demo now uses it.

Finding 3: what did not work

Limits

What I would do next

How it was built

I built Glassbox with Claude Code, an AI coding assistant, as a pair programmer, working from a written project brief with explicit rules: no invented numbers, a baseline for every claim, negative results reported. I set the goals and constraints, made the decisions along the way and reviewed the results at every milestone; the assistant wrote most of the code and ran the experiments.

Python · PyTorch (primitives only) · NumPy · pytest · JavaScript (no dependencies) · GitHub Actions · GitHub Pages