TAI BUI
Back to curriculum
BuildPython

Pre-Training a Mini GPT (124M Parameters)

GPT-2 Small has 124 million parameters: 12 transformer layers, 12 attention heads, 768-dimensional embeddings. You can train it from scratch on a single GPU in a few hours. Few people do. They pull a pre-trained checkpoint and build on top of it. But if you haven't trained one yourself, you're building products on a model you only half understand.

TIME: ~120 minutesPREREQUISITES: Phase 10, Lessons 01-03 (Tokenizers, Building a Tokenizer, Data Pipelines)

Learning Objectives

  • Implement the full GPT-2 architecture (124M parameters) from scratch: token embeddings, positional embeddings, transformer blocks, and the language model head
  • Train a GPT model on a text corpus using next-token prediction with cross-entropy loss
  • Implement autoregressive text generation with temperature sampling, then extend it with top-k/top-p filtering in the exercises
  • Monitor training loss curves and validate that the model learns coherent language patterns
  • Estimate parameter counts and KV cache memory for the GPT-2 family and larger models

The Problem

You know what a transformer is. You've read the diagrams, you can recite "attention is all you need", and you can draw a box labeled "Multi-Head Attention" on a whiteboard. None of that tells you what happens when a model generates text.

GPT-2 Small has 124,438,272 parameters (with weight tying). A training loop set each one: forward pass, loss, backward pass, weight update. The shape is twelve transformer blocks, twelve attention heads per block, a 768-dimensional embedding space, and a vocabulary of 50,257 tokens. To generate a token, the model runs one chain of matrix multiplications that turns a sequence of token IDs into a probability distribution over the next token.

If you haven't built this yourself, the model is a black box. You can call the API and you can fine-tune, but when it hallucinates, repeats itself, or ignores an instruction, you have no mental model for why.

So we build GPT-2 Small from scratch, in numpy instead of PyTorch. Every matrix multiplication stays visible and the gradient code is yours to read. By the end you'll see how 124 million numbers turn into a prediction of the next word.

Warm-up0/3
1. A tokenized corpus is one long array of byte IDs. How do you turn a window of N+1 tokens into an input and a target for next-token prediction?Warm-up
Seven boxes holding the bytes of 'The tra', each with its character and byte value, bracketed as one window.
A 7-token window sliced from the corpus bytes.
2. A byte-level tokenizer is used in place of a learned BPE vocabulary. What is its vocabulary size, and can it hit an unknown token?Warm-up
The word cafe with an accent, shown above five boxes holding its raw UTF-8 byte values 99, 97, 102, 195, 169.
Encoding text as raw bytes, the input a byte-level tokenizer sees.
3. Your corpus is millions of tokens but the model's context window is 1,024. How does a data pipeline normally feed it to the model?Warm-up
A long tick-marked bar labeled corpus of millions of tokens above a much shorter box labeled 1,024 tokens, the context window.
The size mismatch a data pipeline has to bridge.

The Concept

The GPT Architecture

GPT is an autoregressive language model: it generates one token at a time, each conditioned on all the tokens before it. The architecture is a stack of transformer decoder blocks. Here is the full path from token IDs to next-token probabilities:

  1. Token IDs come in. Shape: (batch_size, seq_len).
  2. Token embedding lookup. Each ID maps to a 768-dimensional vector. Shape: (batch_size, seq_len, 768).
  3. Position embedding lookup. Each position (0, 1, 2, ...) maps to a 768-dimensional vector. Same shape.
  4. Add token embeddings + position embeddings.
  5. Pass through 12 transformer blocks.
  6. Final layer normalization.
  7. Linear projection to vocabulary size. Shape: (batch_size, seq_len, vocab_size).
  8. Softmax to get probabilities.

That is the whole model. There are no convolutions and no recurrence, just embeddings, attention, feedforward networks, and layer norms, stacked 12 times.

Mermaid diagram

The Transformer Block

All 12 blocks follow the same pre-norm pattern. GPT-2 normalizes before each sublayer, where the original transformer normalized after:

  1. LayerNorm
  2. Multi-Head Self-Attention
  3. Residual connection (add input back)
  4. LayerNorm
  5. Feed-Forward Network (MLP)
  6. Residual connection (add input back)

The residual connections carry a lot of weight. Without them, gradients tend to vanish before they reach block 1 during backpropagation. With them, gradients flow from the loss back to earlier layers through the skip path, which is why you can stack 12, 32, or 96 blocks (GPT-4 is rumored to use 120).

