TAI BUI
Back to curriculum
CapstonePython

Multi-Head Self-Attention

One linear projection, three views, H parallel heads, one mask. This is the attention block as the model actually uses it.

TIME: ~90 minutesPREREQUISITES: Phase 04 lessons, Phase 07 transformer lessons, Lessons 30 through 32 of this phase

Learning Objectives

  • Implement a batched Query/Key/Value projection as a single linear layer split into H heads.
  • Compute scaled dot-product attention with sqrt(d_head) scaling and a softmax over the key axis.
  • Apply a causal mask that prevents a position from attending to future positions.
  • Inspect per-head attention weights for a fixed input and reason about what each head looks at.
  • Train a small attention block on a toy task and watch the loss fall as the heads specialize.

The frame

Attention is the function that lets a token's representation pull information from other tokens in the same sequence. Self-attention means queries, keys, and values all come from the same input. Multi-head means the projection is split into H parallel attention problems, whose outputs are concatenated and projected back.

The efficient pattern is one linear layer that projects from D to 3 * D. You slice the result into three views and reshape each into H heads of size D // H. The matmul, softmax, and weighted sum then run as batched tensor operations, so the heads run in parallel on the accelerator.

This lesson builds that block and adds the causal mask, so the same code works as the attention layer in a decoder-only language model. The next lesson stacks the block into a full transformer, and the lesson after that trains it.

Check: The frame0/2
1. In self-attention, where do the queries, keys, and values come from?Define
2. What distinguishes multi-head self-attention from plain self-attention?Define

The shape contract

The block takes (B, T, D) and returns (B, T, D). The mask is (T, T) or broadcastable to it. Inside, the intermediate tensors are (B, H, T, d_head) with d_head = D // H, which requires D % H == 0.

Mermaid diagram

All the parameters live in two linear layers: the QKV projection and the output projection. The mask, the softmax, the matmuls, and the reshapes have no parameters.

Check: The shape contract0/4
1. In a block with model width D and H heads, what is d_head?Define
2. Using the code's two linear layers (both with bias), how many parameters does a block with d_model=8 and n_heads=2 have?Apply
3. You build the block with d_model=30 and n_heads=4. What happens?Apply
4. You have D=48 and need each head to work in exactly 8 dimensions while keeping the shape contract. Which number of heads fits?Apply

The QKV split

The naive implementation uses three separate linear layers, one each for Q, K, and V. The efficient one uses a single layer that outputs 3 * D features and splits the result. The two are mathematically equivalent: three matrix multiplications by (D, D) weights are one multiplication by a (3D, D) weight stacked from them.

The single layer is faster because the accelerator launches one matmul instead of three. It is also easier to initialize, since the three sub-matrices live in one parameter tensor and get initialized together.

Check: The QKV split0/1
1. Why does an efficient self-attention block use a single linear layer that outputs 3*D features?Explain

The head reshape

After the split, Q, K, and V are each (B, T, D). To get H parallel attention problems, reshape to (B, T, H, d_head) and transpose to (B, H, T, d_head). That puts the head dimension next to the batch dimension, so PyTorch treats per-head attention as a batched operation over B * H independent instances.

Keep d_head last. The score matmul Q @ K.transpose(-2, -1) contracts it and returns (B, H, T, T) per-head attention scores.

Check: The head reshape0/2
1. After the QKV split, why is each tensor reshaped to (B, T, H, d_head) and then transposed to (B, H, T, d_head)?Explain
2. In the code, _split_heads is called on a tensor of shape (2, 5, 24) with n_heads=6. What shape comes back?Apply

Scaling

Scores are divided by sqrt(d_head) before the softmax. Without that, dot products grow with d_head and push the softmax into a regime where one entry holds almost all the mass and the rest are vanishingly small. Gradients there are tiny and learning stalls. Dividing by sqrt(d_head) keeps the variance of the scores roughly constant across head sizes.

Check: Scaling0/3
1. Scores are divided by sqrt(d_head) before the softmax. What does that division prevent as head size grows?Explain
2. Two runs differ only in head size. The d_head=128 run shows near one-hot attention rows and a stalled loss, while d_head=8 trains. Scores were not divided by sqrt(d_head). Which explanation fits?Analyze
3. A refactor applies the softmax over the query axis (dim=-2) instead of the key axis. What goes wrong?Analyze

The causal mask

The causal mask blocks each position from attending to the positions after it. A decoder-only language model can only condition on the past when predicting the next token, and the mask enforces that. Before the softmax, every entry above the diagonal of the (T, T) score matrix is replaced by negative infinity, and after the softmax those positions get weight zero.

Mermaid diagram

The mask is registered as a buffer at construction, so it lives on the same device as the model and stays out of the gradient graph. It covers the maximum context length the block will see, and at forward time we slice the top-left (T, T) corner.

