TAI BUI
Back to curriculum
BuildPython

Build a Transformer from Scratch — The Capstone

Thirteen lessons come together in one small model that you train yourself.

TIME: ~120 minutesPREREQUISITES: Phase 7 · 01 through 13. Don't skip.

Learning Objectives

  • Assemble token and positional embeddings, pre-norm blocks (RMSNorm, causal attention, SwiGLU), and a tied LM head into a decoder-only GPT in PyTorch.
  • Train it with shift-by-one cross-entropy, AdamW, and gradient clipping, and read the train and val loss it prints.
  • Sample text with temperature and top-k, and explain why generate() crops its context to the last block_size tokens.
  • Name what the baseline leaves out (RoPE, KV cache, Flash Attention, MoE, an LR schedule, bf16) and which exercise adds each one.

The Problem

The capstone is a small decoder-only transformer trained end-to-end on a character-level language modeling task. It reads Shakespeare and generates new Shakespeare. It is small enough to train on a laptop in under 10 minutes, and correct enough that a bigger dataset and longer training get you a real LM.

You've implemented attention, multi-head splits, positional encodings, encoder and decoder blocks, BERT and GPT losses, MoE, and KV cache. Here they have to work together on a real task.

This is the "nanoGPT" of the course. It isn't original: Karpathy's 2023 nanoGPT tutorial is the reference implementation, and a lot of students write it at least once. We lift the shape and retool it around what we've covered.

Warm-up0/3
1. In a decoder-only language model, what does the causal mask stop each position from doing?Warm-up
A 6 by 6 grid with column headers The, cat, sat, on, the, mat and matching row labels; every cell holds a question mark and the third row, sat, is outlined.
Typed example sentence; the cells are left unfilled.
2. Why are the training targets for next-token prediction the inputs shifted by one position?Warm-up
Two rows of 8 boxes under positions 0 to 7. The x row holds the characters F, i, r, s, t, space, C, i. The y row has dashed boxes with question marks.
Characters taken from the text embedded in main.py, which starts 'First Citizen:'.
3. Compared with a BPE tokenizer, how large is a character-level vocabulary for English text?Warm-up
The phrase 'the lowest' cut into 10 single-character pieces under rule A and into 4 chunk pieces (the, space, low, est) under rule B; the total number of pieces each tokenizer must know is a question mark.
Illustrative split typed by hand; real chunk boundaries depend on the text a tokenizer learned from.
Check: The Problem0/1
1. What is nanoGPT?Define
A table of the ten definitions in main.py with their line ranges and lengths: param_count, run_param_preview, try_train, and inside it RMSNorm, CausalSelfAttention, SwiGLUFFN, Block, GPT and get_batch, then main.
Line ranges computed by parsing the lesson's main.py (277 lines by wc -l).

The Concept

Transformer-from-scratch block diagram

Here is the architecture, annotated:

input tokens (B, N)
   │
   ▼
token embedding + positional embedding  ◀── Lesson 04 (RoPE option)
   │
   ▼
┌──── block × L ────────────────────┐
│  RMSNorm                          │  ◀── Lesson 05
│  CausalSelfAttention (causal)     │  ◀── Lesson 03 + 07 (causal mask)
│  residual                         │
│  RMSNorm                          │
│  SwiGLU FFN                       │  ◀── Lesson 05
│  residual                         │
└────────────────────────────────── ┘
   │
   ▼
final RMSNorm
   │
   ▼
lm_head (tied to token embedding)
   │
   ▼
logits (B, N, V)
   │
   ▼
shift-by-one cross-entropy            ◀── Lesson 07

What we ship

  • Hyperparameters: plain variables at the top of try_train() (block_size, d_model, n_heads, n_layers, and so on). There is no config class.
  • CausalSelfAttention: causal and batched, with the mask-and-softmax written out by hand so you can see every step.
  • SwiGLUFFN: a modern FFN.
  • Block: pre-norm, residual-wrapped attention + FFN.
  • GPT: embeddings, stacked blocks, LM head, generate().
  • Training loop with AdamW (lr 3e-4, betas 0.9/0.95, weight decay 0.1) and gradient clipping at 1.0. The learning rate is constant.
  • Char-level tokenizer on Shakespeare text, plus a parameter-count preview table printed before training.

