Ch. 15

Transformers from Scratch

BPE → attention → multi-head → causal mask → RoPE → the full nanoGPT, every line earned by hand.

transformerattentionnanoGPTRoPE

FIG 15 · Explainer video


The transformer is a function from a sequence of tokens to a sequence of 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 . 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 Networksyou 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 + Attentionthe 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 prereqsspecifically the linear algebra part. And Ch 10 — PyTorch foundations, which is where einsum lives: torch.einsum("bhqd,bhkd->bhqk", q, k) is just batched matmul with named axes (b batch, h heads, the repeated d is 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 (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 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 (BPE). Start with a vocabulary of single bytes (256 entries). Look at your training corpus, find the most frequent adjacent 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 ML
LIBRARY
tok = 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").ids
FROM SCRATCH
def 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, merges

from scratch: draft.md §1 from-scratch BPE (pure-Python minbpe approach; not in solution.py)

  1. 1BpeTrainer(vocab_size=10000) + tok.train(files) train_bpe(text, target_vocab) -- the while loop that grows the vocab
  2. 2the internal 'find most frequent pair' step get_pair_counts(seq) then pairs.most_common(1)[0][0]
  3. 3the internal merge / new-token-id assignment merge(seq, best, next_id); merges[best] = next_id; next_id += 1
  4. 4the starting vocabulary of single bytes list(text.encode('utf-8')) and next_id starting at 256
  5. 5tok.encode(text).ids applying 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 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 table is a learned . 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 primitive
LIBRARY
tok_emb = nn.Embedding(vocab_size, d_model)
# idx: (B, T) int64  ->  tok_emb(idx): (B, T, d_model), one learned row per id
FROM SCRATCH
class 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. 1nn.Embedding(vocab_size, d_model) self.weight = randn(vocab_size, d_model) * 1/sqrt(d_model) -- the learned table
  2. 2embedding(idx) self.weight[ids] -- fetch row id per integer (fancy indexing)
  3. 3the registered nn.Parameter that gets gradients self.weight (you would hand-scatter grads back into the indexed rows)
  4. 4output 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. 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 : QQ (query), KK (key), VV (value). Each is a linear projection of the . The score from position ii to position jj is:

scoreij=QiKjdk\text{score}_{ij} = \frac{Q_i \cdot K_j}{\sqrt{d_k}}

The score gets passed through along the jj axis to make it a distribution. Then the output at position ii is the weighted sum of the VjV_j's using those probabilities.

The whole thing in matrix form, for a of one sequence:

Attention(Q,K,V)=softmax ⁣(QKTdk)V\text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{Q K^T}{\sqrt{d_k}}\right) V

Where Q,K,VQ, K, V 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 dk\sqrt{d_k} matters. Without it, for large dkd_k, 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: QQ, KK, VV all come from the same input in self-attention. They are different linear projections of the same token embeddings. In cross-attention (), QQ comes from one sequence and K,VK, V come from another. We only care about self-attention here.

F.scaled_dot_product_attention vs. from scratch

DL primitive
LIBRARY
# 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, fused
FROM SCRATCH
scores = 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. 1F.scaled_dot_product_attention(q, k, v, ...) the four lines: scores, mask, softmax, attn @ v
  2. 2the implicit 1/sqrt(E) scale inside SDPA (E = q.size(-1) = d_k) / math.sqrt(self.d_k) on the raw scores
  3. 3is_causal=True scores.masked_fill(self.mask[:, :, :T, :T], -inf) using the triu(diagonal=1) mask
  4. 4the internal softmax over the last (key) axis F.softmax(scores, dim=-1)
  5. 5the final value-aggregation matmul inside SDPA out = 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 KK 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 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 hh attention heads on the same input, in parallel, each with its own Wq,Wk,WvW_q, W_k, W_v projections, each producing a separate (seq_len,dk)(seq\_len, d_k) output. The outputs are concatenated along the d_k axis (giving (seq_len,hdk)(seq\_len, h \cdot d_k)), then projected back down to dmodeld_{model} by a final linear layer WoW_o.

By convention hdk=dmodelh \cdot d_k = d_{model}, so each head sees a dk=dmodel/hd_k = d_{model}/h-dimensional subspace. For GPT-2 small, dmodel=768d_{model}=768 and h=12h=12, so dk=64d_k = 64.

The clever implementation does not allocate hh separate nn.Linear modules. It allocates one big nn.Linear(d_model, 3 * d_model) to produce all of Q,K,VQ, K, V across all heads at once, then reshapes the result into (batch,h,seq_len,dk)(batch, h, seq\_len, d_k) and lets PyTorch's batched matmul handle the rest.

Library path (the production idiom):

Python
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 model (one that predicts the next given previous ones), you cannot let position ii attend to position j>ij > i. Otherwise the model just looks at the answer and is useless at time.

You enforce this with a mask. Before the , you set scoreij=\text{score}_{ij} = -\infty for all j>ij > i. After softmax, those positions become 0. The query at position ii can only mix values from positions 00 through ii.

In PyTorch:

Python
# 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:

Python
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, 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 's 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 (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 ( — 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 QiKjQ_i \cdot K_j depends only on the relative position iji - j. This lets the model handle relative positions naturally.

The RoPE math is the part people skip. Here it is.

For the kk-th pair of dimensions (2k,2k+1)(2k, 2k+1) in a Q or K vector at position pp, the rotation is:

(q2kq2k+1)=(cos(pθk)sin(pθk)sin(pθk)cos(pθk))(q2kq2k+1)\begin{pmatrix} q'_{2k} \\ q'_{2k+1} \end{pmatrix} = \begin{pmatrix} \cos(p\theta_k) & -\sin(p\theta_k) \\ \sin(p\theta_k) & \cos(p\theta_k) \end{pmatrix} \begin{pmatrix} q_{2k} \\ q_{2k+1} \end{pmatrix}

where θk=100002k/dmodel\theta_k = 10000^{-2k/d_{model}} varies across pairs of dimensions (k=0,1,,dmodel/21k = 0, 1, \dots, d_{model}/2 - 1), giving each pair a different rotation frequency. Pairs with low kk rotate fast, pairs with high kk rotate slowly. The slow-rotating pairs encode long-range positional information; the fast-rotating pairs encode short-range.

After rotation, the dot product QiKjQ_i \cdot K_j ends up being a function of the dimension-pair contents and of cos((ij)θk)\cos((i-j)\theta_k) and sin((ij)θk)\sin((i-j)\theta_k) for each pair kk. 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 QQ and KK by a position-dependent angle, leave VV alone) and come back to the derivation later; nothing downstream depends on you re-deriving it.