Attention: The Core Mechanism

Self-attention lets each token look at the earlier tokens and decide how much weight each one gets. For each token position, compute three vectors from the input:

  • Query (Q): "What am I looking for?"
  • Key (K): "What do I contain?"
  • Value (V): "What information do I carry?"
Q = input @ W_q    (768 -> 768)
K = input @ W_k    (768 -> 768)
V = input @ W_v    (768 -> 768)

attention_scores = Q @ K^T / sqrt(d_k)
attention_scores = mask(attention_scores)   # causal mask: -inf for future positions
attention_weights = softmax(attention_scores)
output = attention_weights @ V

The causal mask is what makes GPT autoregressive. Position 5 attends to positions 0-5 and nothing after. Without the mask, the model could cheat during training by reading the future tokens it is supposed to predict.

Multi-head attention splits the 768-dimensional space into 12 heads of 64 dimensions each, and each head can learn a different attention pattern. One might track syntax (subject-verb agreement), another semantic similarity, another nearby words. The 12 outputs are concatenated and projected back to 768 dimensions.

Mermaid diagram

The division by sqrt(d_k) (sqrt(64) = 8) is a scaling step from the original "Attention Is All You Need" paper. Without it, dot products grow large in high dimensions and push softmax into regions where gradients are nearly zero.

KV Cache: Why Inference Is Fast

Training processes the entire sequence at once. Inference generates one token at a time, and without optimization, generating token N means recomputing attention for all N-1 previous tokens. That is O(N^2) per generated token, or O(N^3) total for a sequence of length N.

The KV cache fixes this. After computing K and V for each token, store them. To generate token N+1, compute Q for the new token and look up the cached K and V for the earlier ones. That cuts the K and V computation per token from O(N) to O(1). The attention scores are still O(N) because the new token attends to every previous position, but the redundant matrix multiplications on the input are gone.

For GPT-2 with 12 layers and 12 heads, the KV cache stores 2 (K + V) x 12 layers x 12 heads x 64 dims = 18,432 values per token. For a 1024-token sequence, that is about 75MB in FP32. Llama 3 405B has 126 layers, and its KV cache for a single long sequence can exceed 10GB. That is one reason long-context inference is memory-bound.

Prefill vs Decode: Two Phases of Inference

Inference on a prompt runs in two phases.

Prefill processes your entire prompt in parallel. All tokens are known, so attention for every position is computed at once. This phase is compute-bound: the GPU spends its time on matrix multiplications. For a 1000-token prompt on an A100, prefill takes roughly 20-50ms.

Decode generates tokens one at a time, each depending on all the previous ones. This phase is memory-bound. The bottleneck is reading the model weights and KV cache from GPU memory, so the compute cores sit mostly idle waiting on memory. For GPT-2, a decode step takes about the same time whatever the matmuls cost in FLOPs, because memory bandwidth is the constraint.

The split matters in production. Prefill throughput scales with GPU compute (more FLOPS = faster prefill). Decode throughput scales with memory bandwidth (faster memory = faster decode). That is why NVIDIA's H100 focused on memory bandwidth improvements over the A100, since it directly speeds up token generation.

Mermaid diagram

The Training Loop

Training an LLM is next-token prediction: given tokens [0, 1, 2, ..., N-1], predict tokens [1, 2, 3, ..., N]. The loss is cross-entropy between the model's predicted distribution and the actual next token.

One training step:

  1. Forward pass: Run the batch through all 12 blocks. Get logits (pre-softmax scores) for each position.
  2. Compute loss: Cross-entropy between logits and target tokens (the input shifted by one position).
  3. Backward pass: Compute gradients for all 124M parameters using backpropagation.
  4. Optimizer step: Update weights. GPT-2 uses Adam with learning rate warmup and cosine decay.

The learning rate schedule matters more than it looks. GPT-2 warms up from 0 to the peak learning rate over the first 2,000 steps, then decays along a cosine curve. Starting at a high rate can make the model diverge, and holding a high rate makes it oscillate late in training. Warmup then decay is the pattern used by every major LLM.

GPT-2 Small: The Numbers

ComponentShapeParameters
Token embeddings(50257, 768)38,597,376
Position embeddings(1024, 768)786,432
Per-block attention (W_q, W_k, W_v, W_out)4 x (768, 768)2,359,296
Per-block FFN (up + down)(768, 3072) + (3072, 768)4,718,592
Per-block LayerNorms (2x)2 x 768 x 23,072
Final LayerNorm768 x 21,536
Total per block7,080,960
Total (12 blocks)85,054,464 + 39,383,808 = 124,438,272