What we don't ship

  • RoPE: implemented conceptually in Lesson 04. Here we use learned positional embeddings for simplicity, and the exercises ask you to swap in RoPE.
  • KV cache during generation: each generation step recomputes attention over the full prefix. That's slower but simpler, and the exercises ask you to add a KV cache.
  • Flash Attention: main.py builds the attention matrix explicitly instead of calling F.scaled_dot_product_attention. Swapping it in is an easy extension, and PyTorch 2.0+ dispatches to a fused kernel when the inputs allow.
  • A learning-rate schedule and bf16 autocast: the loop runs at a constant LR in default precision. Exercise 6 adds both.
  • MoE: each block has a single FFN. You saw MoE in Lesson 11.

Target metrics

main.py as shipped is a smoke test: 3 layers, 4 heads, d_model=64, block_size=64, batch size 16, 500 steps. Its docstring says 4 layers, d_model=128, seq_len=128 and about 2 minutes, which disagrees with the code. The code is what runs, so trust the config block inside try_train(). The larger configuration below is the one the exercises scale you up to, and the numbers come from that configuration, not from the shipped one.

These numbers come from actually running the script. The larger configuration is n_layers=4, d_model=128 (803,584 parameters), everything else as shipped (batch 16, block 64, constant LR 3e-4), on the full tinyshakespeare.txt placed next to main.py, on CPU. Seeds are fixed, so a rerun of the same configuration prints the same numbers on the same machine.

  • Step 0 prints a loss near 88, not the ln 65 ≈ 4.2 you would expect from a uniform guess. The token embedding is initialised from N(0, 1) and the LM head is tied to it, so the first logits are huge. It falls below 3.3 by step 100, so ignore the first row.
  • After 2,000 steps (about 1.5 minutes on a multi-core CPU) train loss is about 2.1 and val loss about 2.2. After 5,000 steps they are about 1.7 and 1.8.
  • Sampled output has Shakespeare's rhythm: line breaks, capitalised speaker tags (MARTIUS: shows up at 5,000 steps), and mostly word-shaped fragments with few real words. Do not expect fluent text from this budget.
  • Val loss (held-out final 10% of text) stays within about 0.2 of train loss through 5,000 steps and is still falling, so overfitting is not the limit at this size and budget.
Check: The Concept0/13
1. What does it mean that the capstone uses tied embeddings?Define
A left-to-right chain of boxes: token ids, tok_emb, blocks, norm_f, lm_head and logits. The lm_head box is highlighted and its weights are marked with a question mark.
Module names from the GPT class in main.py.
2. What is bf16 autocast?Define
Three boxes in a row, forward, backward and optimizer state, each marked precision with a question mark.
Stage names from a standard training step; the precision of each stage is left open.
3. What is a cosine LR schedule with warmup?Define
Empty axes for learning rate against step, from step 0 to the last step, with the vertical axis marked from 0 to peak and a large question mark where the curve would be.
Axes only; the curve is left out on purpose.
4. What does MFU measure?Define
A bar chart of MFU in percent with a dense model at 40 and a MoE model at 30 on a 0 to 100 axis.
Values quoted from the lesson's Key Terms table.
5. What is val loss in this capstone?Define
A horizontal bar split into 899 characters labelled data[:n] and 100 characters labelled data[n:], above a card with the three lines of main.py that define n, train_data and val_data.
Counts computed for the 999-character embedded excerpt.
6. Per "What we don't ship," how does the capstone handle positional information instead of RoPE?Explain
The five characters F, i, r, s, t as boxes at positions 0 to 4, with an arrow to a token vector box and another to a dashed box reading position signal question mark; a note says RoPE is not used.
Prompt characters from main.py's sample; how position enters is left open.
7. Why does the capstone's sampling loop recompute attention over the full prefix at every step?Explain
Three rows for generation steps 1 to 3 on the prompt First: the model receives 5, 6 and 7 characters respectively and outputs a space, k and n.
The sampled characters are the start of the sample printed by a real run of main.py.
8. In each Block, which order of operations does the pre-norm design use?Explain
Eight boxes inside a dashed Block outline in no particular order: SwiGLU FFN, RMSNorm, plus residual, x in, x out, causal attention, RMSNorm and plus residual.
Component names from the lesson; the layout order is arbitrary.
9. What does the baseline do in place of calling F.scaled_dot_product_attention?Explain
Three boxes q, k and v, each of shape (16, 4, 64, 16), feed an attention step box marked implementation question mark, which outputs y of the same shape.
Shapes follow the shipped config: batch 16, 4 heads, block_size 64, head size 16.
10. A fresh, untrained model on a 65-character vocabulary should start with a loss near which value?Apply
A card stating loss equals minus ln of p of the correct character, a 65-character vocabulary, an untrained model and a step 0 loss of question mark; beside it, loss axes from 0 to 6 with a dashed line at step 0.
The step 0 value is left open.
11. Which configuration does the lesson say has 803,584 parameters, and how does the shipped config compare?Apply
A table comparing the shipped config (3 layers, d_model 64, 4 heads, block_size 64) with the larger config (4 layers, d_model 128, 4 heads, block_size 64); the parameters row shows question marks for both.
Settings from the lesson's Target metrics; parameter counts left open.
12. Your step-0 loss prints near 88 instead of ln 65 (about 4.2). What explains it?Analyze
Two bars: a printed step-0 loss near 88 and a uniform-guess loss of about 4.2.
Values quoted from the stem and the lesson's Target metrics.
13. After 5,000 steps on the larger config you see train 1.7 and val 1.8 with both still falling. What should you conclude?Analyze
A line chart with train loss 2.1 at step 2000 and 1.7 at step 5000, and val loss 2.2 and 1.8 at the same steps, with val slightly above train in both.
Points from the lesson's Target metrics; the straight segments only join the two measured points.

