Fun with Learning Technology
LearnCoursesQuestionsTracksToolsNewsExplorePractice
Fun with Learning Technology

A new problem, explained clearly, every day.

Subscribe
Learn
  • Lessons
  • Topics
  • News
  • Tools
  • Courses
  • Career tracks
  • Everything
Site
  • About
  • Contact
  • Support
  • Privacy
  • Terms
Get the daily one

One email per new problem. No spam.

Request a tutorial

Requests shape what gets made next.

© 2026 Fun with Learning TechnologyRSS
Home›Courses›Creating Own LLM›Self-Attention Mechanism and Multi-Head Attention

Core Concepts of Large Language Models

Self-Attention Mechanism and Multi-Head Attention

The self-attention mechanism allows a model to weigh the importance of different words in a sequence relative to each other, enabling it to capture long-range dependencies and contextual relationships. This matters because it forms the backbone of modern transformer-based language models, allowing them to understand nuanced language patterns far better than previous architectures. You reach for self-attention when building any sequence-to-sequence model where context and relationships between elements are critical, such as machine translation, text generation, or even code understanding.

Why Attention is Needed: The Problem with Fixed-Length Context

Before self-attention, models like RNNs or LSTMs processed sequences step-by-step, passing a hidden state from one token to the next. This created a bottleneck: information from early tokens had to be compressed into a fixed-size vector, which often lost critical details, especially in long sequences. For example, in the sentence 'The cat sat on the mat because it was tired,' the word 'it' refers to 'cat,' but an RNN might struggle to retain this relationship after processing several intermediate words. Self-attention solves this by allowing every token to directly attend to every other token, regardless of distance. This means the model can dynamically decide which parts of the input are most relevant for understanding the current token, preserving long-range dependencies without compression. The key insight is that relationships in language are not strictly sequential—they are contextual and often non-local.

# Example of how a fixed-length context fails in RNNs
import numpy as np

# Simulate a hidden state bottleneck in an RNN
hidden_size = 4  # Tiny for demonstration
sequence_length = 10

# Random input tokens (embeddings)
inputs = [np.random.randn(hidden_size) for _ in range(sequence_length)]

# Simulate RNN hidden state updates (simplified)
hidden = np.zeros(hidden_size)
for i, x in enumerate(inputs):
    hidden = np.tanh(hidden + x)  # Compress all past info into 'hidden'
    print(f"Step {i}: Hidden state norm = {np.linalg.norm(hidden):.2f}")
    
# Observation: Hidden state saturates or loses early information
# Self-attention avoids this by letting later tokens 'look back' directly.

The Core Idea: Query, Key, and Value Vectors

Self-attention works by transforming each input token into three vectors: a query, a key, and a value. These names come from information retrieval systems, where a query is matched against keys to retrieve relevant values. In the context of language, the query vector represents 'what the current token is looking for,' the key vector represents 'what the other tokens can offer,' and the value vector represents 'the actual information to pass along.' For example, in the sentence 'She gave the book to him,' when processing 'him,' the query vector for 'him' might strongly match the key vector for 'She' (indicating a subject-object relationship), while weakly matching the key for 'book.' The attention score is computed as the dot product of the query and key vectors, scaled and normalized using softmax to produce weights. These weights are then used to create a weighted sum of the value vectors, which becomes the output for the current token. This mechanism allows the model to dynamically focus on the most relevant parts of the input for each token.

# Implementing query, key, value transformations
import torch
import torch.nn.functional as F

# Sample input embeddings (batch_size=1, seq_len=3, embed_dim=4)
embeddings = torch.randn(1, 3, 4)
embed_dim = 4

# Learnable weight matrices for Q, K, V
W_q = torch.randn(embed_dim, embed_dim)
W_k = torch.randn(embed_dim, embed_dim)
W_v = torch.randn(embed_dim, embed_dim)