The output projection (logits head) shares weights with the token embedding matrix. This is called weight tying. It cuts the parameter count by 38M and improves performance, because the model uses one representation space for both input and output.

Check: The Concept0/29
1. What does it mean that GPT is autoregressive?Define
Four rows, each showing the context growing by one box and a new highlighted box appended at the end.
One new token produced per step as the context grows.
2. What does the causal attention mask do in GPT?Define
A 5 by 5 grid of query row by key column cells; cells above the diagonal are shaded and marked with a large negative value, the rest show 0.
The real mask array added to attention scores before softmax.
3. What is multi-head attention in GPT-2 Small?Define
A box labeled one token's 768-dim vector with an arrow down to 12 small boxes labeled h1 through h12, each marked 64.
The 768-dimensional vector divided into 12 equal slices.
4. What is a KV cache?Define
Step t shows K0, K1, K2 stored in a cache strip; step t+1 shows K0 through K2 greyed out as already known plus a new K3.
Keys and values from earlier tokens carried over to the next step.
5. What is prefill?Define
Six token boxes labeled t0 through t5, each with an arrow converging at the same time into a box labeled matrix multiply array.
Every prompt token entering the model in one pass.
6. What is decode?Define
A single token box with an arrow into a box labeled model, and a dashed arrow looping back to represent the next step.
One token produced, then the step happens again for the next one.
7. What is weight tying in MiniGPT?Define
The real code line logits = x at embedding.token_embed.T, with the shapes of x and the token_embed matrix it multiplies against.
The exact Build It line that computes the final logits.
8. What is a residual connection in a transformer block?Define
A box labeled x feeding a box labeled sublayer, with a line looping from x around the sublayer to merge with its output at a plus node.
A sublayer with its input routed around it.
9. What does layer normalization do to a token's vector?Define
Two rows of eight bars with their numeric values: a before set with mixed sizes, and an after set recentered and rescaled.
A token vector before and after normalizing across its features.
10. What does cross-entropy loss compute at each position when training GPT?Define
Five probability bars over candidate next tokens a through e, with the bar for token c marked as the actual next token.
A predicted distribution and the token that really came next.
11. Why are the attention scores divided by sqrt(d_k)?Explain
The formula scores equals Q times K transpose, divided by the square root of 64, which is 8.
The scaling step applied to raw attention scores.
12. How does GPT-2's placement of layer normalization differ from the original transformer's?Explain
A left-to-right chain: x, LayerNorm, Attention, plus x, LayerNorm, FFN, plus x.
The real order of operations inside one transformer block.
13. Why do residual connections matter in a 12-block transformer?Explain
A stack of 12 thin blocks labeled 12 down to 1, with loss connected only to block 12 at the top.
The question the picture leaves open: how gradient information travels down the stack.
14. Why are position embeddings added to the token embeddings?Explain
Two rows of five bars with identical heights, one labeled the word the at position 3 and the other at position 9.
One token's embedding, looked up at two different spots in a sequence.
15. What does the KV cache save, and what stays O(N) for each new token?Explain
A new query box on the right with arrows fanning out to six stored key boxes K0 through K5.
One new query scoring against every previously cached key.
16. Why is prefill called compute-bound while decode is called memory-bound?Explain
Side by side: five tokens converging at once on the left labeled prefill, versus one token looping through the model on the right labeled decode.
The structural difference between processing a prompt and generating tokens.
17. What learning rate schedule does GPT-2 use, and why not a constant rate?Explain
A line that rises from zero to a peak over the first part of training, then eases back down for the rest, shown schematically.
The overall shape of a learning rate schedule.
18. GPT-2 Small stores 2 (K and V) x 12 layers x 12 heads x 64 dims for each token. Roughly how much FP32 KV cache does a 1,024-token sequence need?Apply
A table of the KV cache inputs: 2 for K and V, 12 layers, 12 heads, 64 dims per head, a 1,024-token sequence, FP32 precision.
The raw quantities the stem gives, before any multiplication.
19. Per-block attention in GPT-2 Small is W_q, W_k, W_v and W_out, each (768, 768). How many parameters is that?Apply
Four boxes labeled W_q, W_k, W_v and W_out, each marked with shape 768 by 768.
The four weight matrices that make up one block's attention.
20. GPT-2 Small without weight tying would need a separate (768, 50257) output matrix. About how many extra parameters would that add?Apply
A single box labeled output projection with shape 768 by 50257, described as a matrix with no weight tying.
The shape of the output matrix the stem describes.
21. A modified model drops the causal mask. Training loss falls to nearly zero quickly, yet generated text is gibberish. What explains this?Analyze
An illustrative loss curve dropping steeply next to a row of garbled characters labeled generated text.
The two symptoms the stem describes: a fast-falling loss and gibberish output.
22. A deep transformer is built so each sublayer returns attn(ln1(x)) with no x + added. The earliest blocks barely change during training. Which ingredient is missing?Analyze
A chain x, LayerNorm, Attention, ending there with nothing added back to the chain's input.
The modified sublayer the stem describes, with the addition left out.
23. A server has room for the model weights but runs out of GPU memory when it serves a few very long sequences. Which part of the lesson explains it?Analyze
A memory bar split into a fixed segment for model weights and a larger segment for per-sequence state that grows with length.
Where GPU memory goes when serving a few very long sequences.
24. A GPT training run uses the peak learning rate from step 0 and the loss blows up within the first few hundred steps. Which part of the schedule was skipped?Analyze
An illustrative loss curve shooting upward within the first few hundred steps.
The symptom the stem describes: training that diverges almost immediately.
25. You serve GPT-2 style models and notice decode is slow while GPU compute utilization sits low. Which hardware change helps token generation most?Design
A mostly empty utilization gauge labeled compute units active during decode.
The symptom: low compute utilization while token generation is slow.
26. A hypothetical model has 24 layers, 16 heads and 64 dims per head. Using the lesson's formula, about how large is its FP32 KV cache for 2,048 tokens?Design
A table of KV cache inputs: 24 layers, 16 heads, 64 dims per head, a 2,048-token sequence, FP32 precision.
The raw quantities for the hypothetical model, before any multiplication.
27. One service has short prompts and long replies, another has very long prompts and short replies. Which resource limits each?Design
Two illustrative services: service A has a small prompt box and a tall reply box, service B has a tall prompt box and a small reply box.
The shape of each service's prompt and reply token counts.
28. You add a KV cache to generate (exercise 3). After the prompt is processed, what should each new step do?Design
Five cached key-value boxes for positions 0 through 4, with a new sixth token arriving and nothing yet computed for it.
The cache state right before a new generation step.
29. You must cut a GPT-2 style model by about 38M parameters without removing blocks or shrinking the 768 width. Which change fits?Design
A table of fixed constraints: 12 blocks, 768 width, and a target cut of about 38M parameters.
The constraints and the target the stem sets up.

