Chapter 24 — Self-attention, step by step with real numbers
Do this chapter with a pen and paper. Seriously — it takes 15 minutes and it is the moment attention stops being mysterious.
The formula (we'll build up to it)
Attention(Q, K, V) = softmax( Q K^T / sqrt(d_k) ) V
Five pieces. We'll do each.
Setup
Sentence: "the cat sat" — 3 tokens. To keep the arithmetic tiny, each
query/key/value has just 2 numbers (d_k = 2). In a real model these are
64 or 128 numbers, but nothing else changes.
Assume the model has already computed (via W_Q, W_K, W_V):
query key value
the q1=[1,0] k1=[1,0] v1=[1.0, 0.0]
cat q2=[0,1] k2=[0,1] v2=[0.0, 1.0]
sat q3=[1,1] k3=[1,1] v3=[0.5, 0.5]
Step 1 — Score: how much does each word match each other word?
Take the dot product of a query with every key. Big dot product = pointing in the same direction = relevant.
Let's compute for "sat" (query q3 = [1,1]):
q3 . k1 = (1 x 1) + (1 x 0) = 1 how relevant is "the" to "sat"?
q3 . k2 = (1 x 0) + (1 x 1) = 1 how relevant is "cat" to "sat"?
q3 . k3 = (1 x 1) + (1 x 1) = 2 how relevant is "sat" to itself?
raw scores: [1, 1, 2]
Step 2 — Scale: divide by sqrt(d_k)
sqrt(d_k) = sqrt(2) = 1.414
scaled scores: [1/1.414, 1/1.414, 2/1.414] = [0.707, 0.707, 1.414]
Why divide? With larger d_k, dot products get large just from having more
terms to add up. Large numbers going into softmax make it "spiky" — almost all
weight on one word, and near-zero gradients (learning stalls). Dividing by
sqrt(d_k) keeps the scores in a well-behaved range. It's a numerical-stability
fix, nothing deeper.
Step 3 — Softmax: turn scores into percentages
exp(0.707) = 2.028
exp(0.707) = 2.028
exp(1.414) = 4.113
------
sum = 8.169
weights = [2.028/8.169, 2.028/8.169, 4.113/8.169]
= [0.248, 0.248, 0.503] (sums to 1.0)
Read this: "sat" pays 25% attention to "the", 25% to "cat", and 50% to itself.
Step 4 — Weighted sum of the values
output_for_sat = 0.248 x v1 + 0.248 x v2 + 0.503 x v3
= 0.248 x [1.0, 0.0]
+ 0.248 x [0.0, 1.0]
+ 0.503 x [0.5, 0.5]
= [0.248, 0.000]
+ [0.000, 0.248]
+ [0.252, 0.252]
------------------
= [0.500, 0.500]
That's it. The new representation of "sat" is [0.5, 0.5] — a blend of the
whole sentence, weighted by relevance. This vector is what gets passed to the
next part of the network.
Step 5 — The causal mask (what makes it a language model)
For an LLM predicting the next word, word 2 must not see word 3 — that would be looking at the answer. So before the softmax, we set future scores to negative infinity, which softmax turns into exactly zero:
Attention allowed (X = allowed, . = blocked):
key: the cat sat
query the X . . "the" sees only itself
query cat X X . "cat" sees "the" and itself
query sat X X X "sat" sees everything before it
Let's redo "cat" (query q2 = [0,1]) with the mask:
q2 . k1 = 0 q2 . k2 = 1 q2 . k3 = BLOCKED (-infinity)
scaled: [0/1.414, 1/1.414, -inf] = [0, 0.707, -inf]
exp(0) = 1.000
exp(0.707) = 2.028
exp(-inf) = 0.000
-----
sum = 3.028
weights = [0.330, 0.670, 0.000]
output_for_cat = 0.330 x [1,0] + 0.670 x [0,1] + 0 x [0.5,0.5]
= [0.330, 0.670]
"cat" attends 33% to "the" and 67% to itself, and 0% to the future.
This single choice — masked or not — is most of the difference between GPT (masked, generates text) and BERT (unmasked, understands text).
Everything at once (why it's fast)
We did one word at a time for clarity. In practice all queries are stacked into a
matrix Q, all keys into K, all values into V, and the whole thing is:
Q K^T scores softmax V output
[3 x 2] @ [2 x 3] = [3 x 3] -> [3 x 3] @ [3 x 2] = [3 x 2]
(every word (weights) (new reps
vs every word) for all words)
Two matrix multiplies for the entire sentence. That is why GPUs love Transformers and hated RNNs.
Practice (30 min) — implement attention yourself
import numpy as np
def softmax(x, axis=-1):
x = x - x.max(axis=axis, keepdims=True)
e = np.exp(x)
return e / e.sum(axis=axis, keepdims=True)
def attention(Q, K, V, causal=True):
d_k = Q.shape[-1]
scores = Q @ K.T / np.sqrt(d_k) # step 1 + 2
if causal: # step 5
n = scores.shape[0]
mask = np.triu(np.ones((n, n)), k=1).astype(bool)
scores[mask] = -np.inf
weights = softmax(scores) # step 3
return weights @ V, weights # step 4
Q = np.array([[1,0],[0,1],[1,1]], dtype=float)
K = np.array([[1,0],[0,1],[1,1]], dtype=float)
V = np.array([[1,0],[0,1],[0.5,0.5]], dtype=float)
out, w = attention(Q, K, V, causal=True)
words = ["the", "cat", "sat"]
print("attention weights (row = who is looking, col = who they look at):")
print(" " + " ".join(f"{x:>6}" for x in words))
for i, row in enumerate(w):
print(f" {words[i]:>5} " + " ".join(f"{x:6.3f}" for x in row))
print("\noutputs:")
for i, row in enumerate(out):
print(f" {words[i]:>5} {row.round(3)}")
Verify the numbers match the hand calculation above. They should: sat gets
[0.5, 0.5], cat gets [0.33, 0.67].
Then experiment:
- Set
causal=False. How do the weights change? What does "the" now see? - Change
q3to[5, 0](a much stronger query towardk1). Recompute — how peaked does the attention become? - Remove the
/ np.sqrt(d_k)and setQ = Q * 10. Look at the weights: they become nearly one-hot (0.999, 0.000, 0.000). Now you've seen why the scaling matters. - Make
Vrandom and 4-dimensional. Confirm the shapes still work.
Write in notes.md: "Attention computes ___, which lets each word ___."
Common confusions
- "Are Q, K, V three different things about the same word?" Yes — three learned projections of the same input vector, used in three different roles.
- "Does the model know grammar?" Not explicitly. It learns whatever attention patterns reduce the loss. Interpretability research finds heads that behave like grammar rules, but nobody programmed them.
- "Why not just use similarity of the embeddings directly?" Because Q and K let the model learn task-specific notions of relevance — "which noun does this pronoun refer to" is a different question from "which words are synonyms".
Check yourself
- Walk through the five steps of attention from memory.
- Why divide by
sqrt(d_k)? - What does the causal mask do, and why is it needed for text generation?
- Why is attention fast on a GPU while an RNN is slow?
Further reading
- Video (essential): 3Blue1Brown, "Attention in transformers, visually explained" — watch it again after doing the arithmetic; it'll land differently.
- Article: "The Illustrated Transformer" — Jay Alammar.
- Article (interactive): "The Annotated Transformer" — Harvard NLP. The original paper, line by line, with runnable PyTorch beside each paragraph.
- Interactive: bbycroft.net/llm — a stunning 3D visualisation where you can click through an actual GPT forward pass, tensor by tensor. Spend 15 minutes.
- Paper: Vaswani et al., "Attention Is All You Need" (2017), arXiv:1706.03762. Try reading it now — you'll understand more than you expect.