Causal attention and the two masks: one hides the future, one cuts links on purpose
Section 3.5 of Raschka's Build a Large Language Model (From Scratch) adds three things to the single attention head from section 3.4: a mask that stops a token looking at the tokens after it, dropout on the attention weights, and a batch dimension. The mask is the one that changes what the model is. Dropout is a second mask that only exists while training. This post is the section worked end to end on the same seven GPT-2 tokens I used last time, The bank of the river was muddy, with every matrix taken from a real run at seed 123. The slides in this post are from the deck I built from the same notes.
The problem: “bank” is reading words that do not exist yet
Plain self-attention lets every row of the 7 × 7 weight matrix spend probability on every column. Here is the row for bank from the section 3.4 head:
The bank of the river was muddy
bank 0.154 0.144 0.141 0.144 0.139 0.138 0.140
Seventy percent of that row goes to the five tokens after “bank”. For a model whose job is to predict the next word, that is the answer leaking into the input. When GPT is asked what follows “The bank”, the words “of the river was muddy” have not been generated yet. Train with the leak and the model learns a trick it can never use at inference.
The fix has one rule: a row may only use columns at or before itself. Rows are the token doing the looking; columns are the tokens being looked at. Everything below is about where in the pipeline to enforce that rule.
The naive mask breaks the row sums
The obvious first move is a lower-triangular matrix of ones from torch.tril, multiplied element-wise into the attention weights. It zeroes the future. It also breaks something.

The rows were probability distributions before the multiply and are not afterwards. The book's first repair is honest: sum each row and divide by it. That gives “bank” a 0.517 / 0.483 split over the two tokens it is allowed to see, and every row sums to 1 again.
row_sums = masked_simple.sum(dim=-1, keepdim=True)
masked_simple_norm = masked_simple / row_sums
# bank 0.517 0.483 0.000 0.000 0.000 0.000 0.000
Correct, but it is three operations where one would do, and the future took part in the softmax denominator before being thrown out. It is a leak followed by a patch.
Put −∞ on the scores, then run softmax once
The trick that production code uses relies on one property of softmax: e−∞ is exactly 0. So instead of masking the weights after softmax, mask the raw scores before it. Any cell that holds −∞ contributes nothing to the denominator and comes out as a clean zero.

mask = torch.triu(torch.ones(T, T), diagonal=1) # 1 = future
masked = attn_scores.masked_fill(mask.bool(), -torch.inf)
attn_weights = torch.softmax(masked / keys.shape[-1] ** 0.5, dim=-1)
Two details tripped me on first read. The mask is now the opposite triangle from the naive version: tril marked what to keep, triu(diagonal=1) marks what to hide. And diagonal=1 matters: without it the diagonal itself would be hidden and a token could not attend to itself.
I compared the two routes on the same head. The largest difference between the renormalized naive matrix and the −∞ matrix was 2.98 × 10⁻⁸, float32 noise. Same maths, leak removed, two operations fewer.
Dropout: the second mask, and it doubles the survivors
Dropout zeroes a random fraction of values and scales the rest by 1/(1−p), so the expected value of each cell is unchanged. Applied to the attention weights it stops the head from leaning on one particular link. The book uses p = 0.5 so the effect is visible; GPT-2 uses about 0.1.

The scaling is what makes inference cheap. Because the average weight is the same with or without dropout, model.eval() simply switches it off and nothing downstream needs to compensate.
Two masks, two jobs
Both operations zero out attention weights, and I found it useful to write down exactly how they differ, because only one of them is part of what the model is.

The module: a batch axis and a buffer
Two changes turn the section 3.4 class into CausalAttention. The input gains a batch dimension, so shapes become (batch, tokens, d_in) and the transpose has to name its axes: keys.transpose(1, 2) instead of .T. And the mask is stored with register_buffer.

The slice self.mask.bool()[:num_tokens, :num_tokens] is why one stored mask of size context_length serves any shorter input: a 7-token batch uses the top-left 7 × 7 corner. And masked_fill_ with the trailing underscore is in-place on the scores, which is fine because the raw scores are not needed after masking and it saves a copy of a (b, T, T) tensor per head.
I checked the class against the by-hand computation with dropout at 0. Identical output, and the module reports exactly three trainable parameters and one buffer.
The whole section in five lines
scores = Q @ K.T
scores[future] = -inf # triu(diagonal=1), a buffer
weights = softmax(scores / sqrt(d_k)) # -inf -> 0, rows still sum to 1
weights = dropout(weights) # training only, survivors x 1/(1-p)
context = weights @ V
Three things I would tell myself before starting: mask the scores with −∞, not the weights with 0, and let softmax normalize; dropout is a random mask that scales survivors so inference can skip it; and the causal mask is a buffer, saved and moved with the model, never trained.
Repo, walkthrough script, interactive study page and the deck: github.com/zakariahere/chap3-causal-attention. The walkthrough prints every matrix in this post; the deck is in the deck/ folder.