# Project embeddings to Q, K, V spaces
queries = torch.matmul(embeddings, W_q)
keys = torch.matmul(embeddings, W_k)
values = torch.matmul(embeddings, W_v)

# Compute attention scores (dot product of queries and keys)
scores = torch.matmul(queries, keys.transpose(-2, -1)) / (embed_dim ** 0.5)
attention_weights = F.softmax(scores, dim=-1)

# Weighted sum of values
output = torch.matmul(attention_weights, values)

print("Attention weights:\n", attention_weights.squeeze(0))
print("\nOutput embeddings:\n", output.squeeze(0))

Scaled Dot-Product Attention: The Math Behind the Magic

The scaled dot-product attention formula is deceptively simple but powerful: Attention(Q, K, V) = softmax(QKᵀ / √dₖ)V, where Q, K, and V are the query, key, and value matrices, and dₖ is the dimension of the key vectors. The scaling factor √dₖ is crucial because it prevents the dot products from growing too large in magnitude as the dimensionality increases, which would push the softmax into regions with extremely small gradients. Without scaling, large dot products would saturate the softmax, making it hard for the model to learn meaningful attention patterns. The softmax ensures that the attention weights sum to 1, creating a probability distribution over the input tokens. This means the output for each token is a convex combination of the value vectors, where the weights are determined by how well the query matches each key. The result is a new representation for each token that incorporates information from all other tokens, weighted by their relevance.

# Implementing scaled dot-product attention from scratch
import math

def scaled_dot_product_attention(query, key, value, mask=None):
    # query, key, value shapes: (..., seq_len, d_k)
    d_k = query.size(-1)
    
    # Compute scaled dot products
    scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
    
    # Apply mask (for decoder self-attention)
    if mask is not None:
        scores = scores.masked_fill(mask == 0, -1e9)
    
    # Softmax to get attention weights
    attention_weights = F.softmax(scores, dim=-1)
    
    # Weighted sum of values
    output = torch.matmul(attention_weights, value)
    
    return output, attention_weights

# Test with sample inputs
query = torch.randn(1, 5, 64)  # (batch, seq_len, d_k)
key = torch.randn(1, 5, 64)
value = torch.randn(1, 5, 64)

output, weights = scaled_dot_product_attention(query, key, value)
print("Output shape:", output.shape)
print("Attention weights shape:", weights.shape)

Multi-Head Attention: Why One Attention Head Isn't Enough

A single attention head can only learn one type of relationship at a time. For example, it might focus on syntactic dependencies (like subject-verb agreement) or semantic relationships (like coreference), but not both simultaneously. Multi-head attention solves this by running multiple attention heads in parallel, each with its own set of query, key, and value projections. This allows the model to jointly attend to information from different representation subspaces at different positions. For instance, one head might focus on local dependencies (e.g., 'red car'), while another captures long-range relationships (e.g., 'The car that I bought last year is red'). The outputs of all heads are concatenated and linearly transformed to produce the final output. This design enables the model to capture diverse patterns without interference, as each head can specialize in a different type of relationship. The number of heads is a hyperparameter, but typically ranges from 8 to 16 in modern architectures, balancing computational cost with representational power.