Build It

You need PyTorch for this one. Install torch (a CPU build is fine) and open code/main.py. The script handles:

  • Reading tinyshakespeare.txt if it sits next to the script, otherwise falling back to a 999-character Shakespeare excerpt embedded in main.py. It does not download anything.
  • A character tokenizer built from sorted(set(text)): one id per distinct character, no bytes, no BPE.
  • Train/val split at 90/10.
  • A training loop in default precision (no autocast).
  • Sampling after training completes.

Step 1: data

python
text = open("tinyshakespeare.txt").read()
chars = sorted(set(text))
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
encode = lambda s: [stoi[c] for c in s]
decode = lambda xs: "".join(itos[x] for x in xs)

With the full tinyshakespeare.txt that gives 65 unique characters, a tiny vocabulary with no BPE and no tokenizer drama. With the embedded excerpt you get 46, because the excerpt contains fewer distinct characters. The script prints vocab_size so you can see which case you are in.

Step 2: model

The model is in code/main.py. The block is textbook from Lesson 05: pre-norm, RMSNorm, SwiGLU, causal multi-head attention (the CausalSelfAttention class). The parameter-count preview the script prints first puts the 4-layer, 4-head, d_model=128 config at ~800K parameters. The shipped 3-layer, d_model=64 config is much smaller, around 155K.

Step 3: training loop

Get a random batch of block_size-token windows (64 as shipped). Forward. Shift-by-one cross-entropy. Backward. Clip. AdamW step. Log every 100 steps. Repeat. AdamW with betas (0.9, 0.95) and weight decay 0.1 is the usual transformer recipe: the lower second beta reacts faster to noisy gradients, and the decay keeps weights from growing unchecked.

python
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, betas=(0.9, 0.95), weight_decay=0.1)
 
for step in range(max_steps):
    x, y = get_batch("train")
    _, loss = model(x, y)  # shift-by-one cross-entropy happens inside forward
    opt.zero_grad(set_to_none=True)
    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    opt.step()

Step 4: sample

Given a prompt, run a forward pass, keep only the top-k logits, sample from the softmax, append the token, and repeat. main.py prompts with "First", uses temperature=0.9 and top_k=10, and stops after 200 new tokens. There is no top-p sampling. generate() feeds the model only the last block_size tokens each step, so with block_size=64 anything past 64 tokens is generated from a sliding window of recent text, and a 500-token generation exceeds the model's context by a wide margin.

Step 5: read the output

Here is the start of the sample after 2,000 steps on the larger config, from a real run:

Firste atre whit haves as wirse,
Or heres you swer hom st her thare wive ore
Tountimim,
As inor ine th yod talais ometinces me, wee,

That isn't Shakespeare, but the line lengths, capitalisation and word shapes are right, which is about what ~800K parameters and 2,000 steps buy you. At 5,000 steps a few real words appear ("my", "mouse", "bring"). The sampler is seeded, so the same configuration reproduces this text on the same machine, but another machine or PyTorch build will print different text.

The shipped 500-step config on the embedded excerpt prints a step-0 loss near 41, train loss 1.95 and val loss 2.88 at step 500. Val loss bottoms out at step 300 (2.81) and then creeps back up while train loss keeps falling: the excerpt is 999 characters, so the model memorises it. That gap is why Exercise 1 sends you to the full file.

