Exercise np-05 — scaled dot-product attention, a causal mask, and multi-head, in NumPy

A Java engineer reading nn.MultiheadAttention(x) sees one call and trusts the library got the details right. Two of those details break silently instead of crashing: mask a score the wrong way and every row still looks like a plausible probability distribution, just not the right one; forget to scale by √d_k and training still runs, only worse. Self-attention, step by step built one unmasked head from Q, K and V; this is the same formula with the two things that page's "mask, and many heads" section named — a mask applied before the softmax, and a projection split across several narrower heads — written out by hand.

~90 minruns in the browser 8 checksnp-05

What you're building

Four of the eight checks are worth reading before you start. Check 2 is a hand-picked 1×2 case — Q=[[1,0]] against two orthogonal keys — where scaled and unscaled attention give different first weights (0.6698 vs 0.7311): a missing / sqrt(d_k) shows up there and nowhere else, because softmax always sums to 1 whether or not the scores were scaled first. Check 3 masks 3 of 6 keys and demands the masked weights be exactly 0.0, to 1e-9 — scores * mask (zero, not -inf) leaves exp(0) in the sum, a small but nonzero leak. Check 4 changes the keys and values of every token after a fixed position t and demands out[t] not move by more than 1e-12 — softmax's denominator sums over every key, so masking the weights after computing that sum still lets a changed future key shift the surviving weights, which is exactly how both masking traps get caught here too. Check 8 builds block-diagonal Wq/Wk/Wv so head 1 can only ever see input dimensions 0–1 and head 2 only 2–3, then zeroes head 2's input slice and demands head 1's output slice not change at all — the check the forgotten transpose fails, because without it every "head" is actually reading a mix of tokens rather than a clean slice of the embedding. Every number here was measured on the reference solution with NumPy 2.1 under CPython 3.13, and none of them depends on the version — they are arithmetic, not floating-point luck.

If you get stuck