Build It

Step 1: Embedding Layer

Token embeddings map each of the 50,257 tokens to a 768-dimensional vector. Position embeddings say where each token sits in the sequence. The two are summed.

python
import numpy as np
 
class Embedding:
    def __init__(self, vocab_size, embed_dim, max_seq_len):
        self.token_embed = np.random.randn(vocab_size, embed_dim) * 0.02
        self.pos_embed = np.random.randn(max_seq_len, embed_dim) * 0.02
 
    def forward(self, token_ids):
        seq_len = token_ids.shape[-1]
        tok_emb = self.token_embed[token_ids]
        pos_emb = self.pos_embed[:seq_len]
        return tok_emb + pos_emb

The 0.02 standard deviation for initialization comes from the GPT-2 paper. Too large and the initial forward passes produce extreme values that destabilize training. Too small and the initial outputs barely differ across inputs, so the early gradients carry little information.

Step 2: Self-Attention with Causal Mask

Single-head attention first. The causal mask sets future positions to negative infinity before softmax, so each position attends only to itself and earlier positions.

python
def attention(Q, K, V, mask=None):
    d_k = Q.shape[-1]
    scores = Q @ K.transpose(0, -1, -2 if Q.ndim == 4 else 1) / np.sqrt(d_k)
    if mask is not None:
        scores = scores + mask
    weights = np.exp(scores - scores.max(axis=-1, keepdims=True))
    weights = weights / weights.sum(axis=-1, keepdims=True)
    return weights @ V

The softmax subtracts the maximum before exponentiating, because exp of a large number overflows to infinity. The output is unchanged, since softmax(x - c) = softmax(x) for a constant c.

Step 3: Multi-Head Attention

Split the 768-dimensional input into 12 heads of 64 dimensions, run attention in each head independently, concatenate the results, and project back to 768.

