← Index fig. 4
Transformer from Scratch
a 93M-parameter English-to-French transformer, written and trained from scratch in PyTorch
I’d used transformers plenty, but I’d never written one where I could point at every line and say what it does. So I wrote one, from the embedding layer up, and then trained it long enough to actually translate.
TLDR: an encoder–decoder transformer in plain PyTorch, the shape of the original “Attention Is All You Need” model. 6 encoder blocks, 6 decoder blocks, d_model 512, 8 heads, a 32,000-word vocabulary on each side. 93.4M parameters. I trained it for 200,000 steps on a million English–French sentence pairs on my laptop’s GPU (Apple MPS), about 21 hours, and added a KV cache that makes decoding 2.2–2.5x faster.
Building it piece by piece
The git log is basically the table of contents. Embeddings and multi-head attention first, then the encoder, then the decoder, then cross-attention, then the full model. I kept the earlier versions of attention in the file (single-head, then one weight matrix per head) because each one was a step I needed to see work before the next.
The attention module is where most of the thinking went. It handles self-attention, masked self-attention and cross-attention with the same code, plus the cache.
Data
The corpus is a 22.5M-row English–French CSV. My first loader just read the whole thing into pandas and tokenised in __getitem__. That’s fine on the 5,000-row sample and hopeless on the real file.
The version that trained the model does two passes over the CSV, once to count words and build the vocabularies and once to encode every row, and writes the result to a binary cache. After that the dataset is memory-mapped, so start-up is instant and fetching a sentence is an array slice. Batches are length-bucketed: sentences of similar length get batched together so you’re not padding a 6-word sentence out to 120.
Tokenisation is the dumb kind: lowercase and split on spaces. Punctuation stays stuck to words, so “paris.” and “paris” are different tokens, and anything outside the top 32,000 becomes <unk>. That shows up in every output below.
Training on a laptop
I trained on the first million rows, 200,000 steps at batch 32, which is about six passes over that subset. It ran for 21 hours at around 3,200 tokens a second. Memory was the constant fight: a 4,096-token batch peaks near 7.8 GB, and somewhere around 32,000 tokens per batch the MPS backend just dies. The training loop skips a batch on out-of-memory instead of crashing, and drops the allocator cache every so often because length bucketing keeps changing tensor shapes.
The training script writes the model’s output for a few fixed sentences every 1,000 steps, and that log turned out to be my favourite record of the model learning. It starts with random French-ish words, picks up common phrases, and eventually gets whole sentences right.
KV cache
Without a cache, generating each new word re-runs attention over every word so far. With one, the encoder runs once and each step only projects the newest token. I pass the cache in and out of the model instead of storing it on the modules, so two translations can run against the same model and nothing leaks into the next training step.
Measured on CPU, cached decoding is 2.15–2.49x faster, and the outputs match the full forward pass to within 2.3e-6.
What it gets right and wrong
The attention maps convinced me it had learned real structure. In the later decoder blocks, cross-attention lines up English and French words almost along a diagonal, bending where French word order differs.
What I’d change
A real subword tokenizer (BPE) first, since half the mistakes above come from the vocabulary. Then a validation set, because right now I only know training loss. There’s also a decoder-only version with grouped-query attention in the repo that I haven’t trained yet.