Check: Build It0/14
1. What is tinyshakespeare?Define
A file card named tinyshakespeare.txt with its size shown as a question mark, above a preview of the first lines of Shakespeare dialogue starting 'First Citizen:'.
The preview lines come from the excerpt embedded in main.py; the full file is not bundled.
2. What does gradient clipping do in this training loop?Define
A line chart of the global gradient norm over 500 steps: about 7 at the start, a peak near 12.8 shortly after step 100, then values around 1 and below by step 300.
Norms recorded by wrapping the clip call in a real run of main.py (norm before any clipping).
3. Why does the recipe use AdamW betas of (0.9, 0.95) instead of the default second beta?Explain
A card comparing the PyTorch default AdamW betas of 0.9 and 0.999 with the lesson's recipe of 0.9 and 0.95.
Default read from torch.optim.AdamW; recipe from the lesson's Step 3.
4. Which description matches the optimization in main.py as shipped?Explain
A card titled main.py listing five fields, optimizer, learning rate, weight decay, gradient clipping and precision, each with a question mark.
Field names only; the values are left open.
5. Why does generate() crop its input to the last block_size tokens at each step?Explain
A bar for 105 tokens: the first 41 are dashed and marked older tokens, and the last 64 are highlighted as the tokens passed to the model.
5 prompt tokens plus 100 generated gives 105; the last 64 are kept, as in idx[:, -block_size:].
6. With text = "cab", the Step 1 code builds chars = sorted(set(text)) and stoi from it. What does encode("cab") return?Apply
A code card with text equal to cab, chars built with sorted(set(text)), the stoi dictionary comprehension and the encode lambda, ending with encode of cab equal to a question mark.
Code from the lesson's Step 1 with the stem's text.
7. You run main.py with no tinyshakespeare.txt next to it. Which vocab_size will it most likely print?Apply
A terminal: ls lists only main.py, then python main.py prints the capstone header, device cpu, and vocab_size with a question mark.
The device line is from a real run on CPU; the vocab_size value is hidden on purpose.
8. You raise max_new_tokens to 500 in the shipped config (block_size=64). What happens during generation?Apply
A bar for 505 tokens split by a dashed line after token 64; the right part is dashed and reads tokens 65 to 505 question mark.
5 prompt tokens plus 500 new tokens gives 505.
9. In the training loop shown, which call runs between loss.backward() and opt.step()?Apply
Three boxes in a row: loss.backward(), a highlighted box with a question mark labelled which call runs, and opt.step().
Boxes show the order given in the stem; the middle call is left open.
10. generate() runs with temperature=0.9 and top_k=10. How many candidate tokens can be sampled at each step?Apply
65 vertical bars of next-character probability sorted from high to low, all drawn in the same colour, under the settings temperature 0.9 and top_k 10.
Illustrative: random logits for 65 characters, not output from a trained model.
11. On the embedded 999-character excerpt, train loss keeps falling but val loss bottoms out at step 300 and then rises. What is the diagnosis and the fix?Analyze
Line chart from step 100 to 500: train loss falls from 4.50 to 1.95; val loss falls from 4.75 to 2.81 at step 300, then reads 2.89 and 2.88.
Values printed by a real run of main.py on the 999-character excerpt.
12. Your 2,000-step sample reads "Firste atre whit haves as wirse,". Which reading is correct?Analyze
A terminal with four lines of generated text beginning 'Firste atre whit haves as wirse,'.
Text quoted from the lesson's Step 5.
13. You add a KV cache and time 500-token generation against the baseline. What must you handle to compare them fairly?Analyze
Two boxes side by side, baseline generate() and KV-cache generate(), each with tokens attended per step as a question mark; text says both produce 500 new tokens.
Setup from the stem; the quantity to match is left open.
14. You add RoPE (Exercise 2) and want to know whether it helped. Which plan fits the lesson's advice?Design
Two terminals side by side. Left, the baseline main.py log with step, train and val columns from 0 to 500. Right, a RoPE variant log with every train and val value a question mark.
The left log is a real run of main.py as shipped; the right one is empty on purpose.

Use It

The capstone is a reference architecture. Three extensions take it toward something real:

  1. Swap the tokenizer. Use BPE (e.g. tiktoken.get_encoding("cl100k_base")). Vocab size jumps from 65 to ~50,000, and model capacity needs to scale up to compensate.
  2. Train on a bigger corpus. Use OpenWebText or fineweb-edu (HuggingFace). 10B tokens on a single A100 takes ~24 hours for a 125M-param GPT.
  3. Add RoPE + KV cache + Flash Attention. The exercises below walk you through each.

Do all three and you end up with a 125M-parameter GPT that generates fluent English. It's not a frontier model, but the same code path, just bigger, is what Karpathy, EleutherAI, and the Allen Institute use to train research checkpoints in 2026.

