Index Ai Academy
Phase 5 · Transformers · Attention

Multi-head attention

Several attention patterns in parallel.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. One blend isn't enough
  2. Sharing out the width
  3. The recipe in symbols
  4. Two heads, two jobs
  5. Heads that learn their own jobs
  6. The real module
  7. Counting the parameters
  8. Worked example: split and merge by hand
  9. 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 dmodeld_{\text{model}} ("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 hh. The size of each slice is the head size, dheadd_{\text{head}}:

dhead=dmodelhd_{\text{head}} = \frac{d_{\text{model}}}{h}

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)
It runs right here in your browser, with nothing to install.

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.

Quick check
A model has 4 heads. What does each head see?
  1. Every token, with all of its numbers
  2. A quarter of the tokens, with all of their numbers
  3. Every token, but only a quarter of each token's numbers
  4. Only the token right before the current one
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.

  • XX is the input: one row of numbers per token.
  • WQ(i)W_Q^{(i)} is head ii's query projection, the matrix that makes its queries. The small (i)(i) is a label meaning "for head ii", not a power.
  • WK(i)W_K^{(i)} makes that head's keys.
  • WV(i)W_V^{(i)} makes that head's values.
  • Attention\text{Attention} is the scaled dot-product attention from earlier lessons.
  • headi\text{head}_i is what head ii outputs. Concat\text{Concat} glues the heads' outputs side by side.
  • WOW_O is the output projection: one more matrix, applied after the heads are glued together.
MultiHead(X)=Concat(head1,…,headh) WOheadi=Attention(XWQ(i),XWK(i),XWV(i))\text{MultiHead}(X) = \text{Concat}(\text{head}_1, \dots, \text{head}_h)\, W_O \qquad \text{head}_i = \text{Attention}(X W_Q^{(i)}, X W_K^{(i)}, X W_V^{(i)})

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 WOW_O 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. WOW_O 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.

Quick check
A width of 512 is shared among 8 heads. What size is each head?
print(512 // 8)
  1. 64
  2. 8
  3. 4096
  4. 512
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 hh small query matrices. It stores one big matrix of size dmodel×dmodeld_{\text{model}} \times d_{\text{model}} 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 dheadd_{\text{head}} columns acts as head 1's WQ(1)W_Q^{(1)}, 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".)

Try it now

Switch heads and pick a word. Each head spends its own attention on a different kind of pattern. These scores are illustrative.

Quick check
This code lists the tokens that "jacket" (position 2) can attend to in head 2. What does it print?
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)
  1. [2]
  2. [1, 2, 4]
  3. [0, 1, 2]
  4. [1, 2]
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, dmodeld_{\text{model}} (C stands for channels). The steps are:

  1. One linear layer makes the queries, keys and values for every head at once. Each comes out with shape (B, T, C).
  2. view cuts the last dimension into (heads, head size). Then transpose(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.
  3. Attention runs once, on all heads together. Each head still gets its own scores and its own softmax.
  4. transpose and reshape undo the split. That's the concatenation.
  5. self.out is WOW_O.
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 dmodeld_{\text{model}} numbers into dheadd_{\text{head}} numbers. So it holds dmodel×dheadd_{\text{model}} \times d_{\text{head}} weights. Its key and value projections are the same size. Then WOW_O is a full square: dmodel×dmodeld_{\text{model}} \times d_{\text{model}} weights. (This module has no bias terms.)

parameters=h⋅3 dmodel dhead+dmodel2=4 dmodel2\text{parameters} = h \cdot 3\, d_{\text{model}}\, d_{\text{head}} + d_{\text{model}}^2 = 4\, d_{\text{model}}^2

In plain words: hh 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 WOW_O. 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 WOW_O: 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.

Quick check
A model has width 512 and 8 heads. What is each head's dimension, and how do the parameters compare with a single 512-wide head?
  1. 64 per head; the same number of parameters
  2. 8 per head; fewer parameters
  3. 512 per head; 8 times the parameters
  4. 64 per head; 8 times the parameters
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

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))
In the lesson you can change this and run it.

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

Create a free account

Try a quiz question

What's the main benefit of several heads over one head of the same total width?

  1. Each head has its own softmax, so tokens can attend to several different things at once
  2. More parameters, because each extra head adds its own full-width set of weights
  3. Less computation in total, because each head only has to look at some of the tokens
  4. It removes the need for a causal mask, since each head can only see its own slice

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson