Skip to main content

Module 3 — Multi-head attention

The five-line function from module 2 works. Yet no production model uses it alone. It runs in parallel copies called heads, each with its own projections, and their outputs are concatenated before a final projection. This module explains why, sizes the heads correctly, and adds the multi-head module to our red-thread Transformer.

One head sees one thing at a time

Look again at the attention matrix from module 2. Every row sums to one. A single row expresses how strongly the current token attends to every other token in one distribution. That distribution is inevitably a compromise: if a token wants to attend both to the subject of the sentence and to a punctuation mark far away, one softmax cannot express two peaks cleanly without smearing them.

Multiple heads solve the problem by giving the model several independent distributions per token. In practice a base Transformer uses 8 or 12 heads. Each head has its own WQW_Q, WKW_K, WVW_V, so each learns to look at something different: syntactic dependencies, coreference, positional patterns, semantic similarity. Post-hoc analyses on BERT and GPT show that heads do specialise, but rarely in ways you can name — one head follows the previous token, another jumps to the sentence subject, several look mostly redundant and can be pruned.

The formula, with several heads

Let hh be the number of heads and dd the model dimension (the embedding size). The head dimension is dk=d/hd_k = d / h, chosen so the total width stays the same:

headi=Attention(XWQi,XWKi,XWVi),i=1,,h.\text{head}_i = \text{Attention}(X W_Q^i, X W_K^i, X W_V^i), \quad i = 1, \dots, h.

The heads run independently, then their outputs are stacked side by side and mixed by a final linear layer:

MultiHead(X)=Concat(head1,,headh)WO.\text{MultiHead}(X) = \text{Concat}(\text{head}_1, \dots, \text{head}_h) \, W_O.

For h=8h = 8 and d=512d = 512, each head works in dimension 64, exactly the toy size we picked in module 2. That is not a coincidence: the paper's numbers were chosen to keep each head small enough that its dot products remain interpretable.

Parameter count, done properly

Miscounting parameters is a rite of passage. Do it slowly, once, and never worry about it again.

For one head with input dimension dd and head dimension dkd_k:

  • WQiW_Q^i: shape d×dkd \times d_k
  • WKiW_K^i: shape d×dkd \times d_k
  • WViW_V^i: shape d×dkd \times d_k

Multiply by hh heads and add the output projection WOW_O of shape hdk×d=d×dh \cdot d_k \times d = d \times d:

3hddk+d2=3hd(d/h)+d2=4d2.3 \cdot h \cdot d \cdot d_k + d^2 = 3 \cdot h \cdot d \cdot (d/h) + d^2 = 4 d^2.

For d=512d = 512, that is 4×5122=10485764 \times 512^2 = 1\,048\,576 parameters per multi-head module — about a million. Multiply by twelve layers and you already see the parameter mass of a small Transformer, before the feed-forward blocks that dominate the total. Module 5 adds those blocks and completes the accounting.

One matrix, three projections

Every serious implementation stores WQW_Q, WKW_K, WVW_V stacked into a single matrix of shape d×3dd \times 3d. A single matmul produces QQ, KK, VV in one shot, then splits the output into three slices. The math is unchanged; the accelerator prefers one large matmul over three small ones.

In PyTorch: from single head to multi-head

We build multi-head attention on top of the function from module 2. The move is mostly reshaping: split the projected tensor across a "head" axis, run attention per head in one batched call, then merge back.

import torch
import torch.nn as nn
import torch.nn.functional as F

class MultiHeadAttention(nn.Module):
def __init__(self, d_model=512, num_heads=8):
super().__init__()
assert d_model % num_heads == 0
self.h = num_heads
self.d_k = d_model // num_heads
self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
self.proj = nn.Linear(d_model, d_model, bias=False)

def forward(self, x, mask=None):
B, N, D = x.shape
qkv = self.qkv(x).reshape(B, N, 3, self.h, self.d_k)
q, k, v = qkv.permute(2, 0, 3, 1, 4) # (3, B, h, N, d_k)
scores = q @ k.transpose(-2, -1) / self.d_k ** 0.5
if mask is not None:
scores = scores.masked_fill(mask == 0, float("-inf"))
weights = F.softmax(scores, dim=-1)
out = (weights @ v).transpose(1, 2).reshape(B, N, D)
return self.proj(out), weights

Save this file under mha.py. Module 5 wraps it in a residual block, module 6 stacks residual blocks into an encoder, and module 10 assembles everything.

What different heads learn

Interpretation is more art than science, but a few patterns recur across models trained on English text.

Kind of headBehaviourDetection
Previous tokenlarge weight on position t1t-1 for every ttfirst sub-diagonal is bright
Positional / neighbourhoodweight decays with distancenarrow bright diagonal band
Punctuationattention collapses on commas or periodsbright vertical stripes on those tokens
Coreferencepronouns look at their antecedentlong-range spikes
Content-basedmatches semantic role rather than positionpatterns that no simple rule captures

Module 10 plots the attention weights of our trained Transformer on the date-translation task. You will recognise a positional head that reads the day, a content head that copies the year, and a mostly redundant head that could probably be pruned.

Why not just add heads without bound

More heads sound better, so why does the paper stop at 8 or 12?

  • Head dimension shrinks with head count. For fixed dd, doubling hh halves dkd_k. Below dk=32d_k = 32 or so, each head becomes too narrow to represent useful patterns and adding more no longer helps.
  • Total parameter count stays 4d24d^2, whatever hh. The gain from more heads has to come from specialisation, not from capacity. Beyond a point, the extra heads duplicate each other.
  • Pruning studies on BERT show that removing half the heads costs less than one point on GLUE. That confirms the redundancy and reframes the design choice: pick the smallest hh that reaches your target, not the largest.
dd must be divisible by hh

Almost every clean implementation asserts dmodh=0d \bmod h = 0. Choose d=512d = 512 and h=7h = 7 and the module will refuse to build, or worse, will silently round. If you tweak hh, adjust dd so the head dimension stays a power of two, which the accelerator prefers.

In summary

  • Multi-head attention runs hh parallel copies of the single-head operation, each with its own WQW_Q, WKW_K, WVW_V, and concatenates their outputs before a final projection WOW_O.
  • The head dimension is dk=d/hd_k = d / h: the total width is preserved, and the parameter count for the whole module is 4d24d^2.
  • Different heads specialise: some follow neighbours, some track punctuation, some resolve coreference — but many are redundant, which is why pruning research is a live topic.
  • In code, store WQ,WK,WVW_Q, W_K, W_V as a single matrix, reshape into a head axis, and reuse the module 2 attention function unchanged.

Next module: positional encoding, which fixes the fact that our attention operation, as it stands, does not know the order of the tokens.