# Implementing multi-head attention
class MultiHeadAttention(torch.nn.Module):
    def __init__(self, embed_dim, num_heads):
        super().__init__()
        self.embed_dim = embed_dim
        self.num_heads = num_heads
        self.head_dim = embed_dim // num_heads
        
        # Ensure embed_dim is divisible by num_heads
        assert embed_dim % num_heads == 0, "embed_dim must be divisible by num_heads"
        
        # Linear projections for Q, K, V
        self.q_proj = torch.nn.Linear(embed_dim, embed_dim)
        self.k_proj = torch.nn.Linear(embed_dim, embed_dim)
        self.v_proj = torch.nn.Linear(embed_dim, embed_dim)
        
        # Final linear projection
        self.out_proj = torch.nn.Linear(embed_dim, embed_dim)
    
    def forward(self, query, key, value, mask=None):
        batch_size = query.size(0)
        
        # Project Q, K, V
        query = self.q_proj(query)  # (batch, seq_len, embed_dim)
        key = self.k_proj(key)
        value = self.v_proj(value)
        
        # Split into multiple heads
        query = query.view(batch_size, -1, self.num_heads, self.head_dim).transpose(1, 2)
        key = key.view(batch_size, -1, self.num_heads, self.head_dim).transpose(1, 2)
        value = value.view(batch_size, -1, self.num_heads, self.head_dim).transpose(1, 2)
        
        # Compute attention for each head
        scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(self.head_dim)
        if mask is not None:
            scores = scores.masked_fill(mask == 0, -1e9)
        attention_weights = F.softmax(scores, dim=-1)
        output = torch.matmul(attention_weights, value)
        
        # Concatenate heads and apply final projection
        output = output.transpose(1, 2).contiguous().view(batch_size, -1, self.embed_dim)
        output = self.out_proj(output)
        
        return output, attention_weights

# Test multi-head attention
mha = MultiHeadAttention(embed_dim=64, num_heads=8)
query = torch.randn(1, 10, 64)
key = torch.randn(1, 10, 64)
value = torch.randn(1, 10, 64)

output, weights = mha(query, key, value)
print("Multi-head output shape:", output.shape)
print("Attention weights shape:", weights.shape)

Putting It All Together: Self-Attention in a Transformer Block

Self-attention is just one component of a transformer block, which also includes layer normalization, residual connections, and position-wise feed-forward networks. The full self-attention sub-layer starts with layer normalization, which stabilizes training by normalizing the inputs. The normalized inputs are then passed through the multi-head attention mechanism, where each token attends to every other token in the sequence. The attention output is added to the original input via a residual connection, which helps mitigate vanishing gradients in deep networks. This is followed by another layer normalization and a position-wise feed-forward network, which applies the same two-layer MLP to each position separately. The feed-forward network introduces non-linearity and allows the model to transform the attention outputs into more complex representations. The entire block is designed to be highly parallelizable, making it efficient to train on modern hardware. This architecture enables transformers to scale to very deep networks, which is why they dominate modern NLP tasks.

# Full transformer block with self-attention
class TransformerBlock(torch.nn.Module):
    def __init__(self, embed_dim, num_heads, ff_dim, dropout=0.1):
        super().__init__()
        self.attention = MultiHeadAttention(embed_dim, num_heads)
        self.norm1 = torch.nn.LayerNorm(embed_dim)
        self.norm2 = torch.nn.LayerNorm(embed_dim)
        
        # Position-wise feed-forward network
        self.ffn = torch.nn.Sequential(
            torch.nn.Linear(embed_dim, ff_dim),
            torch.nn.ReLU(),
            torch.nn.Linear(ff_dim, embed_dim)
        )
        self.dropout = torch.nn.Dropout(dropout)
    
    def forward(self, x, mask=None):
        # Self-attention sub-layer
        attn_output, _ = self.attention(x, x, x, mask)
        x = x + self.dropout(attn_output)
        x = self.norm1(x)
        
        # Feed-forward sub-layer
        ff_output = self.ffn(x)
        x = x + self.dropout(ff_output)
        x = self.norm2(x)
        
        return x

# Test transformer block
block = TransformerBlock(embed_dim=64, num_heads=8, ff_dim=256)
inputs = torch.randn(1, 10, 64)  # (batch, seq_len, embed_dim)
outputs = block(inputs)
print("Transformer block output shape:", outputs.shape)

