Part II — Attention
Causal & multi-head attention
Lesson 05 gave us a trainable attention layer: three projections Wq, Wk, Wv and the derived 1/√d_k scale. But that layer has two fatal flaws for a language model — it lets a token read the future it is supposed to predict, and it computes only one notion of "relevant." This lesson fixes both with a causal mask and multiple heads, and adds dropout to keep the weights honest during training.
New idea: a causal mask sets the upper triangle of the score matrix to −∞ before softmax, so each position attends only to itself and the past; dropout randomly zeros attention weights during training to prevent co-adaptation; and multi-head attention splits d into h parallel heads of width d/h, giving h different views at essentially the same cost.
Forces next: attention only mixes information across positions — it applies no per-position nonlinear transform, and a naive deep stack of these layers will not train. Lesson 07 wraps attention in a feed-forward MLP, LayerNorm, and residual connections to make the block deep-stackable.
1 · The peeking problem
Recall the whole point of the model from lesson 00: it computes p(xt+1 | x1 … xt). The target at position t is the token that actually comes next, xt+1 (lesson 03 gave us that label for free by shifting the sequence). Training slides this prediction across every position at once: position 1 predicts token 2, position 2 predicts token 3, and so on — all in a single forward pass, which is what makes training efficient.
Here is the danger. In lesson 05, the context vector for position t is a blend over all positions, including t+1, t+2, … Those later positions carry the very tokens we are asking the model to predict. If attention at position t can look at position t+1, the model can route the answer straight through and drive its training loss to zero without learning any real structure. This is label leakage, and it produces a model that is useless the moment you deploy it:
So we need to enforce a hard rule inside attention: position t attends only to positions 1 … t. Everything strictly to its right must contribute exactly zero weight. This is called causal (or masked) attention — causal because information flows only forward in time, never backward from the future.
2 · The causal mask
Lay out the T×T score matrix from lesson 05, where entry S[i][j] is how much query i attends to key j. The rule "position i may only see j ≤ i" means every entry above the main diagonal (where j > i) must vanish. That upper triangle is exactly the set of "future" cells.
The clean way to kill those cells is to set them to negative infinity before the softmax:
Softmax exponentiates each score, and e−∞ = 0, so every masked cell receives probability exactly 0. The surviving cells in each row are exponentiated and divided by their own sum, so each row still sums to 1 — but now only over the legal, past-and-present positions. Row 1 attends only to itself; row 2 splits its weight over positions 1 and 2; the last row is the only one that sees the whole sequence.
Concretely, both routes reach the same numbers, but the pre-softmax route is one operation instead of three. In code it is a triangular boolean mask filled with −∞:
# scores S: (T, T) from lesson 05, S = (Q @ K.T) / sqrt(d_k)
mask = triu(ones(T, T), diagonal=1) # 1s strictly above the diagonal
S = S.masked_fill(mask == 1, -inf) # future cells -> -inf
A = softmax(S, axis=-1) # rows sum to 1 over the past only
Z = A @ V # causal context vectors
One subtlety often trips people up: is it really "no leakage" if the future values are still sitting in the V matrix? Yes. Because their attention weights are exactly 0, they multiply into the weighted sum Z = A@V as 0·v = 0 — they contribute nothing. The mask acts on the weights; the values are simply never selected.
3 · Dropout on the attention weights
The mask makes the layer correct. Dropout makes it generalize. On a small corpus — and this book's example is a single short story — a model with 124 million parameters will happily memorize its training text, driving train loss down while validation loss climbs. Dropout is the standard antidote.
Dropout picks a fraction p of the entries in a tensor and sets them to 0, at random, on every training step. Applied to the attention weights, it forces each query to spread its bets: it cannot rely on one favorite key always being available, because that key might be dropped this step. This breaks up brittle co-adaptations where a few weights conspire to overfit, and it acts like training an ensemble of slightly different attention patterns.
There is one required correction. If you zero out fraction p of the weights, the surviving weights sum to less than 1, so the context vector shrinks on average — the layer's output would be systematically smaller during training than at inference (when dropout is off). To keep the expected magnitude constant, the survivors are scaled up by 1/(1−p):
For p = 0.5 the survivors double; for GPT-2's typical p = 0.1 they are scaled by 1/0.9 ≈ 1.11. Two things to remember: dropout is a training-only tool — it is switched off entirely at inference, so generation is deterministic given the weights — and it is applied after the softmax, to the normalized weights (the more common variant in GPT-style models).
4 · Multi-head attention
Now the second flaw. A single head holds one Wq, Wk, Wv, so it can learn exactly one way to decide "what is relevant." But relevance in language is plural: one relationship is subject–verb agreement, another is a pronoun reaching back to its antecedent, another is "the token immediately before me." One geometry cannot be all of these at once.
The fix is embarrassingly cheap. Instead of one attention over the full width d, run h attentions in parallel, each over a slice of width d_head = d/h. Each of these heads has its own small projections and computes its own causal attention over its own slice; the h resulting context vectors are concatenated back to width d and passed through one final output projection Wo that lets the heads mix:
d_head = d / h # each head gets a 1/h slice of the width
for head i in 1..h:
Q_i, K_i, V_i = project x into d_head # per-head Q/K/V
Z_i = softmax(causal(Q_i @ K_i.T / sqrt(d_head))) @ V_i # (T, d_head)
Z = concat(Z_1, …, Z_h) # (T, h * d_head) = (T, d)
out = Z @ W_o # final mix across heads -> (T, d)
The striking part is the cost. Because each head works on width d/h, the total arithmetic is essentially the same as one full-width head — you have rearranged the same FLOPs into h independent geometries rather than added new ones. For GPT-2 "124M", d = 768 and h = 12, so d_head = 768/12 = 64 — twelve parallel 64-dimensional views for the price of one 768-dimensional one. In practice, heads specialize: interpretability work finds "previous-token" heads and "induction" heads that implement copy-and-continue patterns.
Shapes, and the quadratic wall
In real code the heads are not a Python loop — they are a reshape. You project once to full width, then split the width axis into (h, d_head) and move the head axis next to the batch so the matmuls batch over it:
Notice the score tensor (B, h, T, T). It is quadratic in the sequence length T: doubling the context quadruples this tensor's memory. For short toy sequences this is nothing, but at long context it becomes the dominant memory cost — and the reason FlashAttention computes attention tile-by-tile without ever materializing the full T×T matrix (see the cross-reference below).
5 · Watch the future go dark
The widget shows the causal multi-head attention over the six-token sentence "Your journey starts with one step," with each head a different seeded geometry (so the patterns genuinely differ). Toggle causal mask and watch the upper triangle grey out to exactly 0 while every row renormalizes over the past — the KPI "sees future?" flips from yes to no and the masked-cell count jumps to the triangular number. Slide heads from 1 to 4 to switch which head's map you see (note d_head = d/h shrinking) and slide dropout to zero a seeded fraction of the legal weights, rescaling the survivors so the rows still sum to ~1.
Failure modes & checklist
Failure modes
- No causal mask (or masking the wrong triangle). The model reads future tokens and reports a suspiciously low training loss that collapses at inference. Signal: great train perplexity, useless generation.
- Zeroing weights after softmax without renormalizing. Rows no longer sum to 1; each query's attention is silently scaled down by the mass the future would have taken.
- Dropout left on at inference, or missing the 1/(1−p) rescale. Nondeterministic generation, or a train/inference magnitude mismatch that quietly hurts accuracy.
- Choosing h that does not divide d. d_head = d/h must be an integer; otherwise the reshape into heads is undefined.
- Forgetting the output projection Wo. The concatenated heads never get to mix, wasting much of the multi-head benefit.
Checklist
- Upper triangle of the score matrix set to −∞ before softmax.
- Each row sums to 1 over positions ≤ i only.
- Dropout applied to the post-softmax weights, training only, survivors scaled by 1/(1−p).
- h divides d; each head runs over width d_head = d/h.
- Heads concatenated back to width d, then one output projection Wo.
- Score tensor is (B, h, T, T) — remember it is quadratic in T.
Checkpoint
Where this points next
We now have the real attention layer used inside every GPT: causal, multi-headed, regularized with dropout. But step back and notice what attention actually does — it takes each token's vector and replaces it with a weighted blend of other tokens' vectors. That is a mixing operation across positions; it moves information sideways but performs no heavy nonlinear computation within a position. And if you simply stack many of these layers to get depth, the activations drift in scale and the gradients vanish, so the deep stack refuses to train. Lesson 07, The transformer block — norm, GELU, residuals, adds the three pieces that fix both: a feed-forward MLP for per-position processing, LayerNorm to hold activations in range, and residual connections to keep a clean gradient path through depth.
Interview prompts
- Why must a next-token predictor use causal masking? (§1 — position t predicts xt+1; if it can attend to positions > t it copies the answer during training and has nothing to read at inference, so it must only see positions ≤ t.)
- Why set masked scores to −∞ before softmax instead of zeroing weights after? (§2 — e−∞=0 and softmax renormalizes over the survivors automatically, giving rows that sum to 1 over the past in one step; zeroing after leaves rows summing to less than 1 and needs a manual renormalize.)
- If the future values are still in V, how is there no leakage? (§2 — their attention weights are exactly 0, so 0·v=0 in Z=A@V; the mask acts on the weights and the future values are never selected.)
- Why must dropout rescale the surviving weights by 1/(1−p)? (§3 — dropping fraction p shrinks the weighted sum; rescaling keeps the expected magnitude ≈ 1 so the training-time output matches the dropout-free inference path.)
- What does multi-head attention buy, and at what cost? (§4 — h independent similarity geometries (views) instead of one; because each head is width d/h the total FLOPs are essentially unchanged — the same compute rearranged.)
- Why must h divide d, and what is d_head for GPT-2 124M? (§4 — heads split the width evenly, so d_head=d/h must be an integer; for d=768, h=12 it is 64.)
- What is the shape of the score tensor and why does it matter at scale? (§4 — (B, h, T, T), quadratic in T; it dominates memory at long context and is what FlashAttention avoids materializing.)
Companion reads: cs336 · 07 Kernels shows how FlashAttention computes the (B, h, T, T) scores tile-by-tile without ever storing the full matrix — the answer to the quadratic wall; cs336 · 02 The Transformer counts the params and FLOPs of multi-head attention at scale.