In 2017 a research group at Google published the historic paper "Attention Is All You Need", introducing the Transformer architecture. It ended decades of dominance by recurrent RNN/LSTM networks and opened the era of the large language models reshaping the world today.

The Transformer's key move is removing sequential time-step loops entirely, allowing 100% parallel computation on GPU hardware. This lesson puts the architecture under the microscope: the mathematics of scaled dot-product self-attention (Q, K, V), multi-head attention, positional encoding, and assembling a complete Transformer block by hand in PyTorch.

βœ… What you need before starting
Libraries: pip install torch. No downloads, no network.

Knowledge you need: Lesson 9 β€” the idea of attention and why it exists (the bottleneck of a static context vector). This lesson takes that same idea and removes the recurrence entirely. Lesson 2 supplies the dot product, Lesson 3 matrix multiplication and shape matching β€” section 10.1 uses both constantly.

10.1 The parallelisation revolution: self-attention

As we saw in Lesson 9, a recurrent network processes a sentence one word at a time. It cannot compute word 10 until it has finished the hidden state for word 9.

A Transformer, by contrast, processes every word in the sentence at once. To capture the context linking words together it introduces self-attention. Each input word computes how semantically relevant it is to every other word in the sentence, through three representative vectors:

  • Query ($Q$): represents the current word going looking for relevant context.
  • Key ($K$): represents the identifying label of the other words, to be matched against.
  • Value ($V$): represents the actual content of that word, which gets carried forward once the appropriate attention has been found.
