Transformers From Scratch Lab
model.generate() in one line. That is convenient until the model attends to words that come later in the sentence, or a cached decode gives different text from an uncached one, and no message says why. This lab has eight exercises. Each is a short function you write yourself in numpy, then check with hidden tests that run in your browser. Each one starts with a plain-words setup and a trace by hand, so you know the right number before you type a line.After this lesson, you will be able to:
- Write one byte-pair-encoding merge step: count neighbouring pairs, pick the most frequent, and merge it left to right
- Write sinusoidal positional encoding with one broadcast, and show that moving k positions rotates each sine and cosine pair
- Write scaled dot-product attention that survives huge scores, and say why the scores are divided by the square root of d_k
- Write the causal mask and apply it before the softmax, so that no token can see a later one
- Write the split and merge of attention heads, and keep the shapes and the column order straight
- Write layer norm, then a full pre-norm transformer block, and prove that cached decoding gives exactly the numbers of full recomputation
Before You Start
#How This Lab Works
TODO marks. It has a Run button and a Check my work button, and under it sits a Show solution button. The hints appear one at a time, so you can take the smallest nudge that gets you moving.Check my work runs your code, then runs a list of hidden tests, one at a time. It tells you how many passed and shows the message of the first one that failed. The starter is built to fail on purpose, so a fresh cell always says some checks failed. When you finish the TODO lines, all of them pass.
Three things about the tests are worth knowing before you start.
- They are tolerance based. A result close enough to the right answer passes, so a harmless rounding difference never fails you.
- They are deterministic. Any random numbers come from a fixed seed, so a test that fails today fails the same way tomorrow.
- They check more than the one example in the hand trace. Some compare your answer with a second route to the same number, such as a slow loop version. Others test a property that must hold, such as "changing a later token must not change an earlier output". If your code only matches the trace, it will still fail those.
The cells use numpy only. The browser has no torch, so none of this needs a graphics card, and each cell runs in a moment.
A piece of text the model reads as one unit. It may be a whole word, part of a word, or a single character.
Three lists of numbers made from each token. A query asks what to look for, a key says what a token offers, and a value is what gets passed on when the key matches.
For one token, a list of positive numbers that add up to 1, one per token it may look at. They say how much of each value to mix in.
A table of allowed and forbidden pairs that stops a token from looking at tokens after it. It is what lets a model learn to predict the next token.
One of several small attention computations that run side by side, each on its own slice of the columns.
Adding a layer's input back onto its output, so the layer only has to learn a change to the input.
Rescaling the numbers of one token so they have mean 0 and variance 1 across its own features, then applying a learned scale and shift.
The keys and values of the tokens already processed, kept so that generating the next token does not recompute them.
#The Conventions Used Here
Many mistakes in this area are not about maths. They are about which of two conventions a sentence uses. Fix them before writing code. This table is the single source for the whole lab, and the tests assume it.
| Quantity | Meaning in this lab |
|---|---|
| Tokens for BPE | A list of strings. A pair is a tuple (left, right). Counts include overlapping pairs, so aaa holds the pair (a, a) twice. A merge scans left to right and never reuses a token. A tie goes to the pair that appears first in the list. A pair seen fewer than min_count (2) times is not merged. |
| Sequence | X has shape (n, d_model): n tokens as rows, d_model numbers per token as columns. |
| Attention | Q is (n_q, d_k), K is (n_k, d_k), V is (n_k, d_v). The scores are Q @ K.T / sqrt(d_k). Softmax runs across the keys (axis 1), so each row of the weights adds up to 1. |
| Mask | True means "allowed". Forbidden scores are set to minus infinity BEFORE the softmax, never after. Query i may see keys 0 to i. |
| Heads | Shape (n_heads, n, d_head) with d_head = d_model / n_heads. Head h holds columns h * d_head up to (h + 1) * d_head. |
| Positional encoding | Column 2i is sin(pos / 10000^(2i / d_model)) and column 2i + 1 is the cosine of the same angle. i counts pairs, and d_model is even. |
| Layer norm | Mean and variance over the last axis. The variance divides by d. eps (1e-5) is added INSIDE the square root. gamma scales and beta shifts, after normalizing. |
| Block | Pre-norm: x = x + attention(norm(x)), then x = x + feed_forward(norm(x)). The feed-forward layer is relu(h @ W1 + b1) @ W2 + b2. Real models often use a different activation, and the lab uses ReLU so that the numbers stay simple. |
| Cache | A dict with exactly two entries, "K" and "V", each with one row per token seen so far. A step returns a NEW cache and never edits the one it was given. |
When a convention differs from the book you learned from, the tests follow this table. Neither is wrong, and a small change of convention gives different numbers, so name yours before you compare results.
#Exercise 1: One Byte-Pair-Encoding Merge Step
aaa the pair (a, a) occurs at positions 0-1 and at positions 1-2. When you merge, you scan left to right and jump over both tokens you just joined, so aaa becomes aa followed by a.aaabdaaabac. The pairs are aa, aa, ab, bd, da, aa, aa, ab, ba, ac. So (a, a) occurs 4 times, (a, b) twice, and the rest once each. Merging (a, a) gives aa a b d aa a b a c, which is 9 tokens. Now (aa, a) and (a, b) both occur twice, and (aa, a) appears first, so it wins the tie and the tokens become aaa b d aaa b a c. One more step merges (aaa, b) and leaves aaab d aaab a c.One BPE merge step
Write count_pairs, merge_pair and bpe_merge_step. A step finds the most frequent pair (the first one on a tie), merges it from left to right, and does nothing when no pair occurs at least min_count times. Press Check my work to run the hidden tests.
(a, a) twice instead of four times in the trace, and the test says so. A random test then runs many steps on strings of a, b and c, and checks that the joined text never changes and the list always gets shorter.#Exercise 2: Sinusoidal Positional Encoding
pos / 10000^(2i / d_model), column 2i holds its sine, and column 2i + 1 holds its cosine. The sine and cosine of one pair must share one angle. That sharing is what lets the model turn "k steps later" into a rotation.[0, 1, 0, 1]. For position 1 the first pair has angle 1 / 10000^0 = 1, giving sin 1 = 0.8415 and cos 1 = 0.5403. The second pair has angle 1 / 10000^(2/4) = 1 / 100 = 0.01, giving 0.0100 and 1.0000. So row 1 is [0.8415, 0.5403, 0.0100, 1.0000].Sinusoidal positional encoding
Write positional_encoding(n_positions, d_model) so that it returns an (n_positions, d_model) array with sin in the even columns and cos in the odd columns, using the angle pos / 10000^(2i / d_model). Raise ValueError when d_model is odd. Press Check my work to run the hidden tests.
The test that does the most work is the rotation one. It takes the pair at position p, rotates it by the angle k times that pair's frequency, and demands the pair at position p + k. That holds only when sine sits in the even column, cosine in the odd column, and both use the same angle. Putting all the sines first and all the cosines second looks equally plausible, and fails it.
#Exercise 3: Scaled Dot-Product Attention
exp(1000) overflows a 64-bit float. This is the same trick as the stable softmax in the Deep Learning From Scratch Lab.[1, 0], two keys [1, 0] and [0, 1], and values [10, 0] and [0, 10]. The dot products are 1 and 0. Dividing by sqrt(2) gives scores 0.7071 and 0. Then e^0.7071 = 2.0281 and e^0 = 1, so the weights are 2.0281 / 3.0281 = 0.6698 and 0.3302. The output is 0.6698 x [10, 0] + 0.3302 x [0, 10] = [6.6976, 3.3024].Scaled dot-product attention
Write softmax_rows (safe for big scores) and scaled_dot_product_attention(Q, K, V), which returns the output and the weights. Press Check my work to run the hidden tests.
One test adds the same vector to every key. That adds the same number, Q dotted with that vector, to every score in a row, so the weights must not change. A softmax that is not shift-safe, or that runs along the wrong axis, fails it. Another test uses queries and keys a thousand times too large, with floating point errors turned into failures, to catch an overflow.
#Exercise 4: The Causal Mask
exp(-inf) is exactly 0, so the weight is exactly 0 and the remaining weights still add up to 1. Zeroing the weights after the softmax is a classic slip: the rows then add up to less than 1.[[T,F,F],[T,T,F],[T,T,T]]. If every score is equal, token 0 puts all its weight on itself, [1, 0, 0]. Token 1 splits evenly over two tokens, [0.5, 0.5, 0]. Token 2 splits over three, [0.333, 0.333, 0.333].The causal mask
Write causal_mask(n), a lower-triangular array of True, and finish causal_attention so that forbidden scores become minus infinity before the softmax. Press Check my work to run the hidden tests.
The test that matters most changes the last two tokens completely and demands that the outputs of the first three do not move. That is the property the mask exists for, stated without mentioning the mask. A leak of any kind, such as a transposed mask or a mask applied after the softmax, breaks it.
#Exercise 5: Splitting and Merging Heads
reshape(n_heads, n, d_head) has the right shape and the wrong contents, because it cuts the flat memory without respecting which numbers belong to which token. You must first reshape to (n, n_heads, d_head), which keeps every token's columns together, and then swap the first two axes.[[1, 2, 3, 4], [5, 6, 7, 8]], and 2 heads. Head 0 gets the first two columns of every token: [[1, 2], [5, 6]]. Head 1 gets the last two: [[3, 4], [7, 8]]. The result has shape (2, 2, 2), and merging puts the columns back in the original order.Split and merge attention heads
Write split_heads, merge_heads and multi_head_attention. Head h must hold exactly columns h * d_head to (h + 1) * d_head, and merge_heads(split_heads(x)) must give x back. Press Check my work to run the hidden tests.
[[3, 4], [7, 8]], and the bare reshape gives something else. Another test shuffles the input tokens and demands that the output rows are shuffled in the same way. Attention without a mask has no sense of order, so that equality must hold, and a bug that mixes tokens across the head axis breaks it.#Exercise 6: Layer Norm
[1, 2, 3, 4]. The mean is 2.5. The squared distances from the mean are 2.25, 0.25, 0.25, 2.25, so the variance is their average, 1.25, and the standard deviation is 1.118. Dividing the distances by that gives [-1.3416, -0.4472, 0.4472, 1.3416] (eps changes the fifth decimal). With gamma 1 and beta 0 that is the output.Layer norm
Write layer_norm(x, gamma, beta, eps) for an array of any shape whose last axis holds the features. Normalize each token over its own features, then scale by gamma and shift by beta. Press Check my work to run the hidden tests.
Two tests tell batch norm and layer norm apart. After your function, every row should have mean 0, and the columns should not. And a token normalized alone must equal the same token normalized inside a batch. Choosing the wrong axis fails both, and still returns an array of the right shape.
#Exercise 7: A Pre-Norm Transformer Block
In a pre-norm block the layer norm comes before the part and the residual adds the un-normalized input:
x = x + attention(norm(x)), then x = x + feed_forward(norm(x)).In a post-norm block the norm comes after the addition. Both orders exist, and many recent language models use pre-norm. They give different numbers, and the test knows which one you were asked for. In this exercise the layer norm, the softmax and the head split and merge are already written for you, and the weights are small fixed numbers. You write the causal attention step and the block that wires it up.
[0, 0.1, 0.2, 0.3], [0.4, 0.5, 0.6, 0.7] and [0.8, 0.9, 1.0, 1.1], the first output row should be [-0.5825, 0.02, 1.402, -0.8463].A pre-norm transformer block
Write causal_self_attention(h, p), which runs multi-head attention with the causal mask and ends with Wo, and transformer_block(x, p), which wraps attention and a ReLU feed-forward layer each in a pre-norm residual step. Press Check my work to run the hidden tests.
The reference in the tests is written a different way, with explicit loops over heads and rows, so a bug cannot hide by being the same bug twice. One test compares your output with the post-norm block and demands that it differs. Another changes the last token and demands that the earlier rows do not move, which checks the mask end to end.
#Exercise 8: KV-Cache Decoding That Equals Full Recompute
A cache is only useful if it is exact. It is an optimization, so the output must be identical to full recompute, to rounding error. Three details are easy to get wrong. The new token attends to itself, so its key and value must be added BEFORE it attends. Only keys and values are cached, never queries, since a query is used once. And a step must return a new cache and not edit the old one, or a caller that keeps the old cache gets silently wrong results.
KV-cache incremental decoding
Write empty_cache, decode_step, decode_all and projection_counts. decode_all must give the same outputs as the provided attend_full, which recomputes everything. Press Check my work to run the hidden tests.
attend_full. Further tests resume from the cache of the first three tokens and demand row 3, which is the real use of a cache, and run again with different weight shapes. A version that forgets to add the new key before attending, or that caches the raw inputs instead of the projected keys, gives answers that look plausible and differ by a large amount, and the test reports the size of the difference.#Where the Traps Are
Every exercise has one common wrong answer that looks right. They fall into four families.
exp of a large number overflows. Subtract the row maximum before exp, use minus infinity for forbidden scores, and keep eps inside the square root of layer norm so a constant token does not divide by zero.| Symptom | Likely cause |
|---|---|
| Generated text looks fine but a model scores unreasonably well in training | The causal mask is missing, transposed, or applied after the softmax, so tokens see their own answers |
| Attention weights in each row add up to less than 1 | The weights were masked after the softmax instead of the scores before it |
| nan or inf in the attention weights | A softmax without the row maximum shifted out, or a row where every score is minus infinity |
| Heads seem to attend to meaningless mixtures of tokens | A bare reshape was used in split_heads instead of reshape then transpose |
| The same token gets different outputs in different batches | Layer norm was taken down the batch instead of across the features |
| A deep stack trains badly or the signal vanishes | A residual connection is missing, or the skip path goes through the norm |
| Cached decoding drifts away from uncached decoding | The new key and value were added after attending, the raw inputs were cached, or the cache was edited in place |
#Capstone Checklist
Use this list to check that you are ready to read and write real transformer code. Tick each item only if you can do it without looking.
- I can write one BPE merge step and say why overlapping pairs count and why a merge scans left to right
- I can write sinusoidal positional encoding with one broadcast and say why sine and cosine share an angle
- I can write scaled dot-product attention that survives scores of 1000 and say why the scores are divided by the square root of d_k
- I can write the causal mask and say why it goes on the scores before the softmax
- I can write the split and merge of heads and say what a bare reshape gets wrong
- I can write layer norm and say how it differs from batch norm
- I can write a pre-norm block and say where each residual connection takes its input
- I can write cached decoding and test it against full recompute
- Given an attention implementation I have never seen, I can find the axis, order, stability and equivalence choices in under a minute
If you can tick every line, you can read the model code of a library and spot a convention mismatch. If you cannot, go back to the exercise for that line, open the solution, and write the function again without it.
Neural Machine Translation of Rare Words with Subword Units
Rico Sennrich, Barry Haddow, Alexandra Birch (2016)
Byte pair encoding applied to word segmentation, as in exercise 1.
Attention Is All You Need
Ashish Vaswani, Noam Shazeer, Niki Parmar, Jakob Uszkoreit, Llion Jones, Aidan Gomez, Lukasz Kaiser, Illia Polosukhin (2017)
Sinusoidal positional encoding, scaled dot-product attention, multi-head attention and the block, as in exercises 2 to 5 and 7.
Layer Normalization
Jimmy Lei Ba, Jamie Ryan Kiros, Geoffrey Hinton (2016)
Normalizing across the features of one example, as in exercise 6.
On Layer Normalization in the Transformer Architecture
Ruibin Xiong, Yunchang Yang, Di He, Kai Zheng, Shuxin Zheng, Chen Xing, Huishuai Zhang, Yanyan Lan, Liwei Wang, Tie-Yan Liu (2020)
A comparison of the two placements of the norm, the pre-norm block of exercise 7 and the post-norm one.
#Key Takeaways
- A BPE step counts every neighbouring pair including overlaps, merges the most frequent one left to right, and breaks ties by first appearance
- Sinusoidal positional encoding puts sin(pos / 10000^(2i / d_model)) in column 2i and cos of the same angle in column 2i + 1, so a shift of k positions is a rotation of each pair
- Attention is
softmax(Q K^T / sqrt(d_k)) V, with the row maximum subtracted so that large scores cannot overflow - The causal mask sets forbidden scores to minus infinity before the softmax, so later tokens get exactly zero weight and each row still adds up to 1
- Split heads with a reshape to (n, n_heads, d_head) and then a transpose. A bare reshape to (n_heads, n, d_head) has the right shape and the wrong contents
- Layer norm takes the mean and variance over the features of one token, with eps inside the square root, and a token's result does not depend on its neighbours
- A pre-norm block is
x + attention(norm(x))thenx + feed_forward(norm(x)), and a KV cache must give exactly the numbers of full recompute, adding the new key and value before the new token attends
#Quick Check
Why is the causal mask applied to the scores before the softmax, with minus infinity, rather than to the weights after it?