python
class MultiHeadAttention:
    def __init__(self, embed_dim, num_heads):
        self.num_heads = num_heads
        self.head_dim = embed_dim // num_heads
        self.W_q = np.random.randn(embed_dim, embed_dim) * 0.02
        self.W_k = np.random.randn(embed_dim, embed_dim) * 0.02
        self.W_v = np.random.randn(embed_dim, embed_dim) * 0.02
        self.W_out = np.random.randn(embed_dim, embed_dim) * 0.02
 
    def forward(self, x, mask=None):
        batch, seq_len, d = x.shape
        Q = (x @ self.W_q).reshape(batch, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
        K = (x @ self.W_k).reshape(batch, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
        V = (x @ self.W_v).reshape(batch, seq_len, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
 
        scores = Q @ K.transpose(0, 1, 3, 2) / np.sqrt(self.head_dim)
        if mask is not None:
            scores = scores + mask
        weights = np.exp(scores - scores.max(axis=-1, keepdims=True))
        weights = weights / weights.sum(axis=-1, keepdims=True)
        attn_out = weights @ V
 
        attn_out = attn_out.transpose(0, 2, 1, 3).reshape(batch, seq_len, d)
        return attn_out @ self.W_out

The reshape-transpose-reshape dance is the most confusing part of multi-head attention. The (batch, seq_len, 768) tensor becomes (batch, seq_len, 12, 64), then (batch, 12, seq_len, 64), so each head has its own (seq_len, 64) matrix to run attention on. Afterward the steps reverse, ending back at (batch, seq_len, 768).

Step 4: Transformer Block

One complete transformer block: LayerNorm, multi-head attention with residual, LayerNorm, feedforward with residual.

python
class LayerNorm:
    def __init__(self, dim, eps=1e-5):
        self.gamma = np.ones(dim)
        self.beta = np.zeros(dim)
        self.eps = eps
 
    def forward(self, x):
        mean = x.mean(axis=-1, keepdims=True)
        var = x.var(axis=-1, keepdims=True)
        return self.gamma * (x - mean) / np.sqrt(var + self.eps) + self.beta
 
 
class FeedForward:
    def __init__(self, embed_dim, ff_dim):
        self.W1 = np.random.randn(embed_dim, ff_dim) * 0.02
        self.b1 = np.zeros(ff_dim)
        self.W2 = np.random.randn(ff_dim, embed_dim) * 0.02
        self.b2 = np.zeros(embed_dim)
 
    def forward(self, x):
        h = x @ self.W1 + self.b1
        h = np.maximum(0, h)  # GELU approximation: ReLU for simplicity
        return h @ self.W2 + self.b2
 
 
class TransformerBlock:
    def __init__(self, embed_dim, num_heads, ff_dim):
        self.ln1 = LayerNorm(embed_dim)
        self.attn = MultiHeadAttention(embed_dim, num_heads)
        self.ln2 = LayerNorm(embed_dim)
        self.ffn = FeedForward(embed_dim, ff_dim)
 
    def forward(self, x, mask=None):
        x = x + self.attn.forward(self.ln1.forward(x), mask)
        x = x + self.ffn.forward(self.ln2.forward(x))
        return x

The feedforward network expands the 768-dimensional input to 3,072 dimensions (4x), applies a nonlinearity, then projects back to 768. The expansion gives each position a wider internal representation. GPT-2 uses GELU and this code uses ReLU for simplicity, which doesn't change how the architecture works.

Step 5: Full GPT Model

Stack 12 transformer blocks. Add the embedding layer at the front and the output projection at the back.

python
class MiniGPT:
    def __init__(self, vocab_size=50257, embed_dim=768, num_heads=12,
                 num_layers=12, max_seq_len=1024, ff_dim=3072):
        self.embedding = Embedding(vocab_size, embed_dim, max_seq_len)
        self.blocks = [
            TransformerBlock(embed_dim, num_heads, ff_dim)
            for _ in range(num_layers)
        ]
        self.ln_f = LayerNorm(embed_dim)
        self.vocab_size = vocab_size
        self.embed_dim = embed_dim
 
    def forward(self, token_ids):
        seq_len = token_ids.shape[-1]
        mask = np.triu(np.full((seq_len, seq_len), -1e9), k=1)
 
        x = self.embedding.forward(token_ids)
        for block in self.blocks:
            x = block.forward(x, mask)
        x = self.ln_f.forward(x)
 
        logits = x @ self.embedding.token_embed.T
        return logits
 
    def count_parameters(self):
        total = 0
        total += self.embedding.token_embed.size
        total += self.embedding.pos_embed.size
        for block in self.blocks:
            total += block.attn.W_q.size + block.attn.W_k.size
            total += block.attn.W_v.size + block.attn.W_out.size
            total += block.ffn.W1.size + block.ffn.b1.size
            total += block.ffn.W2.size + block.ffn.b2.size
            total += block.ln1.gamma.size + block.ln1.beta.size
            total += block.ln2.gamma.size + block.ln2.beta.size
        total += self.ln_f.gamma.size + self.ln_f.beta.size
        return total

Look at the weight tying: logits = x @ self.embedding.token_embed.T. The output projection reuses the token embedding matrix, transposed. That saves parameters, but it also means the model uses the same vector space for understanding tokens (embeddings) and predicting them (output).

Step 6: Training Loop

A real 124M-parameter run needs a GPU and PyTorch. This loop shows the mechanics on a small model in pure numpy, with a tiny config (4 layers, 4 heads, 128 dims) to keep it tractable.

python
def cross_entropy_loss(logits, targets):
    batch, seq_len, vocab_size = logits.shape
    logits_flat = logits.reshape(-1, vocab_size)
    targets_flat = targets.reshape(-1)
 
    max_logits = logits_flat.max(axis=-1, keepdims=True)
    log_softmax = logits_flat - max_logits - np.log(
        np.exp(logits_flat - max_logits).sum(axis=-1, keepdims=True)
    )
 
    loss = -log_softmax[np.arange(len(targets_flat)), targets_flat].mean()
    return loss
 
 
def train_mini_gpt(text, vocab_size=256, embed_dim=128, num_heads=4,
                   num_layers=4, seq_len=64, num_steps=200, lr=3e-4):
    tokens = np.array(list(text.encode("utf-8")[:2048]))
    model = MiniGPT(
        vocab_size=vocab_size, embed_dim=embed_dim, num_heads=num_heads,
        num_layers=num_layers, max_seq_len=seq_len, ff_dim=embed_dim * 4
    )
 
    print(f"Model parameters: {model.count_parameters():,}")
    print(f"Training tokens: {len(tokens):,}")
    print(f"Config: {num_layers} layers, {num_heads} heads, {embed_dim} dims")
    print()
 
    for step in range(num_steps):
        start_idx = np.random.randint(0, max(1, len(tokens) - seq_len - 1))
        batch_tokens = tokens[start_idx:start_idx + seq_len + 1]
 
        input_ids = batch_tokens[:-1].reshape(1, -1)
        target_ids = batch_tokens[1:].reshape(1, -1)
 
        logits = model.forward(input_ids)
        loss = cross_entropy_loss(logits, target_ids)
 
        if step % 20 == 0:
            print(f"Step {step:4d} | Loss: {loss:.4f}")
 
    return model

The loss starts near ln(vocab_size), which for a 256-token byte-level vocabulary is ln(256) = 5.55. A random model assigns equal probability to every token. As training progresses the loss drops because the model learns common patterns, like "h" after "t" or a space after a period. In the 200-step demo it only falls from about 5.57 to about 5.05, so it has barely started.

The listing above stops at the loss to keep the focus on the forward pass. The train_mini_gpt in main.py also runs a hand-written backward pass and a plain SGD update (learning rate 3e-4), but only for the FFN weights, the second LayerNorm in each block, the final LayerNorm, and the token embeddings. Attention weights, the first LayerNorm in each block and the position embeddings stay at their random initial values. A production run adds Adam, gradient accumulation, learning rate warmup, and gradient clipping. The forward, loss, backward, update loop stays the same and only the optimizer gets fancier.

Step 7: Text Generation

Generation predicts one token at a time. Each prediction is sampled from the output distribution, or taken greedily as the argmax.

python
def generate(model, prompt_tokens, max_new_tokens=100, temperature=0.8):
    tokens = list(prompt_tokens)
    seq_len = model.embedding.pos_embed.shape[0]
 
    for _ in range(max_new_tokens):
        context = np.array(tokens[-seq_len:]).reshape(1, -1)
        logits = model.forward(context)
        next_logits = logits[0, -1, :]
 
        next_logits = next_logits / temperature
        probs = np.exp(next_logits - next_logits.max())
        probs = probs / probs.sum()
 
        next_token = np.random.choice(len(probs), p=probs)
        tokens.append(next_token)
 
    return tokens

Temperature controls randomness. At 1.0 you sample from the raw distribution. At 0.5 the distribution sharpens and the top choices win more often. At 1.5 it flattens and low-probability tokens get a bigger chance. At 0.0 you get greedy decoding, the highest-probability token.

The tokens[-seq_len:] window exists because the model has a maximum context length (1024 for GPT-2), and past that the oldest tokens get dropped. That is the "context window" people talk about.

Check: Build It0/12
1. What does temperature control during generation?Define
Two rows of five bars over the same five options: distribution A has one tall bar and four short ones, distribution B is closer to even.
The same choices, scored two different ways.
2. In the Build It MultiHeadAttention, how does a (batch, seq_len, 768) tensor become one matrix per head?Explain
A box labeled batch, seq_len, 768 with a note that 768 equals 12 heads times 64 dims.
The starting shape before MultiHeadAttention splits it.
3. Why does the attention code subtract the row maximum before exponentiating?Explain
A terminal showing np.exp on raw scores overflowing to infinity, versus np.exp after subtracting the row maximum giving finite values.
A real run showing why the maximum gets subtracted first.
4. Why does generate pass only tokens[-seq_len:] to the model?Explain
A long strip split into a greyed-out left section marked dropped and a highlighted right section of 1,024 tokens marked kept.
The window generate() actually feeds to the model once the text grows past the context length.
5. A byte-level MiniGPT starts out assigning equal probability to all 256 tokens. What loss do you expect at step 0?Apply
Sixteen identical-height bars, representing a sample of the 256 possible byte tokens, each given the same probability.
The model's untrained, perfectly uniform guess over the byte vocabulary.
6. train_mini_gpt uses embed_dim=128 and num_heads=4. What is head_dim in MultiHeadAttention?Apply
A 128-dim vector drawn as four equal, unlabeled segments, one per head.
The demo config's embedding split evenly across 4 heads.
7. For seq_len = 4, np.triu(np.full((4, 4), -1e9), k=1) is used as the mask. How many of the 16 entries hold -1e9?Apply
A 4 by 4 grid where some cells above the diagonal are shaded with a large negative value and the rest show 0.
The real mask array for a 4-token sequence.
8. A batch of 2 sequences of 10 token IDs goes through MiniGPT with the default settings. What shape do the logits have?Apply
Two rows of 10 small boxes, each row a sequence, with an arrow down into a box marked logits: question mark.
A batch of 2 sequences of 10 tokens, before the final projection.
9. In train_mini_gpt with seq_len=64, batch_tokens holds 65 bytes. What shapes do input_ids and target_ids have?Apply
A single strip of tick marks labeled batch_tokens: 65 bytes.
The real slice length train_mini_gpt samples each step.
10. Logits [2.0, 1.0] are divided by temperature 0.5 before softmax. What happens to the gap between them?Apply
Two bars labeled 2.0 and 1.0, with a note to divide both by temperature 0.5.
The logits the stem gives, before the division happens.
11. In main.py the training loss falls steadily. Does that show attention has learned useful patterns?Analyze
A real, gently falling loss curve over 200 training steps, with no detail about which weights changed.
The one signal available during this run: the loss number.
12. You need generate to give near-deterministic answers for a factual lookup tool. Which setting fits?Design
A number line from 0.0 to 1.5 with five marked reference points, none of them highlighted.
The range of values generate()'s temperature argument can take.

Use It

Full Training and Generation Demo

python
corpus = """The transformer architecture has revolutionized natural language processing.
Attention mechanisms allow the model to focus on relevant parts of the input.
Self-attention computes relationships between all pairs of positions in a sequence.
Multi-head attention splits the representation into multiple subspaces.
Each attention head can learn different types of relationships.
The feedforward network provides nonlinear transformations at each position.
Residual connections enable gradient flow through deep networks.
Layer normalization stabilizes training by normalizing activations.
Position embeddings give the model information about token ordering.
The causal mask ensures autoregressive generation during training.
Pre-training on large text corpora teaches the model general language understanding.
Fine-tuning adapts the pre-trained model to specific downstream tasks."""
 
model = train_mini_gpt(corpus, num_steps=200)
 
prompt = list("The transformer".encode("utf-8"))
output_tokens = generate(model, prompt, max_new_tokens=100, temperature=0.8)
generated_text = bytes(output_tokens).decode("utf-8", errors="replace")
print(f"\nGenerated: {generated_text}")

Expect noise, not prose. The corpus is about 880 bytes, the run is 200 steps, and the update is partial SGD, so the loss only slips from about 5.57 to about 5.05 and the generated text is mostly random bytes after the prompt. GPT-2 gets its quality from 40GB of training data and the full 124M-parameter architecture. Output quality isn't the point. You can trace each step: embedding lookup, attention, feedforward, logit projection, softmax, sampling.

Check: Use It0/1
1. After 200 steps the demo loss moved only from about 5.57 to about 5.05 and the output is mostly random bytes. What explains this?Analyze
A real loss curve from running the lesson's training demo, drifting down only slightly over 200 steps.
The actual loss trace from train_mini_gpt, step 0 through 180.

Ship It

This lesson produces outputs/prompt-gpt-architecture-analyzer.md, a prompt that analyzes the architecture choices in a GPT-style model. Feed it a model card or technical report and it breaks down the parameter allocation, attention design, and scaling decisions.

Author's Note

I'd run main.py before rereading the architecture. It prints a parameter breakdown and a memory estimate, then trains the small 4-layer, 128-dim config on a short corpus and generates from a prompt. Its GPT-2 Small row comes out at 124,402,944, not the 124,438,272 in the table, so it's worth working out where the gap comes from instead of trusting either number.

Read the training loop with one thing in mind: the backward pass in main.py is hand-written, and it only reaches the feedforward weights, the second layer norm in each block, the final layer norm, and the token embedding. The attention weights, the first layer norm in each block, and the position embeddings stay where they started, and the update is plain SGD, not Adam. So the printed loss is a real next-token loss, but a falling curve doesn't show that attention has learned anything. Exercise 5 is a good place to see how far that gets you.

My bet is that weight tying is the line worth rereading until it feels boring. logits = x @ self.embedding.token_embed.T reuses the embedding matrix as the output head, which is where the 38M saving comes from. Once you see that, the parameter table stops looking like a list of numbers to memorize.

I'd also keep the ReLU in proportion. It stands in for GELU here, and I'd swap it back as soon as you want numbers that compare with GPT-2. That is exercise 2.

Exercises

  1. Modify the model to use 24 layers and 16 heads instead of 12/12. Count the parameters. How does doubling the depth compare to doubling the width (embedding dimension)?

  2. Implement the GELU activation function (GELU(x) = x * 0.5 * (1 + erf(x / sqrt(2)))) and replace the ReLU in the feedforward network. Run training for 500 steps with each activation and compare the final loss.

  3. Add a KV cache to the generation function. Store K and V tensors for each layer after the first forward pass, and reuse them for subsequent tokens. Measure the speedup: generate 200 tokens with and without the cache and compare wall-clock time.

  4. Implement top-k sampling (only consider the k highest-probability tokens) and top-p sampling (nucleus sampling: consider the smallest set of tokens whose cumulative probability exceeds p). Compare the output quality at temperature 0.8 with top-k=50 vs top-p=0.95.

  5. Build a training loss curve plotter. Train the model for 1000 steps and plot loss vs step. Identify the three phases: rapid initial descent (learning common bytes), slower middle phase (learning byte patterns), and plateau (overfitting on the small corpus). The shape of this curve is the same whether you are training a 128-dim model or GPT-4.

Key Terms

TermWhat people sayWhat it actually means
Autoregressive"It generates one word at a time"Each output token is conditioned on all previous tokens so the model predicts P(token_n | token_0, ..., token_{n-1})
Causal mask"It can't see the future"An upper-triangular matrix of -infinity values that prevents attention to future positions during training
Multi-head attention"Multiple attention patterns"Splitting Q, K, V into parallel heads (e.g., 12 heads of 64 dims each for GPT-2) so each head can learn different relationship types
KV Cache"Caching for speed"Storing computed Key and Value tensors from previous tokens to avoid redundant computation during autoregressive generation
Prefill"Processing the prompt"The first inference phase where all prompt tokens are processed in parallel, compute-bound on GPU FLOPS
Decode"Generating tokens"The second inference phase where tokens are generated one at a time, memory-bound on GPU bandwidth
Weight tying"Sharing embeddings"Using the same matrix for input token embeddings and the output projection head, which saves 38M params in GPT-2
Residual connection"Skip connection"Adding the input directly to the output of a sublayer (x + sublayer(x)), which enables gradient flow in deep networks
Layer normalization"Normalizing activations"Normalizing across the feature dimension to mean 0 and variance 1, with learnable scale and bias parameters
Cross-entropy loss"How wrong the predictions are"-log(probability assigned to the correct next token), averaged over all positions. The standard LLM training objective

Further Reading

Code

Ships With