AI Knowledge Tree

Multi-head attention

Project queries, keys, and values into h parallel subspaces, run h attention heads, and concatenate; show that heads learn complementary patterns.

⏱ 28 minPrerequisites:Scaled dot-product attention

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 hh attention heads in parallel on different linear projections of the input, then concatenates their outputs[1].

The multi-head mechanism

Given input X∈RT×dmodelX \in \mathbb{R}^{T \times d_{\text{model}}}, multi-head attention with hh heads and head dimension dk=dv=dmodel/hd_k = d_v = d_{\text{model}} / h:

For each head i=1,…,hi = 1, \dots, h:

headi=Attention(XWiQ, XWiK, XWiV),\text{head}_i = \text{Attention}(X W_i^Q,\ X W_i^K,\ X W_i^V),

where WiQ,WiK∈Rdmodel×dkW_i^Q, W_i^K \in \mathbb{R}^{d_{\text{model}} \times d_k} and WiV∈Rdmodel×dvW_i^V \in \mathbb{R}^{d_{\text{model}} \times d_v} are per-head projection matrices.

Concatenate all heads and apply an output projection:

MHA(X)=Concat(head1,…,headh) WO,\text{MHA}(X) = \text{Concat}(\text{head}_1, \dots, \text{head}_h)\, W^O,

where WO∈Rhdv×dmodelW^O \in \mathbb{R}^{h d_v \times d_{\text{model}}} projects back to dmodeld_{\text{model}}.

Parameter count

Each head has three projection matrices of size dmodel×dkd_{\text{model}} \times d_k. With hh heads and dk=dmodel/hd_k = d_{\text{model}}/h:

3h⋅dmodel⋅dmodelh+dmodel2=3dmodel2+dmodel2=4dmodel2.3h \cdot d_{\text{model}} \cdot \frac{d_{\text{model}}}{h} + d_{\text{model}}^2 = 3 d_{\text{model}}^2 + d_{\text{model}}^2 = 4 d_{\text{model}}^2.

Same total as one head of size dmodeld_{\text{model}}, but split across hh 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)

python · numpy
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))
Multiple heads let one layer attend several ways at once: here three heads specialize into a previous-token head, a first-token head, and a local-window head, then concatenate.
Multiple heads let one layer attend several ways at once: here three heads specialize into a previous-token head, a first-token head, and a local-window head, then concatenate.

Efficient implementation note

In practice, the hh projections are fused into a single matrix multiply producing (B,T,3dmodel)(B, T, 3 d_{\text{model}}) and then split into Q, K, V -- a factor-of-3 memory reduction and better GPU utilization vs. 3h3h separate matrix multiplies.

Exercises

Exercise 1. Take dmodel=512d_{\text{model}} = 512 and h=8h = 8. Find the per-head dimension dkd_k, the parameter count of the four projection matrices, and confirm it matches a single full-rank attention with dk=dmodeld_k = d_{\text{model}}.

Solution

Step 1. The head dimension splits the model dimension: dk=dmodel/h=512/8=64d_k = d_{\text{model}}/h = 512/8 = 64.

Step 2. The query, key, and value projections together hold 3hdmodeldk3 h d_{\text{model}} d_k parameters. Substituting dk=dmodel/hd_k = d_{\text{model}}/h cancels the hh:

3h dmodel⋅dmodelh=3dmodel2=3⋅5122=786,432.3 h \, d_{\text{model}} \cdot \frac{d_{\text{model}}}{h} = 3 d_{\text{model}}^2 = 3 \cdot 512^2 = 786{,}432.

Step 3. Add the output projection WOW^O, which is dmodel2=262,144d_{\text{model}}^2 = 262{,}144:

3dmodel2+dmodel2=4dmodel2=1,048,576.3 d_{\text{model}}^2 + d_{\text{model}}^2 = 4 d_{\text{model}}^2 = 1{,}048{,}576.

A single head of full width dk=dmodeld_k = d_{\text{model}} would use 3dmodel23 d_{\text{model}}^2 for QKV plus dmodel2d_{\text{model}}^2 for the output map, the same 4dmodel24 d_{\text{model}}^2. Splitting into heads redistributes the same budget across subspaces, it does not spend more.

Exercise 2. The split_heads operation reshapes (T,dmodel)(T, d_{\text{model}}) to (h,T,dk)(h, T, d_k) and the final concat reverses it. Explain why reshape-then-transpose is the correct order, and why simply reshaping to (h,T,dk)(h, T, d_k) without a transpose would be wrong.

Solution

Step 1. Row tt of the input is one token's vector of length dmodeld_{\text{model}}. The head split must keep that token's coordinates together: coordinates [0,dk)[0, d_k) belong to head 0, [dk,2dk)[d_k, 2 d_k) to head 1, and so on. Reshaping (T,dmodel)(T, d_{\text{model}}) to (T,h,dk)(T, h, d_k) 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 (T,h,dk)(T, h, d_k) to (h,T,dk)(h, T, d_k) moves head to the front without disturbing which coordinates sit in each head.

Step 3. Reshaping straight to (h,T,dk)(h, T, d_k) skips the intermediate (T,h,dk)(T, h, d_k) layout. In row-major order that reinterprets the first TdkT d_k 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 (T,h,dk)(T, h, d_k) 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
python · numpy
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. [1]Vaswani, A., et al. (2017). "Attention is all you need". NeurIPS. arXiv:1706.03762↩