Mini-GPT: End-to-End Language Model
Work through examples, try the reasoning yourself, then put it into practice.
View SlidesBefore you start
- Implement token windows, embeddings, attention, and transformer blocks.
- Verify unique parameter enumeration and stable cross-entropy.
What you will practice
- Trace the entire causal model with a shape and parameter ledger.
- Separate target alignment, gradient correctness, tiny-data fitting, and held-out evaluation.
- Generate from the final logit row while respecting the context limit.
In this lesson
Lesson
6 min readMini-GPT: Assemble a Model Whose Training You Can Explain
The capstone combines the components into a decoder-only language model. Its accomplishment is not convincing generated prose: it is a complete path from token IDs through a causal forward pass to a finite loss, correct gradients, an SGD update, and autoregressive generation.
Use tiny dimensions and short sequences. Scalar Value graphs are intentionally inspectable, but much slower and more memory-intensive than tensor kernels. Implement the supplied mini-gpt interface in stages rather than debugging all components at once.
Write the shape contract first
For vocabulary V, embedding width D, maximum context M, and current sequence length T:
| Stage | Shape | Shared across positions? |
|---|---|---|
| Input token IDs | (T,) |
Not parameters |
| Token lookup | (T,D) from (V,D) table |
Yes |
| Position lookup | (T,D) from (M,D) table |
Rows selected by position |
| Elementwise embedding sum | (T,D) |
— |
| Each causal transformer block | (T,D) |
Yes |
| Final LayerNorm | (T,D) |
Yes |
| Bias-free vocabulary projection | (T,V) |
Yes |
Require 1 <= T <= M for training and generation calls. Positions are 0..T-1 within this input window. The final LayerNorm is additional to each block's two normalizations. The vocabulary projection is Linear(D,V,bias=False); its weights are independent of the input embedding table in this exercise.
Use the full-input Q/K/V projections described in the transformer lesson. Register the list of blocks once; traversal must return each parameter identity once even if a child also has an alias.
Work a complete shape and parameter trace
Choose V=3, D=4, H=2, one block, and M=4. For IDs [0,1,2], T=3:
token vectors + position vectors: (3,4)
head projections: (3,2) per head
attention matrices: (3,3) per head
block result and final norm: (3,4)
vocabulary logits: (3,3)
Count unique trainable scalars before training:
token table 3*4 = 12
position table 4*4 = 16
one block = 244
final norm 2*4 = 8
output projection 3*4 = 12
total = 292
The block count is 12D*D + 13D with this pack's biased Q/K/V/output projections and biased FFN. Seeing 536 parameters for this model would suggest the 244-parameter block has been registered twice. That would make a simple optimizer update block parameters twice per step.
The objective is one prediction per position
Take a source stream [0,1,2,0]. Training input is [0,1,2], targets are [1,2,0]. Logit row 0 predicts token 1 after seeing input token 0. Row 1 predicts token 2 after seeing [0,1]. Row 2 predicts token 0 after seeing [0,1,2].
A single forward call computes all three predictions, but each row is constrained by the causal mask. If row 0 could see input token 1, it could read its own target and make the training objective misleadingly easy.
For uniform logits with V=3, each loss is log(3) ≈ 1.098612 and their mean is the same. With B equal-length sequences, average over B*T target positions. The exercise helper handles one sequence; a batch loop may average its per-sequence losses. Unequal-length sequences require weighting by the number of positions if the intended objective is a per-token mean.
Use shifted log-sum-exp directly for each target. Logits [1000,-1000] with target 1 must produce a finite loss near 2000, not a capped value from clamping a probability. Return the mean as a Value so every layer remains connected.
A minimal training experiment
Fix the random seed, a tiny model, and a small repeated token stream. Sample a valid shifted window, then:
logits = model(x)
loss = cross_entropy_loss(logits, y)
for p in model.parameters():
p.grad = 0.0
loss.backward()
for p in model.parameters():
p.data -= learning_rate * p.grad
Rebuild the graph on each iteration. Update only after all gradients for the step are ready. Record the pre-update loss and inspect several gradients from different parts of the model. A single zero gradient can be expected from a masked path, unused embedding row, or inactive ReLU; a whole disconnected parameter group needs investigation.
Before a longer run, finite-difference a selected output weight and compare it to autograd on a fixed example. Check that unique parameter identities match the count above. Then verify that a suitably small learning rate reduces loss on the tiny stream over several steps. One sample of generated text is not a correctness test.
Prove the model cannot look ahead
Run [0,1,2] and [0,1,0] through identical parameters. The first two logit rows must match because their visible prefixes are identical. The third row is allowed to differ. This exercises masking in every block and catches accidental normalization across positions.
This test is different from a target-shift check. A correctly causal model trained against unshifted targets still learns the wrong task. Check both independently.
Generate with a bounded context
Start with a nonempty seed and copy it. For each new token:
- Crop a model input to the most recent M IDs.
- Recompute logits with local position IDs beginning at zero.
- Take only the last row and divide by a finite positive temperature.
- Apply stable softmax, sample one ID, and append it to the copied sequence.
Return the full original seed plus all generated IDs. Cropping limits what the model sees; it does not delete earlier IDs from the returned sequence. With M=4 and a six-token seed, the first prediction uses the last four seed tokens, then shifts the window for the next prediction.
This implementation recomputes every prefix and has no key/value cache or gradient-disabled execution mode. Generation does not call backward or update weights. Cropped windows reset learned absolute positions, so this is the explicit inference convention, not unlimited context memory.
Diagnose the first failing invariant
| Symptom | Targeted check |
|---|---|
| Excellent training loss, poor next-token behavior | Inspect target shift and run prefix causality test |
Loss plateaus near log(V) |
Verify parameter registration and graph connectivity |
| Finite probabilities but wrong extreme loss | Compute cross-entropy in log space |
| Training changes when only aliases change | Count unique parameter identities |
| Failure once generation exceeds M | Crop context before position lookup |
Checkpoint. Should generation sample from every returned logit row?
Check your reasoning
no. Earlier rows predict tokens already supplied in the context. Only the last row predicts the next unseen token.
Completing the capstone means you can explain the contracts and validate their composition. Larger datasets and faster tensor implementations are subsequent engineering steps; they do not replace these invariants.
Practice sequence
Use the order below for a guided path, or choose an item to revisit. A pass records a successful attempt; try a fresh example later to check what you can reconstruct.
- Step 1
Mini-GPT: End-to-End Language Model
- Step 2
Mini-GPT: End-to-End Language Model Checkpoint
Apply the module contracts to concrete traces and diagnose failures.