6.15 — Implementation day: hand-write self-attention in numpy
Goal: Implement single-head causal self-attention from scratch in numpy on a length-8, d=4 example; verify every shape; verify the softmax-rows-sum-to-1 invariant; verify the causal mask blocks future positions; sanity-check the gradient direction by finite differences.
1. Why this session exists
We have written the formula
half a dozen times across 6.2–6.5. Now we type it. The point of an implementation day is not learning numpy — it is making the four shapes (), the mask, and the row-wise softmax concrete. After this session you can sketch attention from memory and you have a sanity test you can run on any new attention variant you encounter.
We will use a tiny example: one batch, one head, sequence length , head dimension . Small enough to inspect by hand; large enough to make every dimension visible. The full script is ~50 lines of numpy; you should be able to write it without looking at this page after the first read-through.
2. Setup
import numpy as np
np.random.seed(0)
T = 8 # sequence length
d_k = 4 # head dimension
d_v = 4 # value dimension (we'll use d_k = d_v for clarity)
# A tiny input embedding sequence (T x d), already after embedding + RoPE,
# so each row is one token's hidden vector at d=4.
X = np.random.randn(T, d_k).astype(np.float64)
print("X shape:", X.shape) # (8, 4)
X is our input — for a single-head model, . In a real Transformer this would be the LayerNormalised input of the attention sub-layer (6.10).
We need the three projection matrices (we'll skip the output projection since it's just one matmul on the result).
W_Q = np.random.randn(d_k, d_k).astype(np.float64) * 0.1
W_K = np.random.randn(d_k, d_k).astype(np.float64) * 0.1
W_V = np.random.randn(d_k, d_v).astype(np.float64) * 0.1
The scale is a stand-in for Xavier-initialised weights; the goal is just to make the post-softmax outputs not degenerate.
3. Computing
Q = X @ W_Q # (T, d_k)
K = X @ W_K # (T, d_k)
V = X @ W_V # (T, d_v)
print("Q:", Q.shape, "K:", K.shape, "V:", V.shape)
# Q: (8, 4) K: (8, 4) V: (8, 4)
Notice that all live in the same space here only because and we're single-head; in general they would have shapes .
4. The score matrix
The unscaled scores are :
S_raw = Q @ K.T # (T, T)
S = S_raw / np.sqrt(d_k) # divide by sqrt(d_k) -- the "scaled" of scaled dot-product
print("S shape:", S.shape) # (8, 8)
Why ? Because, for with standardised entries, — and feeding raw scores into softmax would push it into saturation (one entry , all others , gradients ). We covered this in 6.2 §4.
Sanity check: should be a matrix with no positivity constraint — every entry can be any real number. Print a few rows:
print(S[:3])
# something like
# [[-0.067 0.181 -0.094 0.041 ...]
# [ 0.103 -0.022 0.117 -0.083 ...]
# ...]
Mixed signs, magnitudes around . Good.
5. The causal mask
For autoregressive (decoder) attention, position may only attend to . We forbid by adding in those entries before the softmax. Equivalently, with a large negative constant:
mask = np.triu(np.ones((T, T)), k=1) * -1e9 # upper triangle: -1e9; lower triangle + diag: 0
print("mask:")
print(mask.astype(int))
# [[ 0 -1e9 -1e9 -1e9 -1e9 -1e9 -1e9 -1e9]
# [ 0 0 -1e9 -1e9 -1e9 -1e9 -1e9 -1e9]
# [ 0 0 0 -1e9 -1e9 -1e9 -1e9 -1e9]
# ...
# [ 0 0 0 0 0 0 0 0]]
S_masked = S + mask
After this addition, for . The softmax of is (numerically), so the mask effectively zeros out future positions in the attention weights.
In a real Transformer, modern implementations use either:
- Adding a mask (what we did), or
- Filling with a very-negative number (e.g., the minimum representable in the dtype), or
- Native masked-softmax kernels (FlashAttention, Cap 10.5).
6. Row-wise softmax (numerically stable)
The softmax must be applied along each row of — each query position produces a probability distribution over the key positions.
A direct softmax exp(x) / sum(exp(x)) overflows or underflows for large . The standard fix is the log-sum-exp trick: subtract the row-max first.
def softmax(x, axis=-1):
x_max = np.max(x, axis=axis, keepdims=True) # shape: same as x but reduced
e = np.exp(x - x_max) # never overflows
return e / np.sum(e, axis=axis, keepdims=True)
A = softmax(S_masked, axis=-1) # (T, T) attention weights
print("A shape:", A.shape)
print("Row sums:", A.sum(axis=1)) # should be all ones
# Row sums: [1. 1. 1. 1. 1. 1. 1. 1.]
The check A.sum(axis=1) is the softmax-rows-sum-to-1 invariant: every row of attention weights is a probability distribution. If this is not all-ones, you have a bug. Run it for every new attention variant you implement; it has saved more debugging hours than any other test.
Inspect the upper triangle of :
print("Upper triangle (should be ~0):")
print(A * np.triu(np.ones((T, T)), k=1))
# [[0. 0. 0. 0. ...]
# [0. 0. 0. 0. ...]
# ...]
All entries for are essentially zero, as the mask intended.
7. Mixing values
Finally, multiply the attention weights by the values :
Y = A @ V # (T, d_v)
print("Y shape:", Y.shape) # (8, 4)
Output has the same shape as the input embedding . Each row is a weighted sum of value vectors:
The second equality holds because for (causal mask). So position 's output is a convex combination of past value vectors, weighted by softmax-attention scores.
8. Assembling the full forward pass
Pulling it together, here's the complete single-head causal self-attention as a numpy function:
def single_head_attention(X, W_Q, W_K, W_V):
T, d_k = X.shape[0], W_Q.shape[1]
Q = X @ W_Q # (T, d_k)
K = X @ W_K # (T, d_k)
V = X @ W_V # (T, d_v)
S = Q @ K.T / np.sqrt(d_k) # (T, T)
mask = np.triu(np.ones((T, T)), k=1) * -1e9
A = softmax(S + mask, axis=-1) # (T, T)
return A @ V # (T, d_v)
Y = single_head_attention(X, W_Q, W_K, W_V)
print(Y.shape) # (8, 4)
Ten lines. Run it, check shapes, check row-sums, check upper triangle of .
9. Sanity checks
Run all four of these whenever you debug an attention implementation:
# (1) Shape check
assert Q.shape == (T, d_k)
assert K.shape == (T, d_k)
assert V.shape == (T, d_v)
assert A.shape == (T, T)
assert Y.shape == (T, d_v)
# (2) Softmax rows sum to 1
assert np.allclose(A.sum(axis=1), 1.0, atol=1e-6)
# (3) Causal mask: upper triangle of A is zero
assert np.allclose(np.triu(A, k=1), 0.0, atol=1e-6)
# (4) Output Y is causal: Y[0] depends only on V[0] (since row 0 of A has
# only A[0,0]=1 nonzero). Verify by hand.
assert np.allclose(Y[0], V[0])
print("All checks passed.")
Test 4 is a nice trick: position 0 can only attend to position 0 (everything else is masked), so its attention row is and its output equals exactly. Any deviation indicates a mask bug.
10. Verifying the gradient direction by finite differences
A common "did I implement this right" test is to numerically check the gradient. Pick any scalar (we'll use ), perturb one entry of , and compare the analytical and numerical derivatives.
def loss(X, W_Q, W_K, W_V):
Y = single_head_attention(X, W_Q, W_K, W_V)
return 0.5 * np.sum(Y**2)
# Numerical gradient of loss w.r.t. W_Q[0, 0]
eps = 1e-6
W_Q_pos = W_Q.copy(); W_Q_pos[0, 0] += eps
W_Q_neg = W_Q.copy(); W_Q_neg[0, 0] -= eps
num_grad = (loss(X, W_Q_pos, W_K, W_V) - loss(X, W_Q_neg, W_K, W_V)) / (2 * eps)
print(f"Numerical d L / d W_Q[0,0] = {num_grad:.6f}")
If you also implement the backward pass by hand (chain rule through softmax — exercise (e) below), you can compare. For our purposes here, the forward pass and the numerical sanity check are enough.
11. Extending: multi-head, batched, with RoPE
What we've written is "one batch, one head, no RoPE". The full multi-head batched version is a small extension:
- Multi-head. Reshape from to and transpose to . Run the attention pipeline per head in parallel via batched matmul. Concatenate at the end and project with .
- Batched. Add a leading batch dimension everywhere;
Q @ K.Tbecomesnp.einsum('bhid,bhjd->bhij', Q, K). - RoPE. Before computing scores, rotate each and row by at position (6.7).
- Output projection. Multiply the per-head outputs (concatenated to ) by to mix heads.
Exercise: write multi-head batched attention. It is ~30 lines of numpy, including RoPE. The next session (6.16) walks through nanoGPT's PyTorch implementation, which does the same thing with batched matmul.
12. What we keep going forward
Attention is ten lines of numpy. The invariants are:
- shapes: .
- Score matrix: .
- Softmax rows sum to 1, by row.
- Causal mask: upper triangle of is zero.
- Output shape: .
If any of these fails, you have a bug. Type the code, run the checks, keep the file as a reference whenever you need to read someone else's attention implementation in the future.
Next session walks line-by-line through nanoGPT/model.py — Karpathy's[1] reference implementation — which does this in PyTorch with batched multi-head, the full pre-LN block, and a tiny LM head.
(a) Implement the single-head causal self-attention function from §8 in numpy. Verify all four sanity checks in §9 on a random example.
(b) Modify your implementation to print, for each row of , the indices where . For how many such indices are there? For how many can there be? (Tip: the causal mask sets an upper bound.)
(c) Replace the causal mask with a sliding-window mask of width (6.13 §2). Print and confirm that only a band of width 3 around the diagonal has nonzero entries.
(d) Replace the scaled with an unscaled version. Compute the maximum and minimum entry of over all rows. What happens for large ? Why does the matter?
(e) Derive the backward pass for the attention output with respect to , given a gradient . (Hint: this is just matrix-matrix gradient — write it in numerator layout from 0.2.) Compare the analytical derivative to a finite-difference numerical check.
References
- [1]
Karpathy, A. "Let's build GPT: from scratch, in code, spelled out". YouTube; part of the Zero-to-Hero series at karpathy.ai — Live coding of a Transformer; pairs directly with 6.15–6.16.
↩