Multi-head attention
Several attention patterns in parallel.
Create a free account Try part of it here
Why it matters
Attention lets each token in a sentence look back at the tokens before it and pull in what it needs. One attention pattern gives each token one blend of the others. But a token often needs several kinds of context at once. It may need the word right before it, the subject of the sentence, and the name that "she" refers to. Multi-head attention runs several attention patterns side by side. Each one is free to learn a different link between tokens. Together they cost no more parameters than a single pattern. Every transformer uses it, and the "head" is one of the main parts researchers study when they look inside large language models.
What you'll be able to do
- Explain why one attention pattern per token isn't enough
- Split the model width into several heads, run attention in each, and recombine
- Track shapes through split and merge, and count the parameters
- Recognize the kinds of patterns different heads learn
What it covers
- One blend isn't enough
- Sharing out the width
- The recipe in symbols
- Two heads, two jobs
- Heads that learn their own jobs
- The real module
- Counting the parameters
- Worked example: split and merge by hand
- Sizes in real models
One blend isn't enough
Picture three friends reading the same online listing for a rain jacket. One marks every price with a yellow highlighter. One marks the features in green. One marks anything about sizing in pink. Afterward they pool their notes. Three readers with three pens catch more than one reader trying to do every job with one pen.
The attention from the last lessons is one reader with one pen. For each token (a word or piece of a word), it works out a weight for every earlier token. The weights come from a softmax, so they add up to 1. Then it blends those tokens' values using the weights.
That single blend is the problem. Say the word "light" wants two things: the word right before it, and the product the sentence is about. With one set of weights, it has to split its attention between them. The two answers get averaged into one muddled result.
Multi-head attention gives each token several readers. Each head is a full attention computation with its own weights. One head can follow the previous word while another follows the product. Their results are kept side by side, not averaged together.
In plain words: a head is one attention pattern. Several heads let each token look at several different things at the same time.
Sharing out the width
Each token arrives as a list of numbers, its vector. The length of that list is the model's width, written ("d model"). GPT-2 small, for example, describes each token with 768 numbers.
Multi-head attention doesn't make the model any wider. It cuts the width into equal slices, one per head. The number of heads is written . The size of each slice is the head size, :
In plain words: share each token's numbers out evenly among the heads. Each head works on its own share.
GPT-2 small has 12 heads, so each head works with 768 / 12 = 64 numbers. Now try it with small numbers: a width of 6 and 2 heads gives each head 6 / 2 = 3 numbers. Here is that split in plain Python.
d_model, h = 6, 2 d_head = d_model // h token = [0.1, 0.4, -0.2, 0.7, 0.0, 0.3] # one token's 6 numbers slices = [token[i * d_head:(i + 1) * d_head] for i in range(h)] print("head size:", d_head) print("head 0 gets:", slices[0]) print("head 1 gets:", slices[1]) print("glued back:", slices[0] + slices[1] == token)
Head 0 gets the first three numbers and head 1 gets the last three. Glue the two slices back together, end to end, and you have the original list. That gluing step is called concatenation, or Concat for short.
In a real model the slicing happens a little later, to each token's queries, keys and values. The idea is the same.
Watch out: the heads don't divide up the text. Every head sees every token. What they divide up is the list of numbers that describes each token.
Show the answer
Every token, but only a quarter of each token's numbers
Heads split the width, not the text. Each head gets its own slice of every token's vector.
The recipe in symbols
Here is the whole recipe written as a formula. Each symbol gets its own line first.
- is the input: one row of numbers per token.
- is head 's query projection, the matrix that makes its queries. The small is a label meaning "for head ", not a power.
- makes that head's keys.
- makes that head's values.
- is the scaled dot-product attention from earlier lessons.
- is what head outputs. glues the heads' outputs side by side.
- is the output projection: one more matrix, applied after the heads are glued together.
In plain words: each head makes its own queries, keys and values from the same input. Then it runs ordinary attention. Line the heads' outputs up side by side. Finally, multiply by so that what different heads found can be combined. That matrix mixes the concatenated head outputs back into the model's representation.
Run the numbers with a width of 6 and 2 heads. Each head turns every token into 3 output numbers. Concatenating gives 3 + 3 = 6 numbers per token, the original width. is a 6 × 6 matrix, so the final output is again 6 numbers per token. The output has the same shape as the input. That's what lets the next layer sit on top. You'll see this formula as Python in the module further down.
print(512 // 8)
Show the answer
64
512 // 8 = 64. Heads share the width, so more heads cost no extra parameters overall. Each one gets a narrower view.
Go deeper Why one big projection equals many small ones
Code doesn't usually store small query matrices. It stores one big matrix of size and cuts its result into slices.
Each output number of a matrix product depends only on its own column of the matrix. So the first block of columns acts as head 1's , the next block as head 2's, and so on. "Project, then slice" gives exactly the same numbers as "one small projection per head". It's faster because the computer does one large multiplication instead of many small ones.
Two heads, two jobs
Here is a hand-built example with six tokens: "the rain jacket is light packable". Each token's vector has 9 numbers. The first 6 mark its position, with a 1 in its own slot and 0 everywhere else. This is called one-hot. The last 3 mark its category: product word, feature word or filler.
The projections are set by hand here, not learned. Head 1's make every token look for the previous position. Head 2's make every token look for tokens of the same category. Both heads use a causal mask, so a token can only look at itself and earlier tokens. Same input, two different patterns:
import numpy as np import matplotlib.pyplot as plt tokens = ["the", "rain", "jacket", "is", "light", "packable"] category = [2, 0, 0, 2, 1, 1] # 0 = product word, 1 = feature, 2 = filler T = len(tokens) X = np.hstack([np.eye(T), np.eye(3)[category]]) # 6 position features + 3 category features def attention(Q, K): s = Q @ K.T s = np.where(np.tril(np.ones((T, T))) == 1, s, -np.inf) # causal mask w = np.exp(s - s.max(axis=1, keepdims=True)) return w / w.sum(axis=1, keepdims=True) shift = np.eye(T, k=-1) * 6 # query for position i asks for position i - 1 W_q1 = np.vstack([shift, np.zeros((3, T))]) W_k1 = np.vstack([np.eye(T), np.zeros((3, T))]) W_q2 = np.vstack([np.zeros((T, 3)), np.eye(3) * 4]) # query and key: just the category W_k2 = W_q2 heads = {"head 1: previous token": attention(X @ W_q1, X @ W_k1), "head 2: same category": attention(X @ W_q2, X @ W_k2)} fig, axes = plt.subplots(1, 2, figsize=(10, 4)) for ax, (name, W) in zip(axes, heads.items()): ax.imshow(W, cmap="viridis") ax.set_xticks(range(T)); ax.set_xticklabels(tokens, rotation=45) ax.set_yticks(range(T)); ax.set_yticklabels(tokens) ax.set_title(name) print(f"{name}: 'packable' attends most to {tokens[int(W[-1].argmax())]!r}")
Each picture is a grid of weights. Each row is a token, and the bright squares show where it looks. Head 1 makes a bright stripe just left of the diagonal: every token looks at the one before it. Head 2 lights up tokens that share a category.
Both lines print "light", for different reasons. In head 1, "packable" puts about 99% of its weight on "light", the word before it. In head 2, it splits its weight half and half between the two feature words, "light" and itself. (When two weights tie, argmax picks the first, so it names "light".)
Switch heads and pick a word. Each head spends its own attention on a different kind of pattern. These scores are illustrative.
category = [2, 0, 0, 2, 1, 1] # the, rain, jacket, is, light, packable i = 2 # "jacket" same = [j for j in range(i + 1) if category[j] == category[i]] print(same)
Show the answer
[1, 2]
range(i + 1) is the causal mask: only positions 0 to 2. Of those, "rain" (1) and "jacket" (2) are product words, so head 2 splits its weight between them.
Heads that learn their own jobs
In a trained model, nobody designs these patterns. Heads specialize because it helps the model predict the next token. Researchers have found heads that track the previous token, copy repeated patterns, match brackets, or link pronouns such as "she" to names.
The friends with highlighters were told their jobs. Real heads aren't. Their jobs come out of training, and they are often messier than this example, with several heads overlapping.
The real module
In real code, all the heads run together in one go. The trick is reshaping: changing how the same numbers are grouped, without changing the numbers.
The code names the input's shape with three letters. B is the batch, how many sequences are processed at once. T is the number of tokens in each sequence. C is the width, (C stands for channels). The steps are:
- One linear layer makes the queries, keys and values for every head at once. Each comes out with shape (B, T, C).
viewcuts the last dimension into (heads, head size). Thentranspose(1, 2)moves the heads next to the batch, giving (B, h, T, d_head). Now each head looks like a separate item in the batch.- Attention runs once, on all heads together. Each head still gets its own scores and its own softmax.
transposeandreshapeundo the split. That's the concatenation.self.outis .
import math import torch import torch.nn as nn class MultiHeadAttention(nn.Module): def __init__(self, d_model, n_heads): super().__init__() self.h, self.d_head = n_heads, d_model // n_heads self.qkv = nn.Linear(d_model, 3 * d_model, bias=False) # Q, K and V for all heads in one matmul self.out = nn.Linear(d_model, d_model, bias=False) # W_O def forward(self, x): B, T, C = x.shape q, k, v = self.qkv(x).split(C, dim=-1) # each (B, T, C) q, k, v = (t.view(B, T, self.h, self.d_head).transpose(1, 2) for t in (q, k, v)) # (B, h, T, d_head) scores = q @ k.transpose(-2, -1) / math.sqrt(self.d_head) # (B, h, T, T) scores = scores.masked_fill(~torch.tril(torch.ones(T, T)).bool(), float("-inf")) y = torch.softmax(scores, dim=-1) @ v # (B, h, T, d_head) y = y.transpose(1, 2).reshape(B, T, C) # concatenate the heads: (B, T, C) return self.out(y) torch.manual_seed(0) mha = MultiHeadAttention(d_model=32, n_heads=4) x = torch.randn(2, 6, 32) print("output:", tuple(mha(x).shape)) print("parameters:", sum(p.numel() for p in mha.parameters()), "= 4 × 32 × 32 (Q, K, V and output), whatever the number of heads")
The output shape, (2, 6, 32), matches the input: 2 sequences, 6 tokens, width 32.
Here torch is the course's in-browser teaching version of PyTorch. The code is the same as real PyTorch, but it runs only on your computer's main processor and much more slowly. Seeded random numbers also differ from real PyTorch.
Counting the parameters
The module printed 4,096 parameters. Splitting into heads didn't add any. Here's why.
Each head's query projection turns numbers into numbers. So it holds weights. Its key and value projections are the same size. Then is a full square: weights. (This module has no bias terms.)
In plain words: heads times the head size is the full width again. So the heads' projections add up to three full-width squares, plus one more square for . The number of heads cancels out.
Work it through for the module above: width 32, 4 heads, head size 8.
- One head's three projections: 3 × 32 × 8 = 768.
- Four heads: 4 × 768 = 3,072.
- Add : 32 × 32 = 1,024.
- Total: 3,072 + 1,024 = 4,096, which is 4 × 32 × 32.
The same count in Python, for several head counts:
d_model = 32 for h in [1, 2, 4, 8]: d_head = d_model // h per_head = 3 * d_model * d_head # Q, K and V projections for one head total = h * per_head + d_model * d_model # all heads, plus W_O print(h, "heads of size", d_head, "->", total, "parameters")
Every line prints 4096. What changes with more heads is the number of softmaxes. Four heads of size 8 give four separate attention patterns instead of one.
Show the answer
64 per head; the same number of parameters
d_head = 512 / 8 = 64. The heads share out the width, so the projections are still 512 × 512 in total.
Worked example: split and merge by hand
Three tokens, width 4, two heads. The numbers 0 to 11 fill the tokens in order, so you can see exactly where each number goes. Split the tokens into heads the way the module does, then merge them back.
import numpy as np B, T, C, h = 1, 3, 4, 2 # 1 sequence, 3 tokens, width 4, 2 heads x = np.arange(12.0).reshape(B, T, C) # token 0 = [0, 1, 2, 3], token 1 = [4, 5, 6, 7], ... heads = x.reshape(B, T, h, C // h).transpose(0, 2, 1, 3) # split: (B, h, T, d_head) print("split shape:", heads.shape) print("head 0 sees:", heads[0, 0].tolist()) print("head 1 sees:", heads[0, 1].tolist()) merged = heads.transpose(0, 2, 1, 3).reshape(B, T, C) # merge: (B, T, C) print("merged equals x:", np.array_equal(merged, x))
The split shape is (1, 2, 3, 2): 1 sequence, 2 heads, 3 tokens, 2 numbers each. Head 0 sees all three tokens, but only their first two numbers: [0, 1], [4, 5] and [8, 9]. Head 1 sees the last two numbers of each token. Merging swaps the axes back and glues the slices together, so you get the original x exactly. This is what the module does to the queries, keys and values, and to the attention output.
Sizes in real models
GPT-2 small uses 12 heads of 64 numbers each, for a width of 768. Large language models use dozens of heads in each layer, across dozens of layers.
Many recent models go one step further. Several query heads share the same keys and values. This is called grouped-query attention, and it cuts the memory needed while the model generates text. That memory is the subject of the KV cache lesson.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
What's the main benefit of several heads over one head of the same total width?