Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 11

Multi-Head Attention: Theory and Implementation

Recommended resources

Hello! Welcome to your third lesson in our deep dive into the Transformer architecture.

Introduction

In our last session, we implemented the core engine of the Transformer: scaled dot-product attention. You now have a solid grasp of how this mechanism allows a model to weigh the importance of different tokens using the Query, Key, and Value paradigm. However, you can think of this as giving the model a single "lens" through which to view the relationships in a sequence.

Today, we will significantly upgrade that engine by implementing multi-head attention. This is how the Transformer learns to look at a sentence from multiple perspectives simultaneously—for instance, focusing on grammatical structure with one "head," semantic relationships with another, and pronoun references with a third. Our goal is to implement this as a PyTorch nn.Module and, more importantly, understand how this parallelization enables the model to learn and combine information from different representation subspaces.


The Limitation of a Single Head

Imagine the sentence: "The tired runner drank water from the river bank."

A single attention mechanism, operating on one set of Q, K, and V projections, might learn to associate "runner" with "drank" and "water". But what about the relationship between "river" and "bank"? This context is crucial to disambiguate "bank" from its financial meaning. A single head might struggle to capture both the "action" relationship (runner -> drank) and the "location" relationship (river -> bank) at the same time. The averaging process within a single attention mechanism can wash out these distinct signals.

This is the core motivation for multi-head attention: it allows the model to jointly attend to information from different perspectives at different positions.

To build a strong intuition for this, let's watch a segment from 3Blue1Brown.

Attention in transformers, step-by-step | Deep Learning Chapter 6

This video provides a superb conceptual explanation of why multiple attention heads are necessary and how they work in parallel to capture different kinds of contextual relationships.

Watch the segment from 19:28 to 22:16. Focus on the examples given (like the different meanings of 'Harry') and how having multiple, independent sets of Key, Query, and Value matrices allows the model to learn distinct attention patterns.


The Multi-Head Attention Mechanism

As the video explained, the solution is to run multiple scaled dot-product attention operations in parallel. Each "head" gets its own set of learned linear projection matrices. This allows each head to project the input embeddings into a different, lower-dimensional representation subspace where it can focus on a specific type of relationship.

Transformer Architecture with Multi-Head Attention Detail
This diagram shows the full Transformer architecture. The section on the right provides a detailed view of the Multi-Head Attention mechanism, which we are building today. Notice how the input Q, K, and V are fed into multiple parallel Scaled Dot-Product Attention "heads," whose outputs are then concatenated and passed through a final linear layer.

The process can be broken down into five steps:

  1. Initial Projections: Instead of one set of projection matrices, we have h (the number of heads). In practice, this is often implemented efficiently using three large linear layers for Q, K, and V that project the input from dimension d_model to d_model.
  2. Split into Heads: The resulting (batch, seq_len, d_model) tensors are reshaped into (batch, num_heads, seq_len, d_k), where d_k = d_model / num_heads. This effectively gives each head its own smaller Q, K, and V vectors.
  3. Parallel Attention Calculation: The scaled dot-product attention function you implemented in the last lesson is applied to each head's Q, K, and V tensors simultaneously.
  4. Concatenate Outputs: The outputs from all h heads are concatenated back together, resulting in a tensor of shape (batch, seq_len, d_model).
  5. Final Linear Projection: This concatenated tensor is passed through one final linear layer (W_o) to produce the final output of the multi-head attention block. This layer allows the model to learn how to best combine the information learned by the different heads.

The "Annotated Transformer" blog post, which is the canonical implementation guide, formalizes this.

The Annotated Transformer

This is the definitive reference from the Harvard NLP group. We will look at the section that formally defines Multi-Head Attention, connecting our conceptual understanding to the original paper's mathematics.

Read the section titled 'Multi-head attention'. Focus on the mathematical formula provided. You'll see it's just a wrapper around the Attention() function we already know, applied h times in parallel.


Implementing Multi-Head Attention in PyTorch

Now, let's translate this into code. Your experience building production ML systems and your comfort with PyTorch will be very helpful here, as the main challenge lies in correctly manipulating tensor dimensions.

We will build a MultiHeadAttention nn.Module. For a detailed guide and a clean codebase to follow, the "Transformers From Scratch" series is an excellent resource.

Transformers From Scratch: Part 3 — Multi-Head Attention

This article provides a superb, step-by-step implementation of a MultiHeadAttention class in PyTorch. We will use its structure and code as our primary reference for this section.

Read the sections 'Why Multi-Head Attention?', 'The Multi-Head Attention Mechanism', and then study the 'Implementation' and 'Code Explanation' sections carefully. This will walk you through building the exact class we are about to discuss.

The MultiHeadAttention Class

Based on the article, here is the structure of our class. We'll need the scaled_dot_product_attention function we built in the last lesson.

import torch
import torch.nn as nn
import math

# Assume scaled_dot_product_attention is defined from the previous lesson
def scaled_dot_product_attention(query, key, value, mask=None, dropout=None):
    d_k = query.size(-1)
    scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
    if mask is not None:
        scores = scores.masked_fill(mask == 0, -1e9) # A common way to apply mask
    
    attention_weights = torch.nn.functional.softmax(scores, dim=-1)
    
    if dropout is not None:
        attention_weights = dropout(attention_weights)
        
    output = torch.matmul(attention_weights, value)
    return output, attention_weights


