Implementing One Attention Head in NumPy
Last week you built up the pieces: dot products as similarity scores, softmax as a "turn scores into a probability distribution" operator, and the idea that a token's new representation is a weighted average of other tokens' vectors. Today you turn that into code you can run, poke, and break.
The whole head is one formula:
Everything below is bookkeeping around that line.
1. Shapes first
Shape discipline is the single most useful habit here — and it's also the best way to explain attention to a colleague, because the shapes are the explanation.
Let a sequence be tokens, each already embedded into dimensions, stacked as rows of a matrix .
| Object | Shape | What a row means | |---|---|---| | | | token 's current representation | | | | learned projections | | | | learned projection | | | | "what token is looking for" | | | | "what token offers as a label" | | | | "what token contributes if attended to" | | | | raw score of query against key | | | | row = a probability distribution over all tokens | | | | token 's output = weighted average of value rows |
Two facts worth memorizing, because they answer 80% of a colleague's questions:
- The matrix is the only place tokens interact. Everything else is applied to each token independently.
- 's rows sum to 1 by construction (softmax along the last axis), so each output row is a convex combination of value vectors — an average, never an extrapolation.
Why divide by ?
If and have roughly independent, zero-mean, unit-variance entries, then has variance , so typical magnitude . For that's scores spread over or more, and softmax of that is nearly one-hot: the head becomes a hard lookup with near-zero gradient for every non-winning token. Dividing by puts the logits back on an scale. It's variance control, nothing deeper.
2. The code
import numpy as np
def softmax(x, axis=-1):
x = x - x.max(axis=axis, keepdims=True) # stability: exp never overflows
e = np.exp(x)
return e / e.sum(axis=axis, keepdims=True)
def attention(Q, K, V, mask=None):
d_k = Q.shape[-1]
scores = (Q @ K.T) / np.sqrt(d_k) # (n, n)
if mask is not None:
scores = np.where(mask, scores, -np.inf) # mask==False -> weight 0
A = softmax(scores, axis=-1) # (n, n), rows sum to 1
return A @ V, A # (n, d_v), (n, n)
def self_attention(X, Wq, Wk, Wv, mask=None):
return attention(X @ Wq, X @ Wk, X @ Wv, mask)
def causal_mask(n):
return np.tril(np.ones((n, n), dtype=bool)) # token i may see j <= i
Fifteen lines of substance. Notes on the fiddly bits:
x - x.max(...)shifts every logit in a row by the same constant. Softmax is invariant to that ( cancels ), so it changes nothing mathematically but preventsexpfrom overflowing toinf.-np.infin masked slots gives , exactly zero weight. Combined with the max-subtraction this is safe as long as every row has at least one unmasked entry — a causal mask always keeps the diagonal, so you're fine. A fully masked row yieldsnan; that's your signal you built the mask wrong.- No batch dimension, on purpose. To batch,
Q @ K.Tbecomesnp.einsum('bqd,bkd->bqk', Q, K)(orQ @ K.transpose(0, 2, 1)), and everything else is unchanged because the softmax and matmuls already act on the last axes.
3. Worked example you can check by hand
Skip the projections and feed directly, with , :
Step 1 — raw scores. is just all pairwise dot products:
Row 3 has the largest self-score () because has the largest norm — a reminder that these scores mix "direction agreement" and "magnitude."
Step 2 — scale by . Row 1 becomes .
Step 3 — softmax row 1. , , sum :
Sum: . ✅
Row 2 is the mirror image, . Row 3 scales to , and over a sum of gives
Step 4 — apply to .
Read the story off the numbers: token 1's query aligned with keys 1 and 3, so its output leans toward but gets dragged toward the middle. Token 3, whose query is equally aligned with everything, lands at the balanced point . And notice every entry is between 0 and 10 — the min and max of 's columns. That's the convex-combination property, and it's a free correctness check on any attention implementation.
Q = K = np.array([[1., 0.], [0., 1.], [1., 1.]])
V = np.array([[10., 0.], [0., 10.], [5., 5.]])
out, A = attention(Q, K, V)
assert np.allclose(A.sum(axis=1), 1.0) # rows are distributions
assert (A >= 0).all()
assert out.shape == (3, 2)
assert out.min() >= V.min() - 1e-9 and out.max() <= V.max() + 1e-9
print(np.round(A, 4))
print(np.round(out, 3))
4. Verifying the row-sum property properly
One hardcoded example proves little. Test the invariant on random inputs, including a masked case:
rng = np.random.default_rng(0)
n, d_model, d_k, d_v = 7, 16, 4, 8
X = rng.normal(size=(n, d_model))
Wq = rng.normal(size=(d_model, d_k)) / np.sqrt(d_model)
Wk = rng.normal(size=(d_model, d_k)) / np.sqrt(d_model)
Wv = rng.normal(size=(d_model, d_v)) / np.sqrt(d_model)
out, A = self_attention(X, Wq, Wk, Wv)
assert A.shape == (n, n) and out.shape == (n, d_v)
assert np.allclose(A.sum(axis=1), np.ones(n))
M = causal_mask(n)
out_c, A_c = self_attention(X, Wq, Wk, Wv, mask=M)
assert np.allclose(A_c.sum(axis=1), np.ones(n)) # still distributions
assert np.allclose(A_c[~M], 0.0) # nothing leaks from the future
assert np.isclose(A_c[0, 0], 1.0) # token 0 can only attend to itself
Note what the causal mask does not break: rows still sum to 1, because softmax renormalizes over whatever survives. Row is a distribution over exactly positions. This is the detail most people get wrong when explaining masking — they imagine weights being zeroed out after softmax, which would leave rows summing to less than 1. The masking happens to the logits, before the normalization.
A second probe worth running, since it explains why positional encodings exist at all:
P = rng.permutation(n)
out_p, _ = self_attention(X[P], Wq, Wk, Wv)
assert np.allclose(out_p, out[P]) # shuffle inputs -> outputs shuffle identically
Unmasked self-attention is permutation-equivariant: it has no idea what order the tokens came in. Position information has to be injected into (or into the scores) from outside.
5. Try it yourself
Starting from the code above:
- Sharpening. Replace
Wqwith10 * Wqand recomputeA. ReportA.max(axis=1)and the row entropy before and after. Then delete the/ np.sqrt(d_k)and rerun withd_k = 4vsd_k = 256, watchingA.max(axis=1)— you should be able to state in one sentence why the is there. - Break the mask on purpose. Build a mask that is all
Falsein row 0 and confirm you getnan. Then explain to yourself where thenancomes from, given the max-subtraction trick. - The two-sentence explanation. Write down, without looking at this lesson, what the matrix contains and why its rows sum to 1. If your colleague's follow-up is "so what does do that doesn't?", have an answer ready — you'll formalize it next lesson when we look at what a single head actually learns to do.