Library path (HuggingFace's RoPE impl, simplified):

Python
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 out

You apply this to QQ and KK before computing scores. Not to VV.

FIG 15.3.7

The feed-forward layer: two linear layers and a non-linearity

After every 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.

Python
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 , more than people think for speed.

Why is the FFN here at all? Attention is a linear function of its values (the 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 's vector to have zero mean and unit variance, then scales and shifts by learned per-dimension parameters γ,β\gamma, \beta. This is per token, not across the (that's BatchNorm).

Python
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.beta

The interesting question is where in the transformer block you put the LayerNorm.

The original " 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 .

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 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 's vector flows from the through 12 (or 24, or 96) blocks, each of which adds to the vector. reads from the 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 +=:

Python
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 x

FIG 15.3.10

The full architecture: stack blocks, project to logits

A GPT-style transformer is:

  1. + → input vectors
  2. NN identical transformer blocks ( + FFN with residuals + pre-norm)
  3. Final LayerNorm
  4. Linear projection to vocab_size → logits

That's it. ~50 lines of PyTorch.

Python
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) — logits

That is GPT-2. Llama-style models swap LayerNorm for RMSNorm, sinusoidal/learned position for , 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 at every position. Inputs: tokens 0 through T-1. Targets: tokens 1 through T. Loss: between the logits at each position and the target token at that position.

Python
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 matters at LLM scale.
  • at norm 1.0 prevents the occasional huge from destabilizing training.
  • Learning-rate 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 , you have a sequence of tokens, you run the model, you get logits at the last position, you turn those into a , you append, you repeat.

The boring version is greedy: take argmax(logits). Produces deterministic, often repetitive, often boring output.

The standard version is + top-k + top-p:

Python
@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 idx

Defaults 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 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 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 (covered in another chapter — Efficient )
  • Implements the GELU non-linearity as PyTorch's nn.GELU(approximate='tanh') to match GPT-2's exact form
  • -ties the and unembedding layers
  • Uses nn.LayerNorm with the 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 mechanism is the most-attacked layer of every deployed LLM, for three distinct reasons.