Check: Use It0/2
1. Per Use It, what happens to model capacity needs if you swap the char-level tokenizer for BPE like cl100k_base?Explain
Two columns. Now: a character-level tokenizer feeding an embedding table with 65 rows. After the swap: a BPE tokenizer feeding an embedding table whose row count is a question mark; below, model capacity needed after the swap is a question mark.
Vocabulary size for the character-level case is from the lesson; the new size is left open.
2. You swap the character tokenizer for cl100k_base BPE and keep tied embeddings. What happens to the embedding parameters?Design
A character-level tokenizer with 65 ids is swapped for a cl100k_base BPE tokenizer; below, a token embedding box and an lm_head box each show shape after swap as a question mark, with a note that embeddings stay tied.
Shapes after the swap are left open.

Ship It

The skill in outputs/skill-transformer-review.md reviews a transformer-from-scratch implementation for correctness across all 13 prior lessons.

Check: Ship It0/1
1. What does this lesson's Ship It skill, transformer-review, do?Define
A card showing the front matter of skill-transformer-review.md: name transformer-review, version 1.0.0, phase 7, lesson 14, with a line naming its input as a transformer codebase and its product as a question mark.
Front matter copied from the skill file in the lesson's outputs.
Author's Note

I'd run main.py once before changing anything and keep its output as your baseline. It prints the parameter count, then train and val loss every 100 steps, so an exercise that claims to help (RoPE, MTP, MoE) has a number to beat.

My bet is that the KV cache exercise is the most satisfying one. generate() in main.py feeds the last block_size tokens back through the whole model on every step, which is the recompute-the-prefix behavior listed under What we don't ship. That makes the before-and-after timing in Exercise 3 a straightforward way to see what the cache buys you.

I'd also keep the tied embeddings in any variant you build. main.py points lm_head.weight at tok_emb.weight, so it is one tensor. When you change the vocab size, as in the BPE swap under Use It, that single matrix grows once and serves as both the input and output embedding.

Exercises

  1. Easy. Run code/main.py as shipped and keep the printed train and val losses as your baseline. Then scale up: put the full tinyshakespeare.txt next to the script, set n_layers=4, d_model=128, and max_steps=2000. Expect a final-step validation loss around 2.2 at 2,000 steps (2.16 and 2.19 in two of our runs). Then change max_steps to 5,000 and check that val loss keeps improving and drops under 2.0 (1.84 in ours). The embedded excerpt is far too small for a meaningful val loss.
  2. Medium. Replace learned positional embeddings with RoPE. Apply the rotation to Q and K inside CausalSelfAttention. Train and verify val loss is at least as low.
  3. Medium. Implement a KV cache in the sampling loop. Generate 500 tokens with and without the cache. Note that generate() crops to the last block_size tokens and the learned positional table has only block_size rows, so 500 tokens exceeds the shipped 64-token context. Either raise block_size and retrain, or give both versions the same sliding-window rule, so the timing comparison is fair. Expect the speedup to grow with sequence length (a 5-20x gain on a laptop is a rough estimate, not something main.py measures).
  4. Hard. Add a second head to the model that predicts the next-plus-one token (MTP, Multi-Token Prediction from DeepSeek-V3). main.py has no MTP head, so you write it. Train jointly. Does it help?
  5. Hard. Replace the single FFN per block with a 4-expert MoE. Use a router with top-2 routing. See how val loss changes at matched active parameters.
  6. Medium. Add a linear warmup followed by cosine decay to 10% of peak LR, and wrap the forward pass in bf16 autocast on supported hardware. main.py uses neither. Compare val loss against your baseline.

Key Terms

TermWhat people sayWhat it actually means
nanoGPT"Karpathy's tutorial repo"Minimal decoder-only transformer training code, ~300 LOC, and a common reference.
tinyshakespeare"The standard toy corpus"~1.1 MB of text, a common choice for character-LM tutorials since 2015.
Tied embeddings"Share input/output matrix"LM head weight is the transpose of the token embedding matrix, which saves parameters and improves quality.
bf16 autocast"Training precision trick"Run forward and backward in bf16 and keep optimizer state in fp32, widely used since 2021. Not in main.py; Exercise 6 adds it.
Gradient clipping"Stops spikes"Cap the global grad norm at 1.0 to guard against training blowups.
Cosine LR schedule"The 2020+ default"LR ramps up linearly (warmup), then decays cosine-shaped to 10% of peak. Not in main.py (constant LR); Exercise 6 adds it.
MFU"Model FLOP Utilization"Achieved FLOPs divided by theoretical peak. 40% dense and 30% MoE is strong in 2026.
Val loss"Held-out loss"Cross-entropy on data the model hasn't seen, which makes it an overfit detector.

Further Reading

Code

Ships With