Build and Train Your Own GPT-2
Lesson 4Attention + multi-head attention
The core mechanism, built from naked NumPy up to multi-head PyTorch
Attention + multi-head attention, the mechanism
Where we are in the build
After Lesson 3, every position in your sequence is a (768)-dim vector that knows both what token it is and where it sits. Today's lesson is the one that made transformers transformers: every position looks at every other position and decides who matters, then mixes their information into its own representation.
We build it three times, in increasing realism:
- Naked NumPy, no scaling, one head. Math you can read.
- Scaled dot-product attention. Show why the factor is not optional.
- Multi-head PyTorch,
n_headparallel attention computations sharing the samen_embdbudget.
After this lesson, your scaffold has attention.py with a MultiHeadAttention module. We do not add the causal mask yet, that's Lesson 5. Without it the model can peek at future tokens, which is wrong for language modelling but useful for letting us see attention's behaviour in isolation.
What you're building
attention.py, MultiHeadAttention
Input: (B, T, n_embd) → Output: (B, T, n_embd). Every output position is a learned weighted sum of every input position. Roughly 16% of GPT-2 small's parameters per block live here.
Step 1, attention from scratch in NumPy
Forget projections, forget heads, forget scaling. Start with the simplest possible question: how does one position pull information from every other position?
Each position produces three things:
- A query , "what am I looking for?"
- A key , "what do I have to offer?"
- A value , "what information do I carry?"
The score from position to position is the dot product , high if the query is aligned with that key, low otherwise. Softmax across all turns scores into a probability distribution. Position 's output is the weighted sum of all values, weighted by that distribution.
That's the entire mechanism. Three matrices, one matmul for scores, a softmax, one matmul for the weighted sum. Every transformer in production today does this exact thing, with optimisations layered on top.
Step through it — press Run to execute the NumPy in your browser while the animation builds the Q·Kᵀ scores, the row-wise softmax heatmap, and the weighted-value output:
Scaled dot-product attention: three matrices, one matmul for scores, a softmax, one matmul for the weighted sum. Press Run to execute the real NumPy; the animation builds the (T×T) attention heatmap and the blended output.
import numpy as npT, D = 4, 8x = np.random.randn(T, D) # T tokens × D # project to queries, keys, valuesQ = x @ W_q # (T, D)K = x @ W_kV = x @ W_v # scores: q_i · k_j, scaledscores = Q @ K.T / np.sqrt(D) # (T, T) # softmax row-wise → attention weightsweights = np.exp(scores) / np.exp(scores).sum(-1, keepdims=True) # weighted sum of valuesout = weights @ V # (T, D)
But there's a numerical bug hiding in those np.exp calls. Try it at full GPT-2 scale.
Step 2, why we scale by
Run the same code with instead of , the head dimension GPT-2 small actually uses:
T, D = 4, 64
W_q = np.random.randn(D, D)
W_k = np.random.randn(D, D)
x = np.random.randn(T, D)
Q = x @ W_q
K = x @ W_k
scores = Q @ K.T
print("score range:", scores.min(), scores.max())
weights = np.exp(scores) / np.exp(scores).sum(axis=-1, keepdims=True)
print("entropy of row 0:", -(weights[0] * np.log(weights[0] + 1e-10)).sum())The scores blow up to numbers like to . After softmax, each row puts ~99.99% of its mass on a single position. The "attention" is no longer a soft weighted sum, it's a hard argmax. Gradients through that softmax are essentially zero everywhere except the winning cell, which is the same vanishing-gradient problem we saw in Lesson 1.
Why does it happen? Each entry of is a dot product of two vectors with components each. If those components are roughly , the dot product has variance , its magnitude grows like . At that's already in the tens, and softmax saturates fast.
The fix from Vaswani et al. (2017): divide by before softmax, where is the per-head key dimension:
This rescales the dot products to roughly unit variance. The softmax stays in the soft regime. Gradients flow.
# One-line fix.
scores = Q @ K.T / np.sqrt(D)
weights = np.exp(scores) / np.exp(scores).sum(axis=-1, keepdims=True)
print("entropy of row 0:", -(weights[0] * np.log(weights[0] + 1e-10)).sum())
# entropy is back near log(T) = 1.39, the row is broadly distributedThat single division is why the original Transformer paper is titled "Attention Is All You Need" and not "Attention And A Numerical Hack You Need." The hack is built in.
Step 3, why multiple heads
A single attention layer with has M parameters and produces a single attention pattern per layer. Empirically, that's both expensive and expressively limited, different "kinds" of relationships (subject-verb agreement, coreference, lexical similarity) want different attention patterns.
Multi-head attention runs smaller attention computations in parallel, each with its own of dimension where . The outputs (each of dim ) are concatenated back to dim and passed through one more linear . Same total compute and parameter count as a single head of dim , but the model gets different attention patterns to combine.
For GPT-2 small:
| Quantity | Value |
|---|---|
n_embd () | 768 |
n_head () | 12 |
head_size () | |
| Per-block attention params | M |
| Fraction of GPT-2 small (124M) | ~16% per layer × 12 layers = ~19% in attention |
The factor of comes from three projections (, each ) plus the output projection , also . The number does not depend on : splitting one big -dim head into smaller -dim heads doesn't change the total weight budget, only how those weights are organised.
Code: attention.py
# nanogpt-from-scratch/attention.py (Lesson 4, no causal mask yet)
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
from config import GPTConfig
class MultiHeadAttention(nn.Module):
"""(B, T, n_embd) -> (B, T, n_embd)"""
def __init__(self, config: GPTConfig):
super().__init__()
assert config.n_embd % config.n_head == 0
self.n_head = config.n_head
self.n_embd = config.n_embd
self.head_size = config.head_size # C / H
# One fused projection: (B, T, C) -> (B, T, 3C). Q, K, V at once.
self.qkv = nn.Linear(config.n_embd, 3 * config.n_embd, bias=False)
self.proj = nn.Linear(config.n_embd, config.n_embd, bias=False)
self.attn_dropout = nn.Dropout(config.dropout)
self.resid_dropout = nn.Dropout(config.dropout)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, T, C = x.shape
H = self.n_head
D = self.head_size
q, k, v = self.qkv(x).split(self.n_embd, dim=-1)
# Reshape into heads: (B, T, C) -> (B, H, T, D) for parallel attention.
q = q.view(B, T, H, D).transpose(1, 2)
k = k.view(B, T, H, D).transpose(1, 2)
v = v.view(B, T, H, D).transpose(1, 2)
# Scaled dot-product: (B, H, T, T).
att = (q @ k.transpose(-2, -1)) / math.sqrt(D)
# NOTE: causal mask added in Lesson 5.
att = F.softmax(att, dim=-1)
att = self.attn_dropout(att)
y = att @ v # (B, H, T, D)
y = y.transpose(1, 2).contiguous().view(B, T, C)
return self.resid_dropout(self.proj(y))Three things worth flagging:
- One fused QKV projection is faster than three separate
Linear(C, C)s, same compute, better memory locality. bias=Falseon everyLinear. GPT-2 turns biases off in attention; they don't help and they cost parameters. The GPT-2 paper and nanoGPT both do this.- Two dropouts, one on the attention weights, one on the residual stream. We'll keep
dropout=0.0during pretraining (matches the GPT-2 paper) and only crank it up if we finetune later.
Click any query row to see where that token sends its attention across the keys, the live (T, T) heatmap this whole lesson is about:
"cat" distributes its attention across all tokens. Brighter cell = more attention. Click any token to switch.
Math by typing it
Single-head scaled dot-product attention:
with and . In GPT-2's self-attention, (the head dimension).
Multi-head attention runs copies in parallel and concatenates:
Why the variance argument matters: if have entries with mean 0 and variance 1, the dot product has variance . Standard deviation . Without scaling, softmax inputs grow with and saturate. Dividing by rescales to unit variance and keeps softmax soft.
Parameter count of one multi-head attention block (matching nanoGPT's no-bias setup):
For GPT-2 small (): parameters per block. Across 12 blocks, ~28.3M total, roughly 23% of the model.
Run it on your laptop
Your task
Open attention.py and paste the MultiHeadAttention class above. Then run the smoke test below, it should print a (2, 8, 768) output and a parameter count of 2,359,296.
import torch
from config import GPTConfig
from attention import MultiHeadAttention
config = GPTConfig()
attn = MultiHeadAttention(config)
# Fake input, pretend we already ran embeddings.
B, T, C = 2, 8, config.n_embd
x = torch.randn(B, T, C)
y = attn(x)
print("output shape:", y.shape) # torch.Size([2, 8, 768])
assert y.shape == x.shape, "attention should preserve (B, T, C)"
# Parameter check: should be exactly 4 * C^2.
n_params = sum(p.numel() for p in attn.parameters())
print(f"attention params: {n_params:,}") # 2,359,296
assert n_params == 4 * config.n_embd ** 2
# Sanity: feeding the same input twice gives the same output (no randomness in eval).
attn.eval()
y1 = attn(x)
y2 = attn(x)
assert torch.allclose(y1, y2)
# Sanity: every output position depends on every input position.
# Perturb x at position 0 and see if y changes at position 5.
x2 = x.clone()
x2[:, 0, :] += 1.0
y_pert = attn(x2)
print("y changed at pos 5?", not torch.allclose(y[:, 5, :], y_pert[:, 5, :])) # TrueThat last sanity check is important: without a causal mask, position 5's output does see position 0, and also position 6 and position 7. That's why we still need Lesson 5 to make this autoregressive.
Interactive build · pending wire-up
<attention-explorer />
In-browser cell: input a short sequence, watch the (T, T) attention heatmap for each of the 12 heads in real time. A second tab visualises Q, K, V projections as 2D scatter plots.
What you can now do
- Compute scaled dot-product attention from raw Q, K, V tensors and explain every term in the softmax.
- Write the multi-head reshape pattern:
(B, T, C) → (B, H, T, D) → attention → (B, T, C). - Reason about the parameter budget, exactly per block, regardless of how many heads you slice it into.
- Diagnose softmax saturation when is large and explain why scaling is the fix.
Quiz
Check your understanding
1 / 8In self-attention, what role do Q, K, and V play, and where do they all come from?
Practice it yourself
Nail the nanoGPT specifics — the param budget, the fused split, and the √dₖ fix. Real Python, hidden tests, no setup.
4·C² per block — and why n_head doesn't change it.
One Linear(C, 3C) → q, k, v by slicing the last axis.
Measure attention softness by row entropy — see the scale at work.
What's next
You can build a multi-head attention block. But it has a fatal flaw for language modelling: at training time, position can attend to positions , the future. That means the model can "cheat" and copy the next token from its input. Lesson 5 fixes that with a single line: a triangular causal mask added to the attention scores before softmax. After Lesson 5, the model is finally autoregressive, and ready to be wrapped in a residual block in Lesson 6.