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.
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.
- 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.
- $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$.
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.
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.
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.
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:
=== 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.
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
- Install:
pip install torch. - Download
transformer_block.pyat the end of the lesson, or retype the code above. -
Run it:
python3 transformer_block.py. You will get exactly the numbers above thanks totorch.manual_seed(42)andblock.eval()βeval()disables dropout, without which two runs differ and the causality test reports a false failure. -
Then break it in two ways:
-
Delete the line in
MultiHeadAttentionthat applies the mask (the-1e9fill). 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.triltotorch.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.
-
Delete the line in
Lesson summary & bridge to what's next
- 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):
Comments