Ch. 15
Transformers from Scratch
BPE → attention → multi-head → causal mask → RoPE → the full nanoGPT, every line earned by hand.
The transformer is a function from a sequence of tokens to a sequence of A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → embeddings. Inside that function, every output position looks at every input position, decides how much each one matters, and mixes them. That mixing is called A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary →. The rest of the architecture is bookkeeping: a way to turn discrete tokens into vectors at the start, a way to turn vectors back into token probabilities at the end, and enough non-linear ML between attention layers to make the mixing useful. By the end of this chapter you will have built a small GPT from nn.Module upward, and you will have re-derived every line of it by hand in pure NumPy. Including the part where they tell you "it's just matrix multiplies" and you find out the part they don't say out loud, which is that there are seven different ways the dimensions can match and only one of them is right.
FIG 15.1 · Learning outcomes
By the end of this chapter you will be able to:
- Implement byte-pair encoding for a vocabulary of arbitrary size, in 60 lines of pure Python, and explain why GPT-3 uses ≈50k tokens and not 256.
- Write a causal self-attention head from scratch, in PyTorch, in under 30 lines, including the upper-triangular mask, and explain what each tensor's shape means at every step.
- Stack 12 transformer blocks into a nanoGPT-style model and train it on Tiny Shakespeare overnight on a single T4 to ≈1.5 validation loss.
- Sample from your trained model with greedy, temperature, top-k, and top-p decoding, and explain what each one breaks.
- Diff your from-scratch implementation against Karpathy's nanoGPT line by line and locate every place they differ.
- Explain what RoPE actually does to a vector, mathematically, and why it lets you extrapolate beyond the training context length (sometimes).
- Articulate three places a transformer's attention pattern can be exploited adversarially, and which mech-interp finding revealed each.
FIG 15.2 · What you need first
- Ch 11 — Training Deep Neural Networks — you need to know what gradient clipping, weight decay, AdamW, and learning-rate warmup are. The training loop in this chapter takes those for granted.
- Ch 14 — NLP with RNNs + Attention — the chapter where attention was introduced as a mechanism (Bahdanau-style). This one is the chapter where it becomes the whole architecture.
- Ch 0 — Math & Python prereqs — specifically the linear algebra part. And Ch 10 — PyTorch foundations, which is where
einsumlives:torch.einsum("bhqd,bhkd->bhqk", q, k)is just batched matmul with named axes (bbatch,hheads, the repeateddis the axis summed over), and another chapter walks through exactly this attention-score pattern. If it still reads as noise, that section is the fix, not this chapter.
If you skipped another chapter: you can probably survive. The attention math is re-derived here from the start. The pedagogical bridge that another chapter provides is the why — why anyone went looking for attention in the first place. RNNs are why.
FIG 15.3.1
Tokenization: bytes → integers → vectors
A transformer does not see text. It sees integers, which it then turns into vectors. The job of the tokenizer is to do the text-to-integers part, and the choice of tokenizer is the first place a serious decision gets made.
The naive options are bad. Character-level Chopping text into small pieces and giving each piece a number, because models can only work with numbers.Full glossary → (one integer per Unicode code point) means a 100-character sentence becomes 100 tokens and the model spends most of its compute mixing redundancy. Word-level tokenization gives a The fixed set of all chunks a model is allowed to read or produce.Full glossary → that explodes with every new domain and chokes on misspellings. Both have been used. Neither is what GPT-3 and Llama and Claude actually do.
The dominant choice since GPT-2 is A way of splitting text where common letter pairs and word-pieces get merged into reusable chunks.Full glossary → (BPE). Start with a vocabulary of single bytes (256 entries). Look at your training corpus, find the most frequent adjacent A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → pair, merge it into one new token. Repeat ten thousand times. You end up with ≈50k tokens, the common ones being whole words ( the, and), the rare ones being subwords ( chronological → chrono, logical) or single bytes for things the corpus never saw.
This is the part that surprises people: the BPE vocabulary includes the leading space. cat and cat are different tokens. That is why prompts beginning with cat versus cat produce different outputs in some models. The tokenizer is part of the model.
HuggingFace BPE trainer vs. from scratch
Classical MLtok = Tokenizer(BPE(unk_token="[UNK]"))
tok.pre_tokenizer = Whitespace()
tok.train(["data/tiny_shakespeare.txt"], BpeTrainer(vocab_size=10000))
ids = tok.encode("To be, or not to be").idsdef get_pair_counts(seq):
return Counter(zip(seq, seq[1:]))
def merge(seq, pair, new_token):
out, i = [], 0
while i < len(seq):
if i < len(seq)-1 and (seq[i], seq[i+1]) == pair:
out.append(new_token); i += 2
else:
out.append(seq[i]); i += 1
return out
def train_bpe(text, target_vocab):
seq = list(text.encode("utf-8")) # start from bytes 0-255
merges, next_id = {}, 256
while next_id < target_vocab:
pairs = get_pair_counts(seq)
if not pairs: break
best = pairs.most_common(1)[0][0] # most frequent adjacent pair
seq = merge(seq, best, next_id)
merges[best] = next_id; next_id += 1
return seq, mergesfrom scratch: draft.md §1 from-scratch BPE (pure-Python minbpe approach; not in solution.py)
- 1
BpeTrainer(vocab_size=10000) + tok.train(files)train_bpe(text, target_vocab) -- the while loop that grows the vocab - 2
the internal 'find most frequent pair' stepget_pair_counts(seq) then pairs.most_common(1)[0][0] - 3
the internal merge / new-token-id assignmentmerge(seq, best, next_id); merges[best] = next_id; next_id += 1 - 4
the starting vocabulary of single byteslist(text.encode('utf-8')) and next_id starting at 256 - 5
tok.encode(text).idsapplying the learned merges dict to a new byte sequence (the inverse of training)
What the one call hides
- Regex pre-tokenization (Whitespace() / the GPT-2 pattern) that pins merges to word boundaries and keeps leading spaces inside tokens; the scratch version merges straight across word edges.
- Special tokens ([UNK]/[CLS]/[SEP]) and an unknown-token fallback the from-scratch loop never handles.
- An efficient incremental pair-count update; the naive version recounts every pair from scratch each merge (O(n) per step, slow on real corpora).
- Serialization of merges + vocab so encode/decode are reproducible, plus a fast Rust encode path.
- Caching and deterministic tie-breaking when two pairs are equally frequent.
- Gotcha: The library counts spaces as token-internal (' the' != 'the'), so the same word tokenizes differently with a leading space; the scratch byte version makes this concrete but the library hides it.
- Gotcha: vocab_size includes special tokens and the base alphabet, so you get fewer learned merges than the number implies.
- Gotcha: Without a pre_tokenizer the BPE merges across whitespace and produces very different (often worse) tokens: easy to forget to set it.
Use the HuggingFace tokenizers library in any real project (fast, serializable, matches GPT-2's regex pre-tokenization); the ~30-line from-scratch version is a direct way to see that BPE is just 'repeatedly merge the most frequent adjacent pair starting from bytes'.
On the job: You train/serve a real tokenizer with the library and only hand-write small encode/inspection scripts to debug glitch tokens and leading-space surprises.
Run this on Tiny Shakespeare with target_vocab=1000 and you get about 2.5× compression. With target_vocab=10000 you get about 4×. The library version does some additional things, mainly handling the regex-based pre-tokenization that GPT-2 uses to keep token boundaries at word edges. Look up regex.findall(GPT2_PATTERN,...) if you want the production version.
FIG 15.3.2
Embeddings: integers → vectors
Once you have A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → IDs, you turn each one into a vector. This is what nn.Embedding(vocab_size, d_model) does: a lookup into a learned table of shape (vocab_size, d_model). For each integer id, you fetch row id.
The A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → table is a learned One of the model's internal numbers that gets adjusted as it learns.Full glossary →. At initialization it is small random gaussians. After training, similar tokens end up at similar positions in the d_model-dimensional space, in a way that is qualitatively interpretable (the famous king − man + woman ≈ queen experiment was on word2vec embeddings, but transformer embeddings learn the same kind of structure).
nn.Embedding vs. from scratch
DL primitivetok_emb = nn.Embedding(vocab_size, d_model)
# idx: (B, T) int64 -> tok_emb(idx): (B, T, d_model), one learned row per idclass Embedding:
def __init__(self, vocab_size, d_model):
# init scales variance with embedding dim
self.weight = np.random.randn(vocab_size, d_model) * (1.0 / np.sqrt(d_model))
def __call__(self, ids):
# ids: (batch, seq_len) integer array
return self.weight[ids] # fancy indexing -> (batch, seq_len, d_model)from scratch: draft.md §2 from-scratch Embedding (nn.Embedding used in lab/solution.py: GPT.tok_emb / pos_emb)
- 1
nn.Embedding(vocab_size, d_model)self.weight = randn(vocab_size, d_model) * 1/sqrt(d_model) -- the learned table - 2
embedding(idx)self.weight[ids] -- fetch row id per integer (fancy indexing) - 3
the registered nn.Parameter that gets gradientsself.weight (you would hand-scatter grads back into the indexed rows) - 4
output shape (B, T, d_model)the (batch, seq_len, d_model) result of indexing a (vocab, d_model) table
What the one call hides
- Registers weight as a learnable nn.Parameter so autograd scatters gradients back to exactly the rows you looked up (an index_add under the hood); the scratch version is forward-only.
- padding_idx (default None) can pin a row to all-zeros with no gradient, invisible unless you read the signature.
- Default init is N(0,1), NOT the 1/sqrt(d_model) scaling the scratch uses; the library's init is not the textbook scaling shown here.
- It is pure indexing, but the library hides that it is mathematically one-hot @ W, which trips people reasoning about its gradient.
- Gotcha: idx must be int64/long; float indices throw, and out-of-range ids (>= vocab_size) crash with an opaque CUDA-side assert.
- Gotcha: The embedding is a parameter, so forgetting weight tying means embedding and output head are two separate matrices that both must be learned.
- Gotcha: max_norm / scale_grad_by_freq exist and silently rescale or reweight gradients if set: easy to copy from a config without noticing.
Prefer nn.Embedding; the from-scratch version is to internalize that an embedding is just a learned lookup table (a row per token id) whose gradient touches only the rows you used.
On the job: You write the surrounding wiring by hand (tok_emb + pos_emb, the weight-tying assignment head.weight = tok_emb.weight) but never the lookup itself.
There is nothing more to embeddings than that. The interesting question is whether the unembedding (the final projection from d_model to vocab_size that produces logits) should share weights with the embedding. In GPT-2, it does. In some other models, it doesn't. Both work. Tying saves parameters.
FIG 15.3.3
Self-attention: the core computation
This is the part where you have to be careful with shapes. A calculation where each word gets a score for how related it is to every other word, then blends in the others according to those scores.Full glossary → is the function that, for each position in a sequence, computes a weighted sum of all positions in the same sequence, where the weights come from how similar each position's "query" is to every position's "key".
Three vectors per A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary →: (query), (key), (value). Each is a linear projection of the A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary →. The A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → score from position to position is:
The score gets passed through A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary → along the axis to make it a A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → distribution. Then the output at position is the weighted sum of the 's using those probabilities.
The whole thing in matrix form, for a A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → of one sequence:
Where are matrices of shape (seq_len, d_k). The result is (seq_len, d_k).
Three things are worth labeling explicitly because every implementation gets one of them wrong at first.
Note 1: The Rewriting your numbers so that very large measurements and very small ones are put on a comparable footing.Full glossary → matters. Without it, for large , the dot products become large in magnitude, the softmax saturates, and gradients vanish. Vaswani et al. derive this from the variance of a dot product of two unit gaussians; it shows up in every implementation because if you don't scale, your model trains slower and uses less of the head's capacity.
Note 2: The softmax is along the key axis (the position you are attending to). This means each query gets a probability distribution over keys. The other direction (softmax over queries) is a different operation and is not what attention does. In matrix code: F.softmax(scores, dim=-1) when scores is (..., seq_len_q, seq_len_k).
Note 3: , , all come from the same input in self-attention. They are different linear projections of the same token embeddings. In cross-attention (A two-part design where one half squeezes the input into a compact summary and the other half expands it into the output.Full glossary →), comes from one sequence and come from another. We only care about self-attention here.
F.scaled_dot_product_attention vs. from scratch
DL primitive# q, k, v: (B, n_heads, T, d_k) -- you still do the head reshape yourself
out = F.scaled_dot_product_attention(q, k, v, is_causal=True)
# out: (B, n_heads, T, d_k) = softmax(QK^T / sqrt(d_k) + causal_mask) @ V, fusedscores = q @ k.transpose(-2, -1) / math.sqrt(self.d_k) # QK^T / sqrt(d_k)
scores = scores.masked_fill(self.mask[:, :, :T, :T], float('-inf')) # causal
attn = F.softmax(scores, dim=-1) # over the key axis
out = attn @ v # weighted sum of values
# q, k, v are already (B, n_heads, T, d_k); self.mask is
# torch.triu(torch.ones(L, L), diagonal=1).bool() registered in __init__.from scratch: lab/solution.py: CausalSelfAttention.forward
- 1
F.scaled_dot_product_attention(q, k, v, ...)the four lines: scores, mask, softmax, attn @ v - 2
the implicit 1/sqrt(E) scale inside SDPA (E = q.size(-1) = d_k)/ math.sqrt(self.d_k) on the raw scores - 3
is_causal=Truescores.masked_fill(self.mask[:, :, :T, :T], -inf) using the triu(diagonal=1) mask - 4
the internal softmax over the last (key) axisF.softmax(scores, dim=-1) - 5
the final value-aggregation matmul inside SDPAout = attn @ v
What the one call hides
- The 1/sqrt(d_k) scale is applied for you (from the query's last dim), so you never see why it's there or that you can override it with scale=.
- Builds the causal mask internally from is_causal=True; you never materialize the (T, T) triangular mask or pick diagonal=1.
- Dispatches to a fused FlashAttention / memory-efficient kernel when eligible, so the big (T, T) score matrix is never written to DRAM: same math, far less memory, ~3x faster.
- The softmax-over-keys axis convention and the -inf masking are handled silently (no NaN rows in the pure-causal case because every query keeps at least its own position).
- Optional attention dropout (dropout_p) defaults to 0 and is fused in if you pass it.
- Gotcha: is_causal=True and an explicit attn_mask are mutually exclusive, and is_causal assumes q and k are aligned at the same positions: fine for self-attention, wrong for a cropped KV-cache decode step.
- Gotcha: It scales by 1/sqrt(q.size(-1)) = d_k per head, NOT d_model. Pass un-split (B, T, d_model) tensors and you get the wrong scale and wrong attention.
- Gotcha: Inputs must already be (B, n_heads, T, d_k); SDPA does NOT do the head reshape (the view + transpose) for you.
- Gotcha: Whether you get the fast Flash kernel depends on dtype, head dim, and contiguity; on CPU or odd shapes it silently falls back to the math path (same numbers, no speedup).
In production, prefer F.scaled_dot_product_attention (it is the fused/Flash path real models ship); the from-scratch four lines exist so you can see SDPA is exactly QK^T/sqrt(d_k), mask, softmax, @V.
On the job: You hand-write the head reshape and the block AROUND attention (the QKV split, LayerNorm-residual wrapper, the GPT loop) and the custom attention variants SDPA doesn't expose (ALiBi, sliding-window); the attention core stays SDPA.
Read those two implementations side by side. They are the same function. The library version uses nn.Linear (which is x @ W.T + b), the from-scratch version uses raw x @ W. The transpose on to compute scores: k.transpose(-2, -1) versus k.transpose(0, 2, 1). Same thing, different APIs. If you understand this side-by-side, you understand self-attention.
FIG 15.3.4
Multi-head attention: one head per perspective
A single A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → head can only learn one type of relationship at a time. Maybe it learns "the next-noun pattern". Maybe it learns "the matching-bracket pattern". If you want multiple patterns running in parallel, you give the model multiple heads.
Multi-head attention runs attention heads on the same input, in parallel, each with its own projections, each producing a separate output. The outputs are concatenated along the d_k axis (giving ), then projected back down to by a final linear layer .
By convention , so each head sees a -dimensional subspace. For GPT-2 small, and , so .
The clever implementation does not allocate separate nn.Linear modules. It allocates one big nn.Linear(d_model, 3 * d_model) to produce all of across all heads at once, then reshapes the result into and lets PyTorch's batched matmul handle the rest.
Library path (the production idiom):
class MultiHeadAttention(nn.Module):
def __init__(self, d_model: int, n_heads: int):
super().__init__()
assert d_model % n_heads == 0
self.d_model = d_model
self.n_heads = n_heads
self.d_k = d_model // n_heads
self.qkv_proj = nn.Linear(d_model, 3 * d_model, bias=False)
self.out_proj = nn.Linear(d_model, d_model, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor:
B, T, C = x.shape # batch, seq_len, d_model
qkv = self.qkv_proj(x) # (B, T, 3*C)
q, k, v = qkv.split(self.d_model, dim=-1) # each (B, T, C)
# Reshape to (B, n_heads, T, d_k)
q = q.view(B, T, self.n_heads, self.d_k).transpose(1, 2)
k = k.view(B, T, self.n_heads, self.d_k).transpose(1, 2)
v = v.view(B, T, self.n_heads, self.d_k).transpose(1, 2)
# Batched matmul over heads
scores = q @ k.transpose(-2, -1) / (self.d_k ** 0.5) # (B, n_heads, T, T)
attn = F.softmax(scores, dim=-1)
out = attn @ v # (B, n_heads, T, d_k)
out = out.transpose(1, 2).contiguous().view(B, T, C) # (B, T, C)
return self.out_proj(out)The from-scratch version is the same idea with explicit NumPy reshapes. (See the lab at the bottom of this chapter.)
FIG 15.3.5
Causal masking: keep the model honest
If you want an Generating text one piece at a time, where each new piece is chosen based on everything written so far.Full glossary → model (one that predicts the next A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → given previous ones), you cannot let position attend to position . Otherwise the model just looks at the answer and is useless at Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → time.
You enforce this with a mask. Before the A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary →, you set for all . After softmax, those positions become 0. The query at position can only mix values from positions through .
In PyTorch:
# T = seq_len
mask = torch.triu(torch.ones(T, T), diagonal=1).bool()
# True above the diagonal. Mask those out.
scores = scores.masked_fill(mask, float('-inf'))
attn = F.softmax(scores, dim=-1)In NumPy:
mask = np.triu(np.ones((T, T), dtype=bool), k=1)
scores = np.where(mask, -np.inf, scores)FIG 15.3.6
Positional information: the architecture is permutation-invariant by default
Without something extra, A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → is permutation-invariant: shuffling the tokens in the input shuffles the outputs the same way, but the function the model computes is the same. That is a disaster for sequence modeling because word order matters.
There are three families of fixes.
Absolute positional embeddings (GPT-2, BERT): add a learned vector to each A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary →'s A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → based on its position. pos_emb = nn.Embedding(max_seq_len, d_model), then x = token_emb + pos_emb. Simple. Limits the model to max_seq_len because positions beyond that have no learned embedding.
Sinusoidal Extra information added to each input that depends only on where it sits in the order.Full glossary → (the original transformer): add a sin/cos-based encoding instead of a learned one. Generalizes to longer sequences (in theory). In practice, models trained with sinusoidal positions also struggle to extrapolate, just less catastrophically.
Rotary positional embeddings (A way of telling a model where each token sits by twisting its number bundle a little more for each later position.Full glossary → — Llama, Mistral, GPT-NeoX, most modern LLMs): instead of adding a positional vector, rotate the query and key vectors in 2D subspaces by an angle proportional to their position. Crucially, the dot product depends only on the relative position . This lets the model handle relative positions naturally.
The RoPE math is the part people skip. Here it is.
For the -th pair of dimensions in a Q or K vector at position , the rotation is:
where varies across pairs of dimensions (), giving each pair a different rotation frequency. Pairs with low rotate fast, pairs with high rotate slowly. The slow-rotating pairs encode long-range positional information; the fast-rotating pairs encode short-range.
After rotation, the dot product ends up being a function of the dimension-pair contents and of and for each pair . Hence "relative".
If the rotation-matrix algebra is the first place in this chapter you stall, that is expected — it goes a notch past the linear algebra in another chapter. You can use RoPE correctly by treating the code below as the contract (rotate and by a position-dependent angle, leave alone) and come back to the derivation later; nothing downstream depends on you re-deriving it.
Library path (HuggingFace's RoPE impl, simplified):
def rotary_embed(x: torch.Tensor, pos: torch.Tensor, base: float = 10000) -> torch.Tensor:
"""x: (..., seq_len, d). pos: (seq_len,) integer positions."""
d = x.shape[-1]
half = d // 2
freqs = base ** (-torch.arange(0, half, device=x.device).float() / half)
angles = pos[:, None].float() * freqs[None, :] # (seq_len, half)
cos, sin = angles.cos(), angles.sin()
x_even, x_odd = x[..., 0::2], x[..., 1::2]
out_even = x_even * cos - x_odd * sin
out_odd = x_even * sin + x_odd * cos
out = torch.stack([out_even, out_odd], dim=-1).flatten(-2)
return outYou apply this to and before computing scores. Not to .
FIG 15.3.7
The feed-forward layer: two linear layers and a non-linearity
After every A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → layer there's a per-position feed-forward network (FFN) — the same MLP applied independently to each position's vector. Two linear layers with a non-linearity between them.
class FeedForward(nn.Module):
def __init__(self, d_model: int, d_ff: int = None):
super().__init__()
d_ff = d_ff or 4 * d_model # convention: FFN is 4x wider
self.fc1 = nn.Linear(d_model, d_ff)
self.fc2 = nn.Linear(d_ff, d_model)
def forward(self, x):
return self.fc2(F.gelu(self.fc1(x)))That's the whole thing. The 4× expansion is convention from the original transformer; modern models sometimes use different ratios (Llama uses 2.67× with SwiGLU instead of GELU). The choice of activation matters less than people think for The share of guesses the model got right out of all its guesses.Full glossary →, more than people think for Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → speed.
Why is the FFN here at all? Attention is a linear function of its values (the A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary → weights are non-linear, but each output is a linear combination of values). Without an FFN, the model is too limited. The FFN provides the non-linearity. There is a mech-interp literature on what FFNs actually do in trained models (they appear to encode key-value memories: each column of the first linear layer is a "key" direction that some input pattern activates, and the matching row of the second layer is the "value" it then writes back), but for now treat the FFN as the "compute" of the architecture and attention as the "communication".
FIG 15.3.8
Layer normalization: where you put it changes how you train
LayerNorm normalizes each A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary →'s vector to have zero mean and unit variance, then scales and shifts by learned per-dimension parameters . This is per token, not across the A small group of examples the model looks at together before making one adjustment to its numbers.Full glossary → (that's BatchNorm).
class LayerNorm(nn.Module):
def __init__(self, d_model: int, eps: float = 1e-5):
super().__init__()
self.gamma = nn.Parameter(torch.ones(d_model))
self.beta = nn.Parameter(torch.zeros(d_model))
self.eps = eps
def forward(self, x):
mean = x.mean(dim=-1, keepdim=True)
var = x.var(dim=-1, keepdim=True, unbiased=False)
return self.gamma * (x - mean) / torch.sqrt(var + self.eps) + self.betaThe interesting question is where in the transformer block you put the LayerNorm.
The original "A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → Is All You Need" paper put it after each sub-layer (attention, FFN): x = LayerNorm(x + Attention(x)). This is "post-norm". It is hard to train. The gradients explode at deep stacks unless you use careful learning-rate Starting training with tiny steps that grow for a little while before the main plan kicks in.Full glossary →.
GPT-2 and every modern model put LayerNorm before each sub-layer: x = x + Attention(LayerNorm(x)). This is "pre-norm". It trains easily without warmup. It is the dominant choice.
Llama and later models use RMSNorm instead of LayerNorm — same idea but without subtracting the mean (only normalizing the variance). Faster, no meaningful The share of guesses the model got right out of all its guesses.Full glossary → difference.
FIG 15.3.9
Residual connections: the residual stream view
Every sub-layer is wrapped in a residual connection: x = x + Sublayer(x). This started as an "easier to train deep networks" trick (He et al. 2016, ResNet). Anthropic's mech-interp work reframed it as something much more useful conceptually.
Look at the architecture: each A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary →'s vector flows from the A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → through 12 (or 24, or 96) blocks, each of which adds to the vector. A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → reads from the The main running tally of information that flows through a deep model, with each layer reading from it and adding its bit back in.Full glossary → and writes back. FFN reads and writes. The residual stream is the central "bus" through which all information flows; every component reads-then-writes-to it.
This is the "residual stream" view from 22-anthropic-recent/2021-framework-index §residual-stream. It is the most useful way to think about why mech-interp findings like "the IOI circuit" work: specific attention heads read specific features from the residual stream and write specific corrections back, and you can decompose the model's behavior into those reads and writes.
When you're writing the from-scratch code, the residual connection is just +=:
class Block(nn.Module):
def __init__(self, d_model, n_heads):
super().__init__()
self.ln1 = LayerNorm(d_model)
self.attn = MultiHeadAttention(d_model, n_heads)
self.ln2 = LayerNorm(d_model)
self.ffn = FeedForward(d_model)
def forward(self, x):
x = x + self.attn(self.ln1(x)) # read from stream, write back
x = x + self.ffn(self.ln2(x)) # read from stream, write back
return xFIG 15.3.10
The full architecture: stack blocks, project to logits
A GPT-style transformer is:
- A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → + Extra information added to each input that depends only on where it sits in the order.Full glossary → → input vectors
- identical transformer blocks (A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → + FFN with residuals + pre-norm)
- Final LayerNorm
- Linear projection to vocab_size → logits
That's it. ~50 lines of PyTorch.
class GPT(nn.Module):
def __init__(self, vocab_size, d_model=768, n_heads=12, n_layers=12, max_seq_len=1024):
super().__init__()
self.tok_emb = nn.Embedding(vocab_size, d_model)
self.pos_emb = nn.Embedding(max_seq_len, d_model)
self.blocks = nn.ModuleList([Block(d_model, n_heads) for _ in range(n_layers)])
self.ln_f = LayerNorm(d_model)
self.head = nn.Linear(d_model, vocab_size, bias=False)
# Weight tying: share embedding and output head
self.head.weight = self.tok_emb.weight
def forward(self, idx):
B, T = idx.shape
pos = torch.arange(T, device=idx.device)
x = self.tok_emb(idx) + self.pos_emb(pos)
for block in self.blocks:
x = block(x)
x = self.ln_f(x)
return self.head(x) # (B, T, vocab_size) — logitsThat is GPT-2. Llama-style models swap LayerNorm for RMSNorm, sinusoidal/learned position for A way of telling a model where each token sits by twisting its number bundle a little more for each later position.Full glossary →, and the FFN's GELU for SwiGLU. The skeleton is identical.
FIG 15.3.11
Training: cross-entropy on shifted targets
To train a language model, you give it a sequence and ask it to predict the next A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → at every position. Inputs: tokens 0 through T-1. Targets: tokens 1 through T. Loss: A loss that measures how far a model's predicted chances are from the true answer.Full glossary → between the logits at each position and the target token at that position.
def train_step(model, optimizer, idx, targets):
logits = model(idx) # (B, T, vocab_size)
loss = F.cross_entropy(
logits.view(-1, logits.size(-1)),
targets.view(-1)
)
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
return loss.item()Three details that matter at small scale:
- AdamW is the optimizer everyone uses. Don't use Adam, use AdamW. The decoupled Gently nudging a model's weights toward smaller values to keep the model simpler and less likely to overfit.Full glossary → matters at LLM scale.
- Putting a cap on how big a single training adjustment can be so one wild step doesn't wreck progress.Full glossary → at norm 1.0 prevents the occasional huge A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary → from destabilizing training.
- Learning-rate Starting training with tiny steps that grow for a little while before the main plan kicks in.Full glossary → over the first ~2000 steps from 0 to peak LR, then cosine decay to ~10% of peak. nanoGPT's defaults are good.
Train on Tiny Shakespeare for ~5000 steps with a GPT-2 small (12 layers, 12 heads, d_model 768) and you reach validation loss ≈1.5, generating shakespeare-flavoured nonsense. Train for ~50000 steps and you reach ≈1.1, generating actually-passable Shakespeare imitation.
FIG 15.3.12
Sampling: turning logits into tokens
At Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →, you have a sequence of tokens, you run the model, you get logits at the last position, you turn those into a A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary →, you append, you repeat.
The boring version is greedy: take argmax(logits). Produces deterministic, often repetitive, often boring output.
The standard version is A dial that controls how much a model gambles on unlikely words versus sticking to the most likely one.Full glossary → + top-k + top-p:
@torch.no_grad()
def generate(model, idx, max_new_tokens, temperature=1.0, top_k=None, top_p=None):
for _ in range(max_new_tokens):
# Crop to max_seq_len
idx_cond = idx[:, -model.max_seq_len:]
logits = model(idx_cond)[:, -1, :] # (B, vocab_size) — last position only
logits = logits / temperature
# top-k: keep only the k highest-logit tokens
if top_k is not None:
v, _ = torch.topk(logits, top_k)
logits[logits < v[:, [-1]]] = float('-inf')
# top-p (nucleus): keep tokens whose cumulative prob ≥ p
if top_p is not None:
sorted_logits, sorted_idx = torch.sort(logits, descending=True)
cumprob = F.softmax(sorted_logits, dim=-1).cumsum(dim=-1)
sorted_idx_remove = cumprob > top_p
sorted_idx_remove[..., 1:] = sorted_idx_remove[..., :-1].clone()
sorted_idx_remove[..., 0] = False
indices_to_remove = sorted_idx_remove.scatter(1, sorted_idx, sorted_idx_remove)
logits[indices_to_remove] = float('-inf')
probs = F.softmax(logits, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
idx = torch.cat([idx, next_token], dim=1)
return idxDefaults that work well for general text: temperature=0.8, top_p=0.9. For deterministic completions: temperature=0.0 (which becomes greedy).
FIG 15.3.13
Putting it together: nanoGPT walkthrough
Karpathy's nanoGPT (12-karpathy-code/nanoGPT-master-model + nanoGPT-master-train) is the cleanest from-scratch GPT implementation that actually trains to competitive A score for a language model showing how surprised it is by the test text, lower means less surprised.Full glossary → on real corpora. ~300 lines for the model, ~300 for the training loop. Worth reading top to bottom.
What he does differently from this chapter:
- Combined QKV projection (
nn.Linear(d_model, 3*d_model)) instead of three separate ones — fewer kernel launches, same math - Uses
F.scaled_dot_product_attention, PyTorch's A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → API that dispatches to a FlashAttention kernel when the inputs are eligible — same math, ~3× faster, much less memory. The speedup comes from fusing the whole attention computation into one GPU kernel so the big(T, T)score matrix is computed in fast on-chip memory and never written out to slow GPU DRAM; the naive version pays for a full write-then-read of that matrix between the matmul and the A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary → (covered in another chapter — Efficient Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary →) - Implements the GELU non-linearity as PyTorch's
nn.GELU(approximate='tanh')to match GPT-2's exact form - A number inside the model that gets multiplied by an input, deciding how much that input pushes on the final guess.Full glossary →-ties the A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → and unembedding layers
- Uses
nn.LayerNormwith the A single number that gets added to every guess, the same amount no matter what the inputs are.Full glossary → term following GPT-2
Diff the Block class in nanoGPT against the Block class in the lab at the end of this chapter. They are the same module modulo those four optimizations. Internalizing this diff is the goal of the chapter.
FIG 15.4 · Safety lens · this chapter
What can go wrong with the technique you just built? The A mechanism that lets a model look back over all the input and focus on the parts that matter right now.Full glossary → mechanism is the most-attacked layer of every deployed LLM, for three distinct reasons.
Tokenizer attacks. The BPE The fixed set of all chunks a model is allowed to read or produce.Full glossary → you trained in section 1 is part of the model. It has surprising properties. There are tokens in the GPT-3 vocabulary that the model has barely ever seen (SolidGoldMagikarp is the famous example) because the tokenizer was trained on a corpus where that string appeared in Reddit usernames, but the language model was trained on a different corpus where the username string was extremely rare. The model's behavior on these "glitch tokens" is undefined and exploitable. More practically: tokenizer differences between training-time and safety-eval-time create a measurable gap. A safety A small grid of weights that slides across an image to spot a particular pattern.Full glossary → trained on one tokenizer can miss adversarial inputs that re-tokenize differently. See 26-pentest-redteam/genai-owasp-org-llm-top-10 §prompt-injection and 26-pentest-redteam/github-com-leondz-garak §probes.
Adversarial suffix attacks. The GCG paper (Zou et al. 2023) — Greedy Coordinate A signal showing which direction to change each of the model's numbers to make its mistakes BIGGER, so you go the opposite way to make them smaller.Full glossary →, a gradient-guided search that greedily swaps one suffix A single chunk of a sequence the model reads or produces, often a word or a character.Full glossary → at a time for whichever substitution most lowers the loss — showed that you can find sequences of tokens that, when appended to a malicious prompt, An input crafted to trick a model into doing something it was trained to refuse.Full glossary → essentially every aligned LLM. (A jailbreak is an input crafted to make an aligned model produce content it was trained to refuse.) The attack works because attention is differentiable: gradient-based search on the input A list of numbers that stands in for a word (or an image, or any thing), arranged so that similar things get similar lists.Full glossary → space finds suffixes that maximize the A number between 0 and 1 saying how confident the model is, where 1 means totally sure and 0.5 means a coin-flip.Full glossary → of an affirmative response (the model opening with "Sure, here is how to..." instead of refusing). This is a property of attention plus large vocabularies plus the The main running tally of information that flows through a deep model, with each layer reading from it and adding its bit back in.Full glossary → architecture you just learned. There is no fix purely at the Running a finished, trained model on new data to get answers, as opposed to training it.Full glossary → layer. Mitigations live at training (Anthropic's constitutional AI, Meta's purple-team adversarial training) and at deployment (A score for a language model showing how surprised it is by the test text, lower means less surprised.Full glossary →-based filters, suffix detection). See 26-pentest-redteam/www-anthropic-com-news-many-shot-jailbreaking (Anthropic) and 25-alignment-canon for the safety-paper context.
Attention head circuits and refusal direction. Mech-interp research has identified that aligned models encode their "refuse to answer" behavior along specific directions in the residual stream that you can locate with linear probes — a probe here is just a single direction vector (often the difference between the mean activation on refused prompts and the mean on answered ones) that classifies by the sign of a dot product (14-arena-notebooks/chapter1-part31-linear-probes). Once located, you can ablate the direction at inference time — zero out every vector's component along it — and the model stops refusing. This is the "refusal vector" technique. It has been demonstrated on Llama-2 and Llama-3. It works because the residual-stream view of section 9 is correct: the refusal One piece of information about an example that the model looks at when making a guess.Full glossary → is a single direction added by specific layers, and zeroing the projection along that direction is one matrix operation. See 22-anthropic-recent/2024-scaling-monosemanticity §refusal-features and 14-arena-notebooks/chapter4-part4-persona-vectors.
What habits to adopt from now on, when you write transformer code:
- Test your tokenizer separately from your model. A 5-minute Jupyter cell that encodes a few adversarial strings and confirms the IDs match what you expect catches an entire class of bugs.
- Verify your A block that stops a model from peeking at later positions, so it only sees what came before.Full glossary → by printing the post-A function that turns a list of raw scores into confidences for several options that add up to exactly 1 (100%).Full glossary → attention matrix on a 4-token sequence. Off-by-one errors in masks are the most common silent bug in transformer implementations.
- When you train a model, log per-head attention entropy — heads with persistently low entropy are doing something specific (induction — a head that finds the previous place the current token appeared and copies what followed it, so
…[A][B]…[A]predicts[B]; copying; refusing); heads with persistently high entropy are doing nothing. Both are useful diagnostics that almost no one collects.
FIG 15.5 · Under the hood
The library call, and the lines it hides
You don't have to choose between “use the library” and “build it from scratch.” Here is the one library call, the exact lines it stands in for, and when to reach for which on the job.
nn.LayerNorm vs. from scratch
DL primitiveln = nn.LayerNorm(d_model) # eps=1e-5, elementwise_affine=True
# x: (..., d_model) -> normalize per token over the last dim, then * gamma + betaself.gamma = nn.Parameter(torch.ones(d_model))
self.beta = nn.Parameter(torch.zeros(d_model))
self.eps = eps
def forward(self, x):
mean = x.mean(dim=-1, keepdim=True)
var = x.var(dim=-1, keepdim=True, unbiased=False) # population variance
return self.gamma * (x - mean) / torch.sqrt(var + self.eps) + self.betafrom scratch: draft.md §8 from-scratch LayerNorm (nn.LayerNorm used in lab/solution.py: Block.ln1/ln2, GPT.ln_f)
- 1
nn.LayerNorm(d_model)the whole class: gamma/beta params plus normalize-then-affine forward - 2
internal mean/var over normalized_shape (last dim)x.mean(dim=-1, keepdim=True) and x.var(dim=-1, keepdim=True, unbiased=False) - 3
the learned weight (gamma) and bias (beta)self.gamma = ones(d_model), self.beta = zeros(d_model) - 4
eps=1e-5 added inside the sqrttorch.sqrt(var + self.eps) - 5
the affine outputgamma * (x - mean) / sqrt(var + eps) + beta
What the one call hides
- Normalizes over the LAST dim(s) per token (not across the batch like BatchNorm); the per-token axis is baked in via normalized_shape.
- Uses biased (population) variance, matching unbiased=False; a beginner reaching for torch.var defaults gets the unbiased estimator and a real mismatch (~0.1 here).
- Initializes gamma=1, beta=0 so it starts as identity, and registers both as learned parameters.
- eps is inside the sqrt (var + eps), not outside: a numerical-stability detail people get wrong when reimplementing.
- elementwise_affine=True is the default; set False and gamma/beta disappear entirely.
- Gotcha: It is LayerNorm, not RMSNorm: it subtracts the mean. Modern Llama-style models drop the mean (RMSNorm); copying nn.LayerNorm when you meant RMSNorm changes the model.
- Gotcha: normalized_shape must match the trailing dims of x; pass an int and it normalizes only the last dim, wrong if your feature axis is not last.
- Gotcha: Default eps=1e-5 can be off for fp16; mixed-precision training sometimes needs it tuned.
Prefer nn.LayerNorm (the solution itself does); building it once shows it is per-token standardization plus a learned scale/shift, the prerequisite for pre-norm vs post-norm placement and for swapping in RMSNorm.
On the job: You choose WHERE the norm goes (pre-norm residual: x = x + attn(ln(x))) and occasionally hand-write RMSNorm, but not the normalize-then-affine core.
F.softmax vs. from scratch (stable)
DL primitiveattn = F.softmax(scores, dim=-1) # over the key axis; max-subtraction is internaldef softmax(x, axis=-1):
x_max = np.max(x, axis=axis, keepdims=True) # subtract max for stability
exp = np.exp(x - x_max)
return exp / np.sum(exp, axis=axis, keepdims=True)from scratch: draft.md §3 from-scratch softmax (F.softmax used in lab/solution.py: CausalSelfAttention.forward line 30)
- 1
F.softmax(scores, dim=-1)the whole softmax function called with axis=-1 - 2
the internal max-subtraction for stabilityx_max = np.max(...); np.exp(x - x_max) - 3
dim=-1 (the key axis)axis=-1 in the keepdims np.max / np.sum reductions - 4
normalization to a probability distributionexp / np.sum(exp, axis=axis, keepdims=True)
What the one call hides
- The max-subtraction trick (exp(x - max)) that prevents overflow: the single most-skipped line in naive softmax, and the difference between correct probabilities and NaN on large logits.
- Which axis it reduces over: dim=-1 means over keys; the wrong axis still sums to 1 along the wrong direction and silently breaks attention.
- keepdims-style broadcasting so the divide lines up, handled internally.
- Rows that are entirely -inf (fully masked) still produce NaN; F.softmax does not special-case that for you.
- Gotcha: Always pass dim explicitly; relying on a default flattens or warns depending on version.
- Gotcha: Feeding logits already divided by temperature vs not changes the sharpness; softmax does not know your temperature convention.
- Gotcha: An all--inf row (over-aggressive masking) yields NaN that propagates through the whole batch.
Prefer F.softmax; the from-scratch version exists to make the exp(x - max) stability trick and the key-axis reduction visible, since those are exactly the two things that silently break hand-written attention.
On the job: You never re-implement softmax at work, but you DO choose the reduction axis and the temperature/masking applied to its inputs.
FIG 15.6 · Chapter notebook
Build this chapter with your own hands
A single self-contained notebook. You implement the ideas, check yourself against assert cells as you go, then finish with a capstone. Hint ladders and folded solutions throughout, so it runs top-to-bottom even before you fill anything in.
What you'll build
- A causal self-attention head from raw tensors, with the upper-triangular mask built before the softmax, and a property test that future tokens cannot leak into the past.
- Multi-head attention, a feed-forward block, pre-norm residual blocks, and then the whole GPT assembled in one cell, weight-tied, with a param-count and a shape smoke test after every module.
- A tiny char-level GPT trained on Tiny Shakespeare until it generates Shakespeare-flavoured text, plus a deliberate failure (the default-init logit blow-up) you watch break and then fix.
- A sampling suite (greedy, temperature, top-k, top-p) checked with statistical sanity tests, not vibes.
~18 min on CPU · 140 cells · 23 checked exercises · runs in Colab
FIG 15.7 · Going further
12-karpathy-code/nanoGPT-master-trainthe production training loop with mixed precision, distributed data parallelism, and a learning-rate schedule that works. Read after you've trained your own version.
01-explorables/annotated-transformerHarvard NLP's paper-as-code walkthrough. Same architecture, more elaborate code, no shortcuts. Worth reading once you've built your own.
04-stanford/cs336-lecture_06(kernels/Triton) — when you want to make attention fast, this is where you start. Triton lets you write GPU kernels in Python-like syntax. FlashAttention is a Triton kernel.22-anthropic-recent/2021-framework-indexAnthropic's rigorous decomposition of what a transformer block is computing. Reframes your understanding of attention as a circuit operating on the residual stream.
18-lilian-weng/2023-01-27-the-transformer-family-v2every transformer variant and what each one tries to fix. Encyclopedic.
26-pentest-redteam/www-anthropic-com-news-many-shot-jailbreakingthe canonical demonstration that scaling context length opens new attack surface. Built directly on the attention mechanism in this chapter.
14-arena-notebooks/chapter1-part2-intro-to-mech-interpthe next chapter after this one if you want to go interp.
FIG 15.8 · What this enables
Chapters you can now read, with the connecting idea written out.
Now you can read what CLIP and Flamingo do, because the trick is projecting image patches into the same token space your text transformer already understands, and re-using everything in this chapter.
KV-cache, speculative decoding, FlashAttention, RoPE extrapolation — all build on the architecture in this chapter.
RLHF fine-tunes the exact model you just built. Knowing the architecture is a precondition.
Agents call LLMs in a loop. The LLM in that loop is the model from this chapter; function-calling is a thin layer on top of token sampling from §12.
Retrieval-augmented generation conditions the same transformer on retrieved context. You need to know how attention reads the context.
The whole field assumes you know what attention heads are, what the residual stream is, and how to compute a forward pass by hand. Now you do.
Every modern eval runs against models built like the one in this chapter. The eval is sampling plus a judge model that is also built like this.
Most jailbreaks exploit specific properties of this architecture (tokenizer attacks, attention head circuits, refusal direction in the residual stream). another chapter's safety arguments rest on this chapter's architectural facts.
Once you've built a transformer, every transformer paper you read becomes a diff against your own implementation. That's the deepest reading discipline.
FIG 15.9 · 40 sources
- 01-explorables/annotated-transformer
- 01-explorables/jalammar-illustrated-transformer
- 01-explorables/jalammar-illustrated-gpt2
- 01-explorables/distill-feature-visualization
- 03-curricula/karpathy-minbpe
- 04-stanford/cs336-lecture_01
- 04-stanford/cs336-lecture_02
- 04-stanford/cs336-lecture_06
- 04-stanford/cs336-lecture_10
- 05-safety/alignmentforum-agi-safety-first-principles
- 05-safety/nanda-mech-interp-glossary
- 11-polo-club/transformer-explainer
- 12-karpathy-code/nanoGPT-master-model
- 12-karpathy-code/nanoGPT-master-train
- 12-karpathy-code/makemore_part1_bigrams
- 12-karpathy-code/makemore_part2_mlp
- 12-karpathy-code/makemore_part3_bn
- 12-karpathy-code/makemore_part4_backprop
- 12-karpathy-code/makemore_part5_cnn1
- 13-fastbook/12_nlp_dive
- 14-arena-notebooks/chapter1-part1-transformer-from-scratch
- 14-arena-notebooks/chapter1-part2-intro-to-mech-interp
- 14-arena-notebooks/chapter1-part31-linear-probes
- 14-arena-notebooks/chapter4-part4-persona-vectors
- 16-d2l-sections/chapter_attention-mechanisms-and-transformers__attention-pooling
- 16-d2l-sections/chapter_attention-mechanisms-and-transformers__attention-scoring-functions
- 16-d2l-sections/chapter_attention-mechanisms-and-transformers__multihead-attention
- 16-d2l-sections/chapter_attention-mechanisms-and-transformers__transformer
- 18-lilian-weng/2018-06-24-attention
- 18-lilian-weng/2020-04-07-the-transformer-family
- 18-lilian-weng/2021-09-25-train-large
- 18-lilian-weng/2023-01-10-inference-optimization
- 18-lilian-weng/2023-01-27-the-transformer-family-v2
- 22-anthropic-recent/2021-framework-index
- 22-anthropic-recent/2024-scaling-monosemanticity
- 24-founder-blogs/karpathy-recipe
- 26-pentest-redteam/genai-owasp-org-llm-top-10
- 26-pentest-redteam/www-anthropic-com-news-many-shot-jailbreaking
- 26-pentest-redteam/github-com-leondz-garak
- 29-practice-engineering/lucidrains-xtransformers