Key points

  • Self-attention allows each token in a sequence to dynamically weigh the importance of every other token, solving the long-range dependency problem that plagued earlier architectures like RNNs.
  • The query, key, and value vectors in self-attention are learned projections that enable the model to determine which parts of the input are most relevant for each token's representation.
  • Scaled dot-product attention uses the dot product of queries and keys, scaled by the square root of the key dimension, to compute attention weights that are then used to create a weighted sum of values.
  • Multi-head attention runs multiple attention heads in parallel, allowing the model to capture diverse types of relationships (e.g., syntactic and semantic) without interference between them.
  • Residual connections and layer normalization are critical components of the transformer block, helping to stabilize training and enable very deep networks.
  • The position-wise feed-forward network in a transformer block applies the same two-layer MLP to each position separately, introducing non-linearity and enabling complex transformations of the attention outputs.
  • Self-attention is highly parallelizable, making it efficient to train on modern hardware and enabling the scaling of transformers to billions of parameters.
  • Understanding self-attention and multi-head attention is essential for debugging and improving transformer-based models, as these mechanisms directly control how the model processes and relates information.

Common mistakes

  • Mistake: Treating self-attention as a fixed-weight operation like a convolution. Why it's wrong: Self-attention dynamically computes weights based on input tokens, unlike static convolution kernels. Fix: Emphasize that attention weights are recalculated for every input sequence using query-key dot products.
  • Mistake: Ignoring the role of scaling in the dot-product attention formula. Why it's wrong: Without scaling by the square root of the key dimension, large dot products can lead to vanishing gradients in softmax. Fix: Always include the scaling factor (1/sqrt(d_k)) in the attention score calculation.
  • Mistake: Assuming multi-head attention just repeats single-head attention. Why it's wrong: Multi-head attention projects inputs into multiple subspaces, allowing the model to jointly attend to different representation patterns. Fix: Highlight that each head learns distinct query, key, and value projections.
  • Mistake: Forgetting to mask future tokens in decoder self-attention. Why it's wrong: Without masking, the decoder can 'cheat' by looking at future tokens during training, breaking autoregressive properties. Fix: Apply a causal mask to ensure each position only attends to itself and earlier positions.
  • Mistake: Treating attention weights as interpretable feature importance. Why it's wrong: Attention weights can be noisy and don't always correlate with input importance due to softmax saturation and multi-head interactions. Fix: Use attention weights cautiously for interpretation, and rely on controlled experiments for explainability.

Interview questions

What is the self-attention mechanism in the context of building your own large language model?

Self-attention is a core component of transformer architectures used in large language models. It allows the model to weigh the importance of different words in a sequence relative to each other, capturing long-range dependencies. For example, in the sentence 'The cat sat on the mat because it was tired,' self-attention helps the model understand that 'it' refers to 'cat' by assigning higher attention scores to 'cat' when processing 'it.' The mechanism works by computing three vectors for each word: queries, keys, and values. The attention score is calculated as the dot product of queries and keys, scaled, and passed through a softmax to get weights. These weights are then used to compute a weighted sum of the values. Here’s a simplified code snippet: `attention_scores = softmax((queries @ keys.T) / sqrt(d_key)) @ values`. This enables the model to focus on relevant parts of the input dynamically.

Why is scaling the dot product of queries and keys necessary in self-attention?

Scaling the dot product of queries and keys is crucial to prevent the gradients from becoming too small during training, which can lead to vanishing gradients. When the dot products of queries and keys are large, the softmax function can produce extremely peaked distributions, meaning only a few values get significant weight while others are close to zero. This makes the gradients very small, slowing down or even halting learning. By scaling the dot product by the square root of the key dimension, `sqrt(d_key)`, we ensure the values remain in a range where the softmax produces more balanced gradients. For example, in code: `attention_scores = softmax((queries @ keys.T) / sqrt(d_key))`. This scaling trick stabilizes training and helps the model converge faster, which is especially important in large language models where attention layers are stacked deeply.

How does multi-head attention differ from single-head attention, and what advantages does it offer?

