Multi-head attention
Project queries, keys, and values into h parallel subspaces, run h attention heads, and concatenate; show that heads learn complementary patterns.
A single attention head computes one weighted average over all value vectors. Different positions might need to attend to different aspects of the context simultaneously -- syntactic head agreements, coreference, and positional proximity are all different signals. Multi-head attention runs attention heads in parallel on different linear projections of the input, then concatenates their outputs[1].
The multi-head mechanism
Given input , multi-head attention with heads and head dimension :
For each head :
where and are per-head projection matrices.
Concatenate all heads and apply an output projection:
where projects back to .
Parameter count
Each head has three projection matrices of size . With heads and :
Same total as one head of size , but split across subspaces. The output projection adds no parameters beyond what a single full-rank projection would cost.
Why multiple heads?
Each head learns different projection matrices, so it attends to a different subspace of the representation. Empirically:
- Some heads specialize in positional patterns (attending to the previous token or the first token).
- Some heads capture syntactic structure (subject-verb agreement, dependency relations).
- Some heads implement coreference (associating pronouns with their referents).
No head is forced into these roles -- they emerge from training. Multi-head attention gives the model enough capacity to represent all these patterns simultaneously.
Implementation (batched)
import numpy as np
def softmax(x, axis=-1):
e = np.exp(x - x.max(axis=axis, keepdims=True))
return e / e.sum(axis=axis, keepdims=True)
class MultiHeadAttention:
def __init__(self, d_model, n_heads, seed=0):
assert d_model % n_heads == 0
rng = np.random.default_rng(seed)
s = 0.02
self.h = n_heads
self.dk = d_model // n_heads
# Stack all h projections into one matrix for efficiency
self.Wq = rng.normal(0, s, (d_model, d_model)) # (d, d)
self.Wk = rng.normal(0, s, (d_model, d_model))
self.Wv = rng.normal(0, s, (d_model, d_model))
self.Wo = rng.normal(0, s, (d_model, d_model))
def split_heads(self, x):
"""x: (T, d_model) -> (h, T, dk)"""
T, d = x.shape
return x.reshape(T, self.h, self.dk).transpose(1, 0, 2)
def forward(self, x, mask=None):
"""x: (T, d_model). mask: (T, T) boolean."""
T, d = x.shape
Q = self.split_heads(x @ self.Wq) # (h, T, dk)
K = self.split_heads(x @ self.Wk)
V = self.split_heads(x @ self.Wv)
# Scaled dot-product attention for all heads simultaneously
scores = Q @ K.transpose(0, 2, 1) / np.sqrt(self.dk) # (h, T, T)
if mask is not None:
scores[:, mask] = -1e9
attn = softmax(scores) # (h, T, T)
out = attn @ V # (h, T, dk)
# Concatenate heads: (h, T, dk) -> (T, d_model)
out = out.transpose(1, 0, 2).reshape(T, d)
return out @ self.Wo, attn # (T, d_model), (h, T, T)
T, d_model, h = 6, 16, 4
mha = MultiHeadAttention(d_model, h)
x = np.random.randn(T, d_model)
mask = np.triu(np.ones((T, T), dtype=bool), k=1) # causal
out, attn = mha.forward(x, mask=mask)
print("Output shape:", out.shape) # (T, d_model)
print("Attention shape:", attn.shape) # (h, T, T)
print("Head 0 attention (first 3 tokens):")
print(np.round(attn[0, :3, :3], 3))Efficient implementation note
In practice, the projections are fused into a single matrix multiply producing and then split into Q, K, V -- a factor-of-3 memory reduction and better GPU utilization vs. separate matrix multiplies.
Exercises
Exercise 1. Take and . Find the per-head dimension , the parameter count of the four projection matrices, and confirm it matches a single full-rank attention with .
Solution
Step 1. The head dimension splits the model dimension: .
Step 2. The query, key, and value projections together hold parameters. Substituting cancels the :
Step 3. Add the output projection , which is :
A single head of full width would use for QKV plus for the output map, the same . Splitting into heads redistributes the same budget across subspaces, it does not spend more.
Exercise 2. The split_heads operation reshapes to
and the final concat reverses it. Explain why reshape-then-transpose is the
correct order, and why simply reshaping to without a transpose would be wrong.
Solution
Step 1. Row of the input is one token's vector of length . The head split must keep that token's coordinates together: coordinates belong to head 0, to head 1, and so on. Reshaping to does exactly this, since the last axis is the fastest-varying in row-major memory.
Step 2. Attention runs per head, so head must become the leading (batch-like) axis. Transposing to moves head to the front without disturbing which coordinates sit in each head.
Step 3. Reshaping straight to skips the intermediate layout. In row-major order that reinterprets the first contiguous values as head 0, which mixes coordinates from every token into one head and scrambles the token boundaries. The attention scores would then be computed over a meaningless grouping. Reshape to first, then transpose.
Exercise 3 (code). Verify the split/concat roundtrip is exact, and that two heads of a randomly initialized layer produce different attention patterns (so the heads are not redundant copies).
Solution
import numpy as np
def softmax(x, axis=-1):
e = np.exp(x - x.max(axis=axis, keepdims=True))
return e / e.sum(axis=axis, keepdims=True)
rng = np.random.default_rng(0)
T, d_model, h = 6, 16, 4
dk = d_model // h
x = rng.normal(size=(T, d_model))
split = x.reshape(T, h, dk).transpose(1, 0, 2) # (h, T, dk)
concat = split.transpose(1, 0, 2).reshape(T, d_model)
assert np.allclose(concat, x) # roundtrip is lossless
Wq = rng.normal(0, 0.5, (d_model, d_model))
Wk = rng.normal(0, 0.5, (d_model, d_model))
Q = (x @ Wq).reshape(T, h, dk).transpose(1, 0, 2)
K = (x @ Wk).reshape(T, h, dk).transpose(1, 0, 2)
attn = softmax(Q @ K.transpose(0, 2, 1) / np.sqrt(dk)) # (h, T, T)
assert not np.allclose(attn[0], attn[1]) # head 0 and head 1 differ
print("roundtrip exact; heads attend differently, max diff",
round(np.abs(attn[0] - attn[1]).max(), 3))The reshape-transpose pair is its own inverse, and because each head reads a different slice of the projected space its attention matrix differs, confirming heads carry independent patterns rather than copies.
The next lesson assembles multi-head attention, a position-wise feed-forward network, residual connections, and layer normalization into the full transformer block.
References
- [1]Vaswani, A., et al. (2017). "Attention is all you need". NeurIPS. arXiv:1706.03762↩