πŸ“ The scaled dot-product self-attention formula
Given Query $Q$, Key $K$ and Value $V$ matrices (where $d_k$ is the Key's dimensionality), self-attention is: $$\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{Q K^T}{\sqrt{d_k}}\right) V$$ Step by step:
  • Multiply Q by K ($Q K^T$): measure the similarity (dot product) between every pair of words in the sentence. The result is a square $N \times N$ matrix ($N$ being the sequence length).
  • Scale by $\sqrt{d_k}$: when $d_k$ is large the dot products $Q K^T$ tend to take very large values, pushing Softmax into a saturated region with a tiny gradient (vanishing gradient). Dividing by $\sqrt{d_k}$ keeps the score distribution at variance 1, which keeps backpropagation stable.
  • Softmax: turns those similarities into an attention distribution between 0 and 1.
  • Multiply by Value ($V$): weight the actual Value content by the attention probabilities, producing high-level aggregated context vectors.
⚠️ Pitfall: the quadratic O(N²) cost
Because the square matrix product $Q K^T$ compares every word with every other word in the sentence, a self-attention layer's time and space complexity grows as $\mathcal{O}(N^2)$ in the context length $N$. That is why LLMs consume enormous amounts of VRAM as you enlarge the chat context window, and it is the Transformer architecture's single biggest bottleneck.
πŸ”’ The real numbers: why dividing by $\sqrt{d_k}$ stabilises the variance
Suppose the components of $Q$ and $K$ are drawn independently from a standard normal distribution $\mathcal{N}(0, 1)$. A statistical simulation (20,000 samples per case) shows:
  • $d_k = 8$: variance of $Q K^T$ (unscaled) $\approx 8.09$ β€” roughly equal to $d_k$.
  • $d_k = 64$: variance $\approx 63.22$.
  • $d_k = 512$: variance $\approx 512.09$.
Exactly as theory predicts: the variance of a dot product of $d_k$ independent terms grows linearly with $d_k$. After dividing by $\sqrt{d_k}$, all three cases land at a variance of approximately $1.0$ (0.988 β†’ 1.011 β†’ 1.0002) regardless of how large $d_k$ is β€” which is precisely why the scaling factor keeps the score distribution stable no matter the model size.

10.2 Learning in several directions: multi-head attention & positional encoding

With a single attention stream the model can only focus on one kind of semantic link at a time β€” a noun-to-verb grammatical relation, say.

The fix is multi-head attention. Rather than computing attention directly on vectors of the full width $d_{\text{model}}$, the network splits those dimensions into $h$ parallel streams (each of the smaller width $d_k = d_{\text{model}} / h$). Each head gets independently initialised weight matrices and is free to learn a different contextual relationship (head 1 subject-verb syntax; head 2 geographic relations; head 3 temporal relations). The results from all heads are then concatenated and projected through a Linear layer back to the original width.

Because a Transformer processes every word in parallel, the model has no idea of word order. To a Transformer, "I love you" and "You love me" have completely identical representation matrices β€” the sentence is a shuffled bag of words. To inject order information we must add a positional encoding vector directly onto the word embedding before it reaches the first attention block.

πŸ“ Sinusoidal positional encoding
In Google's original paper the researchers used sine and cosine waves of differing frequencies to generate a unique positional vector for coordinate $pos$ and dimension $i$: $$PE_{(pos, 2i)} = \sin\left(\frac{pos}{10000^{\frac{2i}{d_{\text{model}}}}}\right)$$ $$PE_{(pos, 2i+1)} = \cos\left(\frac{pos}{10000^{\frac{2i}{d_{\text{model}}}}}\right)$$ This sinusoidal relationship lets the model easily learn relative position (two words $k$ apart, for instance), because $PE_{pos+k}$ can be expressed as a linear function of $PE_{pos}$.
⚠️ A common misconception: multi-head attention costs NO extra parameters
Many learners assume that splitting into $h$ heads multiplies the parameter count by $h$. The truth is the opposite: the four nn.Linear layers ($W_q, W_k, W_v, W_o$) are always a fixed $d_{\text{model}} \times d_{\text{model}}$ regardless of how many heads there are, because "splitting into heads" is only a .view() reshape of an existing tensor β€” it creates no new weight matrices.

With $d_{\text{model}} = 64$ (computed and verified in code), the four Linear layers total exactly $16{,}640$ parameters whether you use $1$ head or $8$. The head count only changes how the memory is "sliced" to compute several contextual perspectives in parallel; it does not make the model heavier.

10.3 Encoder-decoder architecture and the GPT (decoder-only) model

The original Transformer architecture stacks two major parts:

  • Encoder: reads the whole source sentence to extract bidirectional features. Used in language-understanding models such as BERT.
  • Decoder: adds an autoregressive mechanism to generate each next word.

For the decoder, training text generation requires a mask called the causal mask (or look-ahead mask). This layer hides all future words by overwriting the $Q K^T$ dot-product scores at those positions with $-\infty$ before Softmax: $$\text{Softmax}(-\infty) = 0$$ This guarantees that when predicting the next word at step $t$, the model cannot "cheat" by seeing the data at steps $t+1$, $t+2$ during training.

πŸ’‘ The dominance of decoder-only models (GPT)
Most of today's most capable LLMs β€” GPT-4, Llama, Gemini, Claude β€” drop the traditional encoder entirely and move to a decoder-only design. That architecture is considerably simpler, scales more easily, and optimises autoregressive computation for creative, intelligent text generation.

10.4 Hands-on project: building a complete Transformer block in PyTorch

The project assembles everything above into one working Transformer block: multi-head attention, layer normalisation, residual connections and the feed-forward network.

transformer_block.py
import torch
import torch.nn as nn
import torch.nn.functional as F

class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, num_heads):
        super(MultiHeadAttention, self).__init__()
        assert d_model % num_heads == 0, "d_model must divide evenly by num_heads"
        
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads
        
        # Linear projections producing Query, Key and Value.
        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 projection, applied after the heads are concatenated.
        self.W_o = nn.Linear(d_model, d_model)
        
    def forward(self, q, k, v, mask=None):
        batch_size, seq_len, _ = q.size()
        
        # 1. Project the input, then split it across the heads.
        #    Shapes: (batch, seq, d_model) -> (batch, seq, heads, d_k) -> (batch, heads, seq, d_k)
        Q = self.W_q(q).view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
        K = self.W_k(k).view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
        V = self.W_v(v).view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2)
        
        # 2. Scaled dot-product scores. Dividing by sqrt(d_k) keeps the values in a
        #    range where Softmax does not saturate.
        # Q K^T: (Batch, heads, Seq, d_k) x (Batch, heads, d_k, Seq) -> (Batch, heads, Seq, Seq)
        scores = torch.matmul(Q, K.transpose(-2, -1)) / torch.sqrt(torch.tensor(self.d_k, dtype=torch.float32))
        
        # Apply the mask. -1e9 before Softmax becomes effectively 0 after it.
        if mask is not None:
            scores = scores.masked_fill(mask == 0, -1e9)
            
        # 3. Softmax turns the scores into attention weights summing to 1.
        attention_weights = F.softmax(scores, dim=-1)
        
        # 4. Weight the Values, then restore the original dimensions.
        # (Batch, heads, Seq, Seq) x (Batch, heads, Seq, d_k) -> (Batch, heads, Seq, d_k)
        context = torch.matmul(attention_weights, V)
        
        #    Concatenate the heads back together: (batch, seq, d_model)
        context = context.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)
        
        # Output projection.
        output = self.W_o(context)
        return output