Multi-head attention extends single-head attention by running multiple self-attention mechanisms in parallel, called 'heads,' and then combining their outputs. In single-head attention, the model computes one set of attention scores for the entire input, which can limit its ability to focus on different types of relationships. Multi-head attention allows the model to jointly attend to information from different representation subspaces at different positions. For example, one head might focus on syntactic relationships, while another captures semantic dependencies. The outputs of all heads are concatenated and linearly transformed to produce the final output. Here’s how it’s implemented: `multi_head_output = concat(head_1, head_2, ..., head_h) @ W_o`, where each head computes its own attention scores. This approach improves the model’s capacity to learn diverse patterns, leading to better performance on complex tasks like language understanding and generation.

Compare the computational efficiency of self-attention with recurrent layers like RNNs or LSTMs in a large language model. Which one would you choose for building your own LLM and why?

Self-attention and recurrent layers like RNNs or LSTMs differ significantly in computational efficiency, especially for long sequences. RNNs process sequences sequentially, which makes them inherently slow for long inputs due to their inability to parallelize computations. Self-attention, on the other hand, computes attention scores for all positions simultaneously, allowing for full parallelization across the sequence. This makes self-attention much faster during training, especially on modern hardware like GPUs or TPUs. However, self-attention has a quadratic time and memory complexity, O(n²), with respect to sequence length, which can be prohibitive for very long sequences. For building a large language model, I would choose self-attention because its parallelizability and ability to capture long-range dependencies outweigh the drawbacks. Techniques like sparse attention or memory-efficient implementations can mitigate the quadratic complexity issue, making self-attention the better choice for scalability and performance.

Explain how positional encodings are integrated with self-attention in a transformer model. Why are they necessary?

Positional encodings are added to the input embeddings in a transformer model to provide the self-attention mechanism with information about the order of words in a sequence. Unlike RNNs or LSTMs, self-attention does not inherently capture sequential order because it processes all positions in parallel. Positional encodings are typically sine and cosine functions of different frequencies, which allow the model to learn relative positions. For example, the positional encoding for position `pos` and dimension `i` is computed as: `PE(pos, i) = sin(pos / 10000^(i/d_model))` for even `i` and `PE(pos, i) = cos(pos / 10000^(i/d_model))` for odd `i`. These encodings are added to the input embeddings before being fed into the self-attention layers. Without positional encodings, the model would treat the input as a bag of words, losing critical sequential information. This integration enables the transformer to understand the context and relationships between words based on their positions in the sequence.

Walk me through the implementation of a multi-head attention layer in your own LLM. Include the key steps and explain how you would handle the dimensions and projections.

Implementing a multi-head attention layer involves several key steps, starting with splitting the input into multiple heads, computing attention for each head, and then combining the results. First, the input embeddings of shape `(batch_size, seq_len, d_model)` are projected into queries, keys, and values using three separate linear layers: `W_q`, `W_k`, and `W_v`, each of shape `(d_model, d_model)`. These projections are split into `h` heads, so each head has dimensions `(batch_size, seq_len, d_model // h)`. For each head, we compute the attention scores as `softmax((queries @ keys.T) / sqrt(d_key)) @ values`, where `d_key = d_model // h`. The outputs of all heads are concatenated along the last dimension, resulting in a tensor of shape `(batch_size, seq_len, d_model)`. Finally, this concatenated output is passed through a final linear layer `W_o` of shape `(d_model, d_model)` to produce the multi-head attention output. Here’s a simplified code outline: `queries, keys, values = split_heads(linear_proj(x))`, `attn_output = concat([scaled_dot_product_attention(q, k, v) for q, k, v in zip(queries, keys, values)])`, `output = linear_proj(attn_output)`. This approach ensures the model can attend to different parts of the input simultaneously while maintaining the original embedding dimension.

All Creating Own LLM interview questions →

Check yourself

