Scaled dot-product attention
Derive the query-key-value soft lookup, show why 1/sqrt(d_k) scaling is necessary, and implement a single attention head in numpy.
Bahdanau attention computes a context vector as a weighted sum of encoder states, with weights determined by a learned alignment score between the decoder state and each encoder position. The Transformer (Vaswani et al., 2017) generalizes this to a single unified mechanism: scaled dot-product attention, which any sequence -- not just encoder-decoder pairs -- can apply to itself[1].
The query-key-value abstraction
Frame attention as a soft lookup table: you have a table of key-value pairs, and a query you want to look up. Instead of returning the value for an exact key match, you return a weighted sum of all values, with weights proportional to how well each key matches the query.
For a set of queries , keys , and values :
Steps:
- Compute similarity scores: (one score per query-key pair).
- Scale by to prevent saturation.
- Apply softmax row-wise to get weight matrix (each row sums to 1).
- Return (each query gets a weighted sum of values).
Why scale by ?
If the components of and are i.i.d. with mean 0 and variance 1, then has mean 0 and variance , so the standard deviation grows as .
Without scaling, the dot products grow large in magnitude as increases. The softmax of large values approaches a one-hot distribution: almost all weight is placed on the largest score. This pushes the attention into a nearly-argmax regime where gradients are tiny (the softmax is saturated). Dividing by keeps the inputs to softmax at unit variance, maintaining informative gradients.
Put numbers on it at , a typical head dimension, where the score standard deviation is . Take two keys whose raw scores differ by one standard deviation, a gap of , with everything else held fixed so only the scaling changes:
- Unscaled. puts weight on the larger key. The distribution is one-hot in all but name; the second key gets and its gradient is starved.
- Scaled by . The same gap becomes , and puts weight on the larger key. The attention stays soft and both keys carry gradient.
A wider gap of two standard deviations ( raw, after scaling) saturates even harder unscaled () but stays soft once scaled (). The scaling does not change which key wins; it changes whether the loser is heard at all.
Self-attention
When , , are all derived from the same sequence via linear projections:
the result is self-attention: every position attends to every other position in the same sequence. Each output position is a weighted sum of all value vectors, with weights reflecting how relevant each key position is to the current query position.
Causal masking
For autoregressive language modeling, position must not attend to positions (it cannot see the future). Causal masking sets the pre-softmax scores to for all :
After softmax, becomes 0, so future positions receive zero weight.
Implementation
import numpy as np
def scaled_dot_product_attention(Q, K, V, mask=None):
"""
Q: (n, d_k) K: (m, d_k) V: (m, d_v) mask: (n, m) boolean (True = masked out)
Returns: (n, d_v) output, (n, m) attention weights
"""
d_k = Q.shape[-1]
scores = Q @ K.T / np.sqrt(d_k) # (n, m)
if mask is not None:
scores[mask] = -1e9 # set masked positions to -inf
attn = np.exp(scores - scores.max(axis=-1, keepdims=True))
attn /= attn.sum(axis=-1, keepdims=True) # softmax row-wise
out = attn @ V # (n, d_v)
return out, attn
# Demo: 5-token sequence, d_k = d_v = 4
T, d_k, d_v = 5, 4, 4
rng = np.random.default_rng(0)
X = rng.normal(0, 1, (T, 8)) # input embeddings (d_model=8)
Wq = rng.normal(0, 0.1, (8, d_k))
Wk = rng.normal(0, 0.1, (8, d_k))
Wv = rng.normal(0, 0.1, (8, d_v))
Q, K, V = X @ Wq, X @ Wk, X @ Wv
# Causal mask: True where j > i
mask = np.triu(np.ones((T, T), dtype=bool), k=1)
out, attn = scaled_dot_product_attention(Q, K, V, mask=mask)
print("Output shape:", out.shape)
print("Attention weights (row = query, col = key):")
print(np.round(attn, 3))
print("Each row sums to:", attn.sum(axis=1).round(4))Verify that the upper triangle of the attention matrix is zero (masked), and that each row in the lower-triangular region sums to 1.
Complexity
For a sequence of length :
- Computing : -- quadratic in sequence length.
- Computing : .
- Total: time and memory (the attention matrix).
This quadratic scaling in is the core limitation of standard attention, motivating FlashAttention (D7) and linear attention variants.
Exercises
Exercise 1. A single query attends to two keys and with values and , head dimension . Compute the scaled scores, the attention weights, and the output vector by hand.
Solution
Step 1. Raw scores are the dot products and . Scale by :
Step 2. Softmax the scaled scores. The weight on is
so the weights are .
Step 3. The output is the weighted sum of values:
The query leans toward the matching key but stays soft; the non-matching value still contributes a third of the output.
Exercise 2. Two keys produce raw scores that differ by (everything else fixed). Compute the softmax weight on the larger key with no scaling, and again after dividing by . Explain why the scaled case keeps gradient flowing to the smaller key.
Solution
Step 1. Unscaled, the scores are . The weight on the larger key is
so the smaller key receives .
Step 2. After dividing by , the gap becomes and the scores are :
so the smaller key now holds .
Step 3. The softmax gradient with respect to a logit is proportional to the weight times one minus the weight, . At this is about , vanishingly small; at it is about , three orders of magnitude larger. Scaling pulls the distribution out of the saturated regime so both keys carry usable gradient.
Exercise 3 (code). Implement scaled dot-product attention and verify two structural facts on a random causal example: every attention row sums to , and the strictly upper triangle (future positions) is exactly zero.
Solution
import numpy as np
def attention(Q, K, V, mask=None):
d_k = Q.shape[-1]
scores = Q @ K.T / np.sqrt(d_k)
if mask is not None:
scores = np.where(mask, -1e9, scores)
attn = np.exp(scores - scores.max(axis=-1, keepdims=True))
attn /= attn.sum(axis=-1, keepdims=True)
return attn @ V, attn
rng = np.random.default_rng(0)
T, d_k, d_v = 6, 8, 8
Q, K, V = rng.normal(size=(T, d_k)), rng.normal(size=(T, d_k)), rng.normal(size=(T, d_v))
mask = np.triu(np.ones((T, T), dtype=bool), k=1) # True above diagonal = future
out, attn = attention(Q, K, V, mask=mask)
assert np.allclose(attn.sum(axis=1), 1.0) # rows are distributions
assert np.allclose(attn[mask], 0.0) # no weight on the future
print("rows sum to 1, upper triangle zeroed; output shape", out.shape)The mask drives future logits to , which softmax sends to zero, while each row still normalizes to a valid distribution over the visible past.
The next lesson stacks independent attention heads in parallel -- multi-head attention -- which allows different heads to attend to different representation subspaces.
References
- [1]Vaswani, A., et al. (2017). "Attention is all you need". NeurIPS. arXiv:1706.03762↩