class FeedForwardNetwork(nn.Module):
    def __init__(self, d_model, d_ff):
        super(FeedForwardNetwork, self).__init__()
        self.linear1 = nn.Linear(d_model, d_ff)
        self.relu = nn.ReLU()
        self.linear2 = nn.Linear(d_ff, d_model)
        
    def forward(self, x):
        return self.linear2(self.relu(self.linear1(x)))

class TransformerBlock(nn.Module):
    def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
        super(TransformerBlock, self).__init__()
        
        # Multi-head attention.
        self.attention = MultiHeadAttention(d_model, num_heads)
        
        # LayerNorm, applied around each sub-block (the residual connections below
        # are what let gradients reach the early layers of a deep stack).
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        
        # The position-wise feed-forward network.
        self.feed_forward = FeedForwardNetwork(d_model, d_ff)
        
        self.dropout = nn.Dropout(dropout)
        
    def forward(self, x, mask=None):
        # 1. Self-attention + residual connection + LayerNorm.
        #    x + attn_out is the residual: it gives gradients a path straight back.
        attn_out = self.attention(x, x, x, mask)
        x = self.norm1(x + self.dropout(attn_out))
        
        # 2. Feed-forward + residual connection + LayerNorm, same pattern again.
        ff_out = self.feed_forward(x)
        x = self.norm2(x + self.dropout(ff_out))
        
        return x

if __name__ == "__main__":
    # Seed, so the numbers below are the same on every run.
    torch.manual_seed(42)

    d_model, num_heads, d_ff = 64, 8, 256
    seq_len, batch_size = 10, 2

    block = TransformerBlock(d_model=d_model, num_heads=num_heads, d_ff=d_ff)
    block.eval()  # no dropout, so the causal test below is deterministic
    print(f"=== Transformer block: d_model={d_model}, heads={num_heads}, d_ff={d_ff} ===")

    x = torch.randn(batch_size, seq_len, d_model)
    print(f"input shape:  {tuple(x.shape)}")

    # Lower-triangular mask: position i may attend to 0..i, never to i+1 onwards.
    causal_mask = torch.tril(torch.ones(seq_len, seq_len)).unsqueeze(0).unsqueeze(1)
    print(f"causal mask shape: {tuple(causal_mask.shape)}")

    with torch.no_grad():
        output = block(x, mask=causal_mask)
    print(f"output shape: {tuple(output.shape)}  (identical to the input, as it must be)")

    # ---------------------------------------------------------------- the real test
    # Matching shapes prove almost nothing: an attention that ignored the mask
    # entirely would still return the right shape. The property that actually
    # matters for a decoder (GPT) is CAUSALITY β€” position i must not see i+1.
    #
    # So: change ONLY the last token and re-run. If the mask works, every earlier
    # position must come out bit-for-bit identical, because none of them was
    # allowed to look at the token we changed.
    print("\n=== Does the causal mask actually work? ===")
    x_perturbed = x.clone()
    x_perturbed[:, -1, :] = torch.randn(batch_size, d_model)  # rewrite the LAST token

    with torch.no_grad():
        output_perturbed = block(x_perturbed, mask=causal_mask)

    earlier_drift = (output[:, :-1, :] - output_perturbed[:, :-1, :]).abs().max().item()
    last_drift = (output[:, -1, :] - output_perturbed[:, -1, :]).abs().max().item()

    print(f"  changed the last token only")
    print(f"  largest change in positions 0..{seq_len - 2}: {earlier_drift:.2e}")
    print(f"  largest change in the last position:  {last_drift:.4f}")

    if earlier_drift < 1e-6 < last_drift:
        print("  PASS β€” earlier positions did not move, the last one did.")
        print("  That is causality: the past cannot see the future.")
    else:
        print("  FAIL β€” the mask is leaking information backwards in time.")

    # And the counter-test: without a mask, changing the last token must disturb
    # everything, because every position now attends to every other.
    with torch.no_grad():
        free = block(x, mask=None)
        free_perturbed = block(x_perturbed, mask=None)
    free_drift = (free[:, :-1, :] - free_perturbed[:, :-1, :]).abs().max().item()
    print(f"\n  same experiment with NO mask: earlier positions moved by {free_drift:.4f}")
    print("  Non-zero, as expected β€” which confirms the test above measures the mask")
    print("  and not some accident of the architecture.")

