Causal masking
Stop tokens from seeing the future.
Create a free account Try part of it here
Why it matters
A language model learns by guessing the next token, over and over. In training it sees the whole sentence at once. If it could peek at the token it's meant to guess, the task would be trivial. It would learn to copy, not to predict, and it would be useless for writing new text. The causal mask is a small triangle of blocked spots that stops the peeking. It also makes training fast: one pass over a sentence gives a guess, and a grade for that guess, at every position.
What you'll be able to do
- Explain why a language model must not attend to future tokens
- Build the lower-triangular causal mask and apply it to attention scores
- Verify causality: changing a later token can't change earlier outputs
- See how masking lets one forward pass train on every position at once
What it covers
- No peeking
- Worked example: masking one row
- The whole mask at once
- Training on every position at once
- Proving it's causal
- Where causal masks are (and aren't) used
No peeking
Think of studying with flashcards. You cover the answer, make your guess, then lift the cover. If the answer showed through the card, you'd get every one right and learn nothing.
A language model faces the same risk. In training it reads a whole sentence at once, such as "It is light and packable". At each token, its job is to guess the token that comes next. When it's at "light", the right answer is "and". It may use "It is light" to make that guess. It must not see "and" or anything after it. If it could, the answer would be sitting in its input.
Recall how attention works from the last few lessons. Each token sends out a query: what it's looking for. Every token offers a key: what it contains. Each query and key give a score. Then softmax turns each token's row of scores into weights that add up to 1.
The rule: block the future
Number the tokens from 0, the way Python does. Let be the position of the token doing the looking, the query. Let be the position of the token it looks at, the key. The mask changes one thing. Whenever the key comes later than the query, its score becomes minus infinity, written . This happens before the softmax.
In plain words: if the token being looked at comes after the token doing the looking, wipe out its score before the softmax.
Why minus infinity? Softmax raises the number e (about 2.718) to the power of each score. Then it divides each result by the row's total. And e to the power of minus infinity is exactly 0. So a blocked token gets a weight of exactly 0. The weights on the allowed tokens still add up to 1. In Python, minus infinity is float("-inf").
Each row is one word. With the mask on it can't see anything to its right, and its 100% is shared among the words left.
import math print(math.exp(0), math.exp(float("-inf")))
Show the answer
1.0 0.0
e to the power of 0 is 1.0, a perfectly normal amount of weight. Only minus infinity turns into exactly 0.0. That's why the mask uses minus infinity and not 0.
Worked example: masking one row
Take a sentence of four tokens. Look at the row for token 1. Its raw scores for tokens 0, 1, 2 and 3 are 2, 1, 3 and 0. Tokens 2 and 3 come later, so they're blocked. Work out the attention weights.
import math scores = [2.0, 1.0, 3.0, 0.0] # token 1's scores for tokens 0, 1, 2, 3 allowed = [True, True, False, False] # tokens 2 and 3 come later masked = [s if ok else float("-inf") for s, ok in zip(scores, allowed)] powers = [math.exp(s) for s in masked] # e to the power of each score total = sum(powers) print(masked) print([round(p / total, 3) for p in powers])
The mask turns the scores into 2, 1, minus infinity and minus infinity. The powers of e are 7.389, 2.718, 0 and 0, which add up to 10.107. Dividing each power by that total gives 0.731, 0.269, 0 and 0.
Token 2 had the highest raw score, 3, yet it gets no weight at all. Suppose you set the blocked scores to 0 instead. Since e to the power of 0 is 1, each would still take about 8% of the weight. The future would leak in.
The whole mask at once
Apply the rule to every pair and you get a grid. Each row is a query and each column is a key. Here it is for four tokens, with 1 for allowed and 0 for blocked.
key 0 key 1 key 2 key 3 query 0 1 0 0 0 query 1 1 1 0 0 query 2 1 1 1 0 query 3 1 1 1 1
Row may look at columns 0 up to , itself included. The allowed spots fill the bottom-left half of the grid, diagonal included. A grid with this shape is called lower-triangular.
The code below builds the mask for a six-token sentence. Then it applies the mask to random scores and draws the weights as a picture.
import torch import matplotlib.pyplot as plt tokens = ["It", " is", " light", " and", " pack", "able"] T = len(tokens) mask = torch.tril(torch.ones(T, T)).bool() # True = allowed print(mask.int()) torch.manual_seed(0) scores = torch.randn(T, T) weights = torch.softmax(scores.masked_fill(~mask, float("-inf")), dim=-1) print("row 0:", weights[0].numpy().round(2), " (the first token can only attend to itself)") print("row 3:", weights[3].numpy().round(2)) plt.imshow(weights.numpy(), cmap="viridis") plt.xticks(range(T), tokens, rotation=45) plt.yticks(range(T), tokens) plt.xlabel("key (attended to)") plt.ylabel("query (attending)")
Here's what each piece does.
Tis the number of tokens, here 6.torch.ones(T, T)makes a T × T grid of 1s.torch.trilkeeps the grid's lower triangle and sets the rest to 0. The name is short for "triangle, lower"..bool()turns 1 into True and 0 into False. True means allowed.~maskflips every True and False. Now True marks the blocked spots.masked_fillwrites minus infinity into those blocked spots.torch.softmax(..., dim=-1)turns each row into weights.
In the picture, each row is a query and each column is a key. Brighter squares get more weight. Everything above the diagonal is exactly zero. Each row still sums to 1 over the tokens it's allowed to see. (torch is the course's in-browser PyTorch subset.)
Count the allowed spots row by row. Row 0 may look at 1 token. Row 1 may look at 2. Each row adds one more.
Show the answer
15
Count the lower triangle, diagonal included. The rows allow 1, 2, 3, 4 and 5 keys, and 1 + 2 + 3 + 4 + 5 = 15.
Training on every position at once
Because each position only sees its past, one pass over the sentence gives many guesses at once. Each guess is honest, because it's made from that position's own prefix: the tokens up to and including it.
Here's the flashcard sentence laid out that way. The left column is what the model may see. The right column is the token it must guess.
| sees so far | must guess |
|---|---|
| It | is |
| It is | light |
| It is light | and |
| It is light and | pack |
| It is light and pack | able |
Six tokens give five guesses. In general, the inputs are the sentence minus its last token. The targets are the same sentence minus its first token. So the targets are the inputs shifted by one place. Training grades every guess, then averages the grades into one number, the loss.
The model always gets the real text as its input, never its own earlier guesses. This "shift by one" setup is called teacher forcing. It's why training is so efficient. A training sequence of 2,048 tokens teaches the model about 2,048 predictions for the price of one pass.
At generation time, the future doesn't exist yet. The model writes one token, adds it to the text, then writes the next. The mask makes training work under those same conditions: every guess uses only what came before.
Go deeper Why not zero the weights after the softmax?
You could run the softmax on every score first, then set the future weights to 0. But then each row would no longer add up to 1. You'd need a second step to rescale the row. Putting minus infinity in before the softmax gives the same final weights in one step, because the softmax does the rescaling for you. That's also why the mask is applied to the scores, not the weights.
Show the answer
8
The inputs are the first 8 tokens and the targets are the last 8. Every input position makes one guess, so one pass gives 8 guesses.
Proving it's causal
"Causal" means cause and effect only run one way. Earlier tokens can affect later outputs. Later tokens can never affect earlier ones. Put precisely, the output at position depends only on inputs 0 up to .
You can test this. Make up six token vectors and run causal attention. Then change token 4 only and run it again. Outputs at earlier positions must not move.
import math import torch def causal_attention(x, W_q, W_k, W_v): q, k, v = x @ W_q, x @ W_k, x @ W_v T = x.shape[0] scores = q @ k.T / math.sqrt(q.shape[-1]) scores = scores.masked_fill(~torch.tril(torch.ones(T, T)).bool(), float("-inf")) return torch.softmax(scores, dim=-1) @ v torch.manual_seed(1) x = torch.randn(6, 8) W_q, W_k, W_v = torch.randn(8, 4), torch.randn(8, 4), torch.randn(8, 4) before = causal_attention(x, W_q, W_k, W_v) x2 = x.clone() x2[4] = torch.randn(8) # change token 4 only after = causal_attention(x2, W_q, W_k, W_v) changed = [(after[i] - before[i]).abs().max().item() > 1e-6 for i in range(6)] print("output changed at positions:", [i for i, c in enumerate(changed) if c])
The function is the scaled dot-product attention from the last lesson, with the mask added. The last line compares each output before and after the change. It counts a change only if some number moved by more than 1e-6, one millionth. That small margin ignores rounding noise.
Only positions 4 and 5 change. Positions 0 to 3 can't see token 4, so nothing about them moves.
Show the answer
Positions 2, 3, 4 and 5
Token 2 is visible to itself and to every later position. Positions 0 and 1 come earlier, so they can't see it.
Where causal masks are (and aren't) used
- Decoder models (GPT, Claude, Llama): causal attention everywhere. That's what lets them write text left to right, one token at a time.
- Encoder models (BERT, embedding models): no causal mask. Every token sees the whole input, which suits tasks about understanding a text. A later lesson covers these.
- Padding masks: sentences in a batch often have different lengths. Shorter ones get filler tokens, called padding, to even them out. A padding mask blocks those filler tokens, so real tokens never attend to them. It's a different mask with a different job.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
Why can a decoder model train on all T positions of a sequence in one forward pass?