Running the Checks
Every page in this section ends with a Check yourself — a short experiment that either confirms the page’s claim or breaks it. They are all the same kind of thing: a few lines of Python, run against a real but small model that fits on a laptop. What none of them spell out, because it would mean saying it thirty times, is the part that comes before the experiment — which language, what to install, how the model gets loaded, and what the handful of objects the checks keep naming actually are. This page is that missing preamble. Read it once and keep it open; after that, every Check yourself is copy-and-run.
The three kinds of check#
The experiments differ in what they need in front of you:
- Load and look. Most of them. Download GPT-2 small, run one forward pass, and read the numbers that come back — no training, a second or two on a plain CPU. Neural networks, perplexity, the residual stream, attention, and the unembedding all live here.
- Train a tiny model. A handful — skip connections, backprop through one weight, mixture of experts, grouped-query attention, fine-tuning — change the architecture and watch the loss move. Those use nanoGPT and take minutes, not seconds.
- No model at all. A few — GELU and SwiGLU, RoPE, superposition, context length, tokenization — are pure arithmetic on random tensors, or a tokenizer with no weights behind it. They download nothing.
Everything below is Python 3. The rest of this page sets up each of the three in turn.
Load and look: GPT-2 small in HuggingFace#
Two packages cover every load-and-look check:
|
|
torch is PyTorch, the array-and-autograd library the model’s arithmetic runs on; transformers is HuggingFace’s library of pretrained models, which is where GPT-2 comes from. With those installed, the following is the setup that every load-and-look Check yourself silently assumes:
|
|
That one forward pass produces everything the load-and-look checks read. The three keyword arguments and the objects they fill are the vocabulary those checks are written in:
labels=idsmakes the model score itself. It returnsoutputs.loss, the average cross-entropy over the sequence. You pass the same tensor as both input and labels because there is no separate label — the model shifts the text by one internally and predicts each token from the ones before it. Exponentiate the loss and you have perplexity:torch.exp(outputs.loss).output_hidden_states=Truefillsoutputs.hidden_states, a tuple of 13 tensors each shaped[1, n, 768]— the embedding output, then one snapshot after each of the 12 blocks.hidden_states[0]is the input before any block has run;hidden_states[-1]is already past the final normalization, which HuggingFace applies before handing back that last entry. (Left off,outputs.hidden_statesisNone.)outputs.logits, always present, is shaped[1, n, 50257]: one score for every token in the vocabulary, at every position.
The named pieces of the model that the checks reach into all hang off model:
|
|
That wte/lm_head sharing is weight tying, and c_attn being one matrix rather than three is covered under Q/K/V projections.
Two more switches some checks flip#
- Attention weights. To read the attention pattern itself, load with
attn_implementation="eager"and passoutput_attentions=True; thenoutputs.attentionsis a tuple of[1, 12, n, n]matrices. The faster default SDPA (scaled dot-product attention) path doesn’t reliably hand those back, so the checks in one attention head and attention pineageron purpose. - The KV cache.
model.generate(...)and a plain forward pass both takeuse_cache=True(the default) orFalse. The tokens are identical either way — the KV cache is an optimization, not an approximation — which is exactly the check on that page.
Replacing a module’s output mid-pass#
The attention and residual stream checks delete part of the model while it runs, using a forward hook — PyTorch’s callback that fires when a module produces its output and can return a replacement:
|
|
Hook c_proj rather than .attn itself: .attn returns a tuple, not a plain tensor, and the block would index into your replacement instead of using it.
Generating text#
The sampling and decoding checks call model.generate(...). Greedy decoding is do_sample=False; nucleus sampling is do_sample=True, top_p=0.9; speculative decoding passes a small assistant_model=. See sampling strategies for what the arguments do.
A few checks (RLHF, multi-head attention) swap "gpt2" for another HuggingFace model id — "gpt2-xl", "Qwen/Qwen2.5-0.5B" — but change nothing else about the setup above.
Train a tiny model: nanoGPT#
The architecture checks need to modify the model and retrain, which the HuggingFace weights don’t let you do cleanly. They use nanoGPT — Andrej Karpathy’s roughly 300-line implementation of this exact model, small enough to read end to end. Clone it and install its dependencies:
|
|
The quickstart trains a character-level model on a 1 MB Shakespeare corpus in minutes on a CPU:
|
|
Two files matter for the checks. model.py holds the architecture — this is where “in nanoGPT, delete both skips” or “swap one block’s MLP for eight copies plus a router” happen, as edits to those ~300 lines. train.py runs the training loop and prints the loss you’re asked to watch. nanoGPT can also load GPT-2’s pretrained weights, with GPT.from_pretrained('gpt2'), which is how the grouped-query attention check starts from the real model before changing it.
No model: just torch (or tiktoken)#
Some checks download nothing at all. The ones on GELU, RoPE, superposition, and context length are arithmetic on random tensors — pip install torch and you have everything, no weights involved. Build a random matrix with torch.randn(768, 3072), call torch.nn.functional.gelu, and you’re running the experiment.
The tokenization check needs only the GPT-2 tokenizer, which ships without the model in a separate small package:
|
|
|
|
Check yourself#
The harness has its own falsifiable check: run the load-and-look setup at the top of this page verbatim, then confirm it’s wired up before you trust any experiment built on it.
|
|
hidden_states should have 13 entries; if it has none, you dropped output_hidden_states=True. The logits’ last axis is the 50,257-token vocabulary. And torch.exp(outputs.loss) comes back a two-digit number for ordinary English — the same quantity the perplexity page opens on. If outputs.loss is missing entirely, you dropped labels=ids.
Depends on / leads to#
Depends on GPT-2, the model these checks all run against. Leads to every page’s Check yourself; the reading order proper resumes at tokenization.