class MultiHeadAttention(nn.Module):
    def __init__(self, d_model: int, num_heads: int, dropout: float = 0.1):
        super().__init__()
        assert d_model % num_heads == 0, "d_model must be divisible by num_heads"

        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads  # Dimension of each head

        # Efficient implementation: single large linear layers for all heads
        self.W_q = nn.Linear(d_model, d_model)
        self.W_k = nn.Linear(d_model, d_model)
        self.W_v = nn.Linear(d_model, d_model)
        
        # Final output projection layer
        self.W_o = nn.Linear(d_model, d_model)
        
        self.dropout = nn.Dropout(dropout)
        self.attention_weights = None

    def forward(self, query: torch.Tensor, key: torch.Tensor, value: torch.Tensor, mask: torch.Tensor = None):
        batch_size = query.shape[0]

        # 1. Apply linear projections: (batch, seq_len, d_model) -> (batch, seq_len, d_model)
        Q = self.W_q(query)
        K = self.W_k(key)
        V = self.W_v(value)

        # 2. Reshape for multi-head processing
        # (batch, seq_len, d_model) -> (batch, num_heads, seq_len, d_k)
        Q = Q.view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        K = K.view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        V = V.view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2)
        
        # 3. Apply scaled dot-product attention for each head in parallel
        # Q, K, V shape: (batch, num_heads, seq_len, d_k)
        output, self.attention_weights = scaled_dot_product_attention(Q, K, V, mask, self.dropout)
        # output shape: (batch, num_heads, seq_len_q, d_k)
        
        # 4. Concatenate heads and reshape back
        # (batch, num_heads, seq_len_q, d_k) -> (batch, seq_len_q, d_model)
        output = output.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model)

        # 5. Apply final linear projection
        # (batch, seq_len_q, d_model) -> (batch, seq_len_q, d_model)
        output = self.W_o(output)
        
        return output

The most critical part is Step 2, the reshaping operation.

  • Q.view(batch_size, -1, self.num_heads, self.d_k) reshapes the d_model dimension into two separate dimensions: num_heads and d_k. The tensor shape becomes (batch_size, seq_len, num_heads, d_k).
  • .transpose(1, 2) then swaps the seq_len and num_heads dimensions. The final shape is (batch_size, num_heads, seq_len, d_k). This groups all the data by head, allowing the scaled_dot_product_attention function to perform batched computations across both the batch_size and num_heads dimensions.

Step 4 simply reverses this process to combine the heads' outputs.

Test your understanding!

You have an input tensor X with shape (batch_size=32, seq_len=50, d_model=512). You are using a MultiHeadAttention layer with num_heads=8.

What is the shape of the Query tensor Q after it has been projected by W_q and reshaped/transposed, right before being fed into the scaled_dot_product_attention function?

Show answer

The shape will be (32, 8, 50, 64).

Here's the breakdown:

  1. d_k = d_model / num_heads = 512 / 8 = 64.
  2. The input X is passed through W_q (a 512 -> 512 linear layer), so the shape is still (32, 50, 512).
  3. .view(32, 50, 8, 64) reshapes the tensor. The -1 in view(batch_size, -1, ...) resolves to seq_len.
  4. .transpose(1, 2) swaps the seq_len (dim 1) and num_heads (dim 2) axes, resulting in the final shape of (32, 8, 50, 64).

To see these tensor manipulations performed in a live coding session, the following video is an excellent supplement.

Multi Head Attention in Transformer Neural Networks with Code!

The CodeEmporium channel provides a very direct, code-first walkthrough of building Multi-Head Attention. Watching this will solidify your understanding of the tensor reshaping logic.

Watch the video from 2:30 to 13:31. The author steps through the same process we just outlined: defining dimensions, creating the combined QKV vector, splitting it into heads, applying attention, and concatenating the results. It's a great practical demonstration of the code we just wrote.


Conclusion

Congratulations! You have now implemented one of the most important components of the Transformer. By moving from a single attention head to multiple parallel heads, you've unlocked the model's ability to capture far more nuanced and varied relationships within the data.

Key Takeaways:

  • Motivation: Multi-head attention overcomes the limitations of single-head attention by allowing the model to focus on different types of information (e.g., syntactic, semantic) in parallel.
  • Representation Subspaces: Each head operates in a different learned "subspace," created by its unique set of projection matrices (W_q_i, W_k_i, W_v_i).
  • Mechanism: The core idea is to split d_model into h smaller chunks, run scaled dot-product attention on each chunk in parallel, and then concatenate the results and pass them through a final mixing layer.
  • Implementation: The key to an efficient implementation in PyTorch is the use of view() and transpose() to reshape tensors for parallel processing across heads, rather than using explicit loops or separate layers for each head.

What's Next?

So far, we have focused on self-attention, where Q, K, and V all come from the same input sequence. But what happens in an encoder-decoder model where the decoder needs to pay attention to the encoder's output?

In our next lesson, we will compare and contrast different applications of the attention mechanism: self-attention, cross-attention, and masked self-attention. We will also implement causal masking, which is essential for ensuring that a decoder can only attend to previous tokens when generating text, preserving the autoregressive property.