Check: The causal mask0/9
1. What is a causal mask in an attention block?Define
2. How does the causal mask stop position t from attending to position t+1?Explain
3. The mask is applied to the scores before the softmax rather than to the weights after it. Why?Explain
4. The causal mask is registered as a buffer at construction. What does that give you?Explain
5. A block has max_context_length=12 and is run on a full 12-token input with no dropout. For one head, how many entries in the weight row of query position 3 (counting from 0) are nonzero?Apply
6. You change tokens at positions 4 to 7 of an 8-token input and rerun the block. Which output rows stay identical?Apply
7. A block is built with max_context_length=4 and called on an input of shape (1, 5, 8). What happens?Apply
8. A colleague reports that weights[0, 0] has nonzero entries above the diagonal after the softmax. Which is the most likely cause?Analyze
9. You remove the causal mask and train on the repeat task. The loss drops to near zero much faster than before. Why should you not trust that result?Analyze

The output projection

After attention you have per-head context vectors of shape (B, H, T, d_head). Transpose back to (B, T, H, d_head), reshape to (B, T, D), and apply a final (D, D) linear projection. That projection is where the model mixes the heads. Without it, the H heads would only recombine in later layers, which constrains the block artificially.

Check: The output projection0/2
1. What is the output projection in the block?Define
2. You delete the output projection from the block and keep everything else. What do you lose?Analyze

Attention weight inspection

Pass return_weights=True to the forward pass and the block returns the per-head attention weights, shape (B, H, T, T), alongside the output. The demo prints a heatmap of one head's weights on a short input (one random token id repeated across the context), so you can see the causal-triangle structure and the per-position focus.

In a trained model, different heads learn different patterns. Some attend to the immediately previous token, some to the start of the sequence, and some spread attention almost uniformly. This hook is the entry point for that interpretability work.

Check: Attention weight inspection0/3
1. What does an attention-weight tensor of shape (B, H, T, T) tell you?Define
2. Why print one head's weights as a heatmap for a short input before reading much into the loss curve?Explain
3. A block has d_model=16 and n_heads=4. You run a batch of 2 sequences of length 5 with return_weights=True. What is the shape of the weights?Apply

The training demo

The demo at the bottom of main.py wires the attention block to a tiny LM head and trains the whole thing on a repeat task. Each input row is a single random id replicated across the context, and the target is the input shifted by one, so the model has to learn that the next token equals the previous one. The loss is cross-entropy. With H=4, D=32, T=12, and a vocabulary of 64, the loss starts near random (around log(64) ~ 4.16) and the demo prints the average loss for each of three epochs on CPU. The script asserts only that the final epoch's average loss is below the first epoch's.

The demo isn't meant to produce a useful model. It confirms that gradients flow through every piece of the block and that the heads learn something on a problem where the answer is obvious.

Check: The training demo0/2
1. The repeat-task demo starts with a loss near log(64), about 4.16. Why is that the expected starting value?Explain
2. In the repeat task, each row is one random id X repeated across all 12 positions and the target is the input shifted by one. What is the target at position 5?Apply

What this lesson does not do

No feed-forward block. A real transformer layer is attention followed by a two-layer MLP, with a residual connection and layer norm around each. The next lesson adds those.

No rotary or AliBi positional encoding. Both apply at the QKV projection step of this same block, but they are a separate teaching unit. Transforming Q and K before the matmul makes the block compatible with either.

No KV cache for inference. Caching keys and values across forward passes is the optimization that makes autoregressive decoding fast. It changes the shape contract on K and V but not on Q, and it belongs in the inference lesson.

Check: What this lesson does not do0/3
1. This block has no residual connection, layer norm, or feed-forward network. Why are they missing?Explain
2. You want rotary positional encoding in this block without changing its shape contract. Where does it go?Design
3. You add a KV cache to decode one new token at a time. Which statement about shapes is right?Design
Author's Note

I'd pin the shape contract before writing anything else: (B, T, D) in, (B, H, T, d_head) in the middle, (B, T, D) out, and D % H == 0. Most of this block is reshapes, transposes, and matmuls, so a shape check at each boundary is where I'd start.

My bet is that return_weights=True earns its place early. With it you can check that the weights above the diagonal are zero before you read much into the loss curve, and on the repeat task the answer is obvious enough that a wrong heatmap tells you something is broken.

How to read the code

main.py defines MultiHeadSelfAttention. The class holds two linear layers and a registered mask buffer. Its forward pass projects, reshapes, scores, masks, applies the softmax, weights the values, reshapes back, and projects again. The demo at the bottom builds a small model that wraps the attention with token and positional embeddings and an LM head, trains it on a copy task for three epochs, and prints the loss curve and a per-head attention heatmap. The tests in code/tests/test_attention.py pin the shape contract, the causality property, the softmax property, the head-split property, and gradient flow.

Run the demo. Then increase n_heads from 4 to 8 (keeping d_model=32, so d_head=4) and watch the heatmap change.

Warm-up0/3
1. A batch of 8 sequences, each 12 tokens long, has every token mapped to a 32-number embedding vector. What shape is the resulting tensor?Warm-up
2. A softmax is applied across one row of raw scores. What does the output row look like?Warm-up
3. A language model reads "the cat sat on" and must predict the next word. Which tokens can it legitimately use?Warm-up
Check: How to read the code0/1
1. The exercise raises n_heads from 4 to 8 with d_model=32. How does the block change?Analyze

Code