1. In self-attention, why do we scale the dot product of queries and keys by the square root of the key dimension?

  • A.To prevent the softmax from saturating due to large dot product values, which would cause gradient vanishing
  • B.To normalize the attention weights so they sum to 1 across all keys
  • C.To reduce the computational cost of the attention mechanism
  • D.To ensure the attention weights are positive and can be interpreted as probabilities
Show answer

A. To prevent the softmax from saturating due to large dot product values, which would cause gradient vanishing
The correct answer is the first option: scaling prevents softmax saturation by keeping dot products in a reasonable range, which maintains gradient flow during backpropagation. The second option is incorrect because softmax already normalizes weights. The third option is wrong because scaling adds minimal computation. The fourth option is incorrect because softmax inherently produces positive probabilities, regardless of scaling.

2. What is the primary advantage of using multi-head attention over single-head attention in an LLM?

  • A.It allows the model to jointly attend to information from different representation subspaces at different positions
  • B.It reduces the number of parameters by sharing weights across heads
  • C.It speeds up training by parallelizing attention computations across heads
  • D.It guarantees better interpretability by isolating attention patterns per head
Show answer

A. It allows the model to jointly attend to information from different representation subspaces at different positions
The correct answer is the first option: multi-head attention enables the model to capture diverse patterns by learning distinct query, key, and value projections for each head. The second option is incorrect because multi-head attention increases parameters. The third option is partially true but not the primary advantage. The fourth option is wrong because attention heads are not inherently interpretable.

3. During training of an autoregressive LLM, why must future tokens be masked in the decoder's self-attention?

  • A.To prevent the model from 'cheating' by attending to future tokens, which would break the autoregressive property
  • B.To reduce memory usage by ignoring irrelevant tokens
  • C.To stabilize training by limiting the attention span to nearby tokens
  • D.To enforce a fixed context window size for all inputs
Show answer

A. To prevent the model from 'cheating' by attending to future tokens, which would break the autoregressive property
The correct answer is the first option: masking future tokens ensures the model only attends to past and current tokens, preserving the autoregressive nature of generation. The second option is incorrect because masking doesn't reduce memory usage. The third option is wrong because masking isn't about stabilization. The fourth option is incorrect because masking doesn't enforce a fixed window size.

4. How does the self-attention mechanism dynamically adapt to different input sequences?

  • A.By computing attention weights as a function of the input tokens, allowing different tokens to attend to each other based on their content
  • B.By using pre-trained static weights that are fine-tuned for specific tasks
  • C.By randomly initializing attention weights for each input sequence
  • D.By normalizing the input tokens to a fixed length before computing attention
Show answer

A. By computing attention weights as a function of the input tokens, allowing different tokens to attend to each other based on their content
The correct answer is the first option: self-attention dynamically computes attention weights via query-key dot products, making it input-dependent. The second option describes static weights, not self-attention. The third option is incorrect because weights are learned, not random. The fourth option is wrong because normalization doesn't enable dynamic adaptation.

5. In multi-head attention, what is the purpose of concatenating the outputs of all heads and projecting them through a final linear layer?

  • A.To combine the diverse patterns learned by each head into a unified representation for the next layer
  • B.To reduce the dimensionality of the output to match the input dimension
  • C.To ensure each head contributes equally to the final output
  • D.To normalize the attention weights across all heads
Show answer

A. To combine the diverse patterns learned by each head into a unified representation for the next layer
The correct answer is the first option: concatenation and projection integrate the specialized representations from each head into a cohesive output. The second option is incorrect because dimensionality reduction isn't the primary goal. The third option is wrong because heads don't contribute equally. The fourth option is incorrect because normalization isn't the purpose of this step.

Take the full Creating Own LLM quiz →

← PreviousArchitecture of Transformer Models: Encoder-Decoder vs. Decoder-OnlyNext →Positional Encoding and Its Importance

Creating Own LLM

36 lessons, free to read.

All lessons →

Track your progress

Sign in to mark lessons done, score quizzes and keep notes.

Open in the app