Running it produces:

Terminal
=== Transformer block: d_model=64, heads=8, d_ff=256 ===
input shape:  (2, 10, 64)
causal mask shape: (1, 1, 10, 10)
output shape: (2, 10, 64)  (identical to the input, as it must be)

=== Does the causal mask actually work? ===
  changed the last token only
  largest change in positions 0..8: 0.00e+00
  largest change in the last position:  3.9485
  PASS β€” earlier positions did not move, the last one did.
  That is causality: the past cannot see the future.

  same experiment with NO mask: earlier positions moved by 0.2319
  Non-zero, as expected β€” which confirms the test above measures the mask
  and not some accident of the architecture.
πŸ”¬ Why this test matters more than matching shapes
An output shape equal to the input shape is necessary, but it proves almost nothing. An attention mechanism that ignored the mask completely β€” or that merely averaged every token β€” would return exactly that shape and still "pass". If you implement attention yourself and only check shapes, you can ship a bug that destroys the whole model.

The property that actually has to hold in a decoder (i.e. GPT) is causality: position $i$ must not see position $i+1$. If it can, the model "cheats" during training β€” it sees the very word it is supposed to predict β€” and then collapses when generating real text, because there the future does not exist yet.

The test in the script is simple and decisive: change only the last token and rerun. If the mask is correct, every earlier position must come out bit-for-bit identical, because none of them was allowed to look at the token we changed. The measured result is 0.00e+00: not "very small" but exactly zero.

And the counter-test is what gives the test its value: rerun with no mask and the earlier positions move by 0.2319 β€” clearly non-zero. Without that step, the zero above might merely reflect an architecture that happens not to propagate information, rather than a mask doing its job. This is a habit worth carrying: a test that returns "as expected" only earns trust once you have also shown it knows how to fail.

How to run this project on your own machine

  1. Install: pip install torch.
  2. Download transformer_block.py at the end of the lesson, or retype the code above.
  3. Run it: python3 transformer_block.py. You will get exactly the numbers above thanks to torch.manual_seed(42) and block.eval() β€” eval() disables dropout, without which two runs differ and the causality test reports a false failure.
  4. Then break it in two ways:
    • Delete the line in MultiHeadAttention that applies the mask (the -1e9 fill). The test flips from PASS to FAIL, and you see exactly how that bug presents β€” rather than having to trust a description of it.
    • Change torch.tril to torch.triu (an upper triangular mask). The model still runs, the shapes are still right, but now it can only see the future β€” precisely the class of bug a shape check will never catch.

Lesson summary & bridge to what's next

πŸ”‘ What you now have:
  • Achieved: Q, K and V as three roles rather than three mysteries, and the four steps of scaled dot-product attention.
  • Achieved: why the $\sqrt{d_k}$ divisor exists, measured: the variance of $Q K^T$ grows linearly with $d_k$, and the division returns it to 1 at any model size.
  • Achieved: multi-head attention costs no extra parameters β€” 16,640 either way at $d_{\text{model}} = 64$, because splitting heads is a reshape, not new weights.
  • Achieved: why positional encoding is mandatory β€” without it "I love you" and "You love me" are the same matrix.
  • Achieved: a causal mask, and a test that proves it works instead of assuming it β€” including the counter-test that shows the test can fail.
  • Achieved: the $\mathcal{O}(N^2)$ cost, and therefore why a longer context window is expensive rather than free.

Bridge to the next lesson: the architecture is understood. From Lesson 11 the series stops building models and starts using them: talking to a large language model through its API, and controlling what comes back.

Download the hands-on code for this lesson

The Python file transformer_block.py β€” a complete Transformer block, plus the causality test that proves the mask actually works (run python transformer_block.py, needs torch):

Download transformer_block.py

πŸ“– Further reading

Related lessons in this series

Lesson 9: Recurrent networks (RNN) and the rise of attention Lesson 11: Prompt programming & mastering the LLM API Back to the Practical AI Engineer roadmap

Comments