Tokenizer attacks. The BPE 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 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 gradient-guided search that greedily swaps one suffix 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, 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 space finds suffixes that maximize the 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 architecture you just learned. There is no fix purely at the layer. Mitigations live at training (Anthropic's constitutional AI, Meta's purple-team adversarial training) and at deployment (-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 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 by printing the post- 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 primitive
LIBRARY
ln = nn.LayerNorm(d_model)   # eps=1e-5, elementwise_affine=True
# x: (..., d_model) -> normalize per token over the last dim, then * gamma + beta
FROM SCRATCH
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)   # population variance
    return self.gamma * (x - mean) / torch.sqrt(var + self.eps) + self.beta

from scratch: draft.md §8 from-scratch LayerNorm (nn.LayerNorm used in lab/solution.py: Block.ln1/ln2, GPT.ln_f)

  1. 1nn.LayerNorm(d_model) the whole class: gamma/beta params plus normalize-then-affine forward
  2. 2internal mean/var over normalized_shape (last dim) x.mean(dim=-1, keepdim=True) and x.var(dim=-1, keepdim=True, unbiased=False)
  3. 3the learned weight (gamma) and bias (beta) self.gamma = ones(d_model), self.beta = zeros(d_model)
  4. 4eps=1e-5 added inside the sqrt torch.sqrt(var + self.eps)
  5. 5the affine output gamma * (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 primitive
LIBRARY
attn = F.softmax(scores, dim=-1)   # over the key axis; max-subtraction is internal
FROM SCRATCH
def 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. 1F.softmax(scores, dim=-1) the whole softmax function called with axis=-1
  2. 2the internal max-subtraction for stability x_max = np.max(...); np.exp(x - x_max)
  3. 3dim=-1 (the key axis) axis=-1 in the keepdims np.max / np.sum reductions
  4. 4normalization to a probability distribution exp / 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-train

    the 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-transformer

    Harvard 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-index

    Anthropic'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-v2

    every transformer variant and what each one tries to fix. Encyclopedic.

  • 26-pentest-redteam/www-anthropic-com-news-many-shot-jailbreaking

    the 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-interp

    the 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
  1. 01-explorables/annotated-transformer
  2. 01-explorables/jalammar-illustrated-transformer
  3. 01-explorables/jalammar-illustrated-gpt2
  4. 01-explorables/distill-feature-visualization
  5. 03-curricula/karpathy-minbpe
  6. 04-stanford/cs336-lecture_01
  7. 04-stanford/cs336-lecture_02
  8. 04-stanford/cs336-lecture_06
  9. 04-stanford/cs336-lecture_10
  10. 05-safety/alignmentforum-agi-safety-first-principles
  11. 05-safety/nanda-mech-interp-glossary
  12. 11-polo-club/transformer-explainer
  13. 12-karpathy-code/nanoGPT-master-model
  14. 12-karpathy-code/nanoGPT-master-train
  15. 12-karpathy-code/makemore_part1_bigrams
  16. 12-karpathy-code/makemore_part2_mlp
  17. 12-karpathy-code/makemore_part3_bn
  18. 12-karpathy-code/makemore_part4_backprop
  19. 12-karpathy-code/makemore_part5_cnn1
  20. 13-fastbook/12_nlp_dive
  21. 14-arena-notebooks/chapter1-part1-transformer-from-scratch
  22. 14-arena-notebooks/chapter1-part2-intro-to-mech-interp
  23. 14-arena-notebooks/chapter1-part31-linear-probes
  24. 14-arena-notebooks/chapter4-part4-persona-vectors
  25. 16-d2l-sections/chapter_attention-mechanisms-and-transformers__attention-pooling
  26. 16-d2l-sections/chapter_attention-mechanisms-and-transformers__attention-scoring-functions
  27. 16-d2l-sections/chapter_attention-mechanisms-and-transformers__multihead-attention
  28. 16-d2l-sections/chapter_attention-mechanisms-and-transformers__transformer
  29. 18-lilian-weng/2018-06-24-attention
  30. 18-lilian-weng/2020-04-07-the-transformer-family
  31. 18-lilian-weng/2021-09-25-train-large
  32. 18-lilian-weng/2023-01-10-inference-optimization
  33. 18-lilian-weng/2023-01-27-the-transformer-family-v2
  34. 22-anthropic-recent/2021-framework-index
  35. 22-anthropic-recent/2024-scaling-monosemanticity
  36. 24-founder-blogs/karpathy-recipe
  37. 26-pentest-redteam/genai-owasp-org-llm-top-10
  38. 26-pentest-redteam/www-anthropic-com-news-many-shot-jailbreaking
  39. 26-pentest-redteam/github-com-leondz-garak
  40. 29-practice-engineering/lucidrains-xtransformers