Field Notes

Multi-head attention, one shape at a time (Part 9)

Multi-head attention, one shape at a time (Part 9)

Part 9 of LLMs, the whole thing. In Part 8, I worked through causal masking and dropout. The next section of Sebastian Raschka’s Build a Large Language Model (From Scratch) adds multiple attention heads.

I understood the idea before I understood the code. Two heads meant two sets of query, key and value parameters. Then I reached view(...).transpose(...) and had to stop. What had been computed, and what was merely being rearranged?

The explanation that clicked was to follow one token through those operations. This time I use the shorter sentence The river bank was muddy. The shifted training inputs are The / river / bank / was; their targets are river / bank / was / muddy. That gives us four input positions, with bank at index 2.

Same input, different parameters

A head computes attention weights and uses them to mix value vectors. Multiple heads can compute different mixtures for the same token because each head has its own query, key and value parameters. We do not assign them jobs such as grammar or meaning.

My tiny example uses four input features, two heads and three features per head. Each head reads all four input features. I pack the two heads’ query matrices into one larger linear layer; keys and values get separate layers of the same size.

mha_W_query = nn.Linear(4, 6, bias=False)
mha_W_key   = nn.Linear(4, 6, bias=False)
mha_W_value = nn.Linear(4, 6, bias=False)

packed_queries = mha_W_query(x_batch)
packed_keys    = mha_W_key(x_batch)
packed_values  = mha_W_value(x_batch)

Each weight matrix has shape (6, 4). Rows 0–2 belong to head 1; rows 3–5 belong to head 2. Calling a layer computes x_batch @ layer.weight.T. The six outputs per token already contain both heads’ results.

view: the queries are already there

packed_queries has shape (1, 4, 6): one sentence, four tokens, six query numbers per token. Now I give the heads an axis:

queries_by_token = packed_queries.view(1, 4, 2, 3)
# General form: (batch_size, num_tokens, NUM_HEADS, HEAD_DIM)

Read that as one sentence, four tokens, two heads per token, three query numbers per head. For bank, one six-number row becomes two groups of three. No query is recalculated. No weight changes.

The six actual query values for bank grouped into two heads of three values each.
The same bank query before and after view; values rounded to three decimals.

The total stays at 24 elements: 1 × 4 × 6 = 1 × 4 × 2 × 3. This view shares the original storage; its shape must be compatible with that storage layout. Keys and values receive the same transformation. These are computed tensor values, distinct from the trainable weights stored inside the linear layers.

transpose: put all tokens inside each head

head_queries = queries_by_token.transpose(1, 2)
# (B, T, H, D) -> (B, H, T, D)
# (1, 4, 2, 3) -> (1, 2, 4, 3)

Axis 1 held tokens; axis 2 held heads. Swapping them changes how we index the same query vectors. Before, each token contained both heads. Afterwards, each head contains every token.

Token-first and head-first layouts, with the same bank query address before and after transpose.
transpose(1, 2) swaps the token and head axes; each token’s three-number query stays intact.
queries_by_token[0, 2, 0]  # sentence 0, bank, head 1
head_queries[0, 0, 2]      # exactly the same vector

For these dense tensors, transpose also shares storage. Changing a shape directly to (1, 2, 4, 3) with view would not perform the same axis swap: matching dimensions alone does not preserve which vector belongs to which head.

Each head gets its own attention matrix

We arrange heads first because batched matrix multiplication treats the final two axes as matrices. Each batch/head pair independently compares every query with every key:

head_raw_scores = head_queries @ head_keys.transpose(-2, -1)
# (1, 2, 4, 3) @ (1, 2, 3, 4) -> (1, 2, 4, 4)

scaled = head_raw_scores / HEAD_DIM**0.5
masked = scaled.masked_fill(causal_mask, -torch.inf)
head_attention_weights = torch.softmax(masked, dim=-1)

Scale by the square root of three, the key width of one head. The same (4, 4) causal mask broadcasts across the batch and head axes. Both heads let bank use The, river and itself, while blocking was.

Actual bank attention rows from two heads, both assigning zero weight to the future token was.
Different weights, identical causal restriction. This run uses randomly initialized parameters; these patterns are not learned linguistic roles.

Collect values, concatenate, then mix

After attention dropout, each head collects its own value vectors. With p=0.5, surviving attention weights double; individual rows need not sum to one afterwards.

head_contexts = mha_dropout(head_attention_weights) @ head_values
# (1, 2, 4, 3)

joined_contexts = head_contexts.transpose(1, 2).contiguous().view(1, 4, 6)
out_proj = nn.Linear(6, 6, bias=False)
mha_context_vectors = out_proj(joined_contexts)

The transpose brings each token’s heads together. contiguous() makes the layout suitable for merging the head and feature axes with view. Concatenation preserves both three-number results as six features. The trainable output projection can then mix features from both heads, separately at each token position. It creates no path to a future token.

The final shape is (1, 4, 6). This teaching script deliberately keeps a six-feature output from a four-feature input; a full GPT block needs the attention output width to match the stream it adds it to through the residual connection.

The checks that made the shapes trustworthy

The walkthrough checks packed attention against heads calculated separately from their weight rows, verifies normalization before dropout, and checks that future weights stay zero. In a separate check, changing the final input left all earlier outputs exactly unchanged. A batch of two distinct inputs also passed the head-equivalence checks.

All numerical figures come from the full script with seed 123 on PyTorch 2.13.0 CPU. Nothing is trained. The outputs are context features, not vocabulary probabilities. I can now read Step 15 literally: the projections compute the values; view exposes the heads; transpose organizes them for the next multiplication.

The complete runnable gptversion.py prints every stage. Install torch and tiktoken, then run python gptversion.py. Steps 14–20 cover this post. The chapter reference is Raschka’s official Chapter 3 notebook, section 3.6.