DL S044 · BPE from Scratch — Byte by Byte
Build a byte-pair-encoding tokenizer on a 10 MB corpus in pure Python. Understand every line of GPT-2's vocab file. Handle Unicode bytes, merge rules, greedy encode, and the classic edge cases.
🎯 Implement BPE training and encoding from scratch, produce a working vocab file for a 10 MB corpus, and hand-trace merges on a toy example.
Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 44 / 80 · Module M08 · ~2 hours
The story
You've been feeding your models character-level tokens. That's fine for Tiny Shakespeare, wasteful for anything real. GPT-2, GPT-3, and pre-Llama frontier models all use byte-pair encoding (BPE) — a middle ground between characters (~100 tokens, model has to learn spelling from scratch) and full words (~100k+ tokens, huge embedding table, out-of-vocab problems). BPE gives you a vocab of ~50k subword tokens where common words become single tokens and rare words split into meaningful chunks.
BPE's core algorithm is embarrassingly simple:
- Start with each byte as its own token.
- Find the most frequent adjacent pair of tokens in the corpus.
- Merge that pair into a new token.
- Repeat until you hit your target vocab size.
The trick is doing it efficiently on billions of tokens, and handling the Unicode / whitespace / punctuation edge cases that will otherwise mangle your model's ability to represent code, emoji, and non-English text. This session we build a working BPE from scratch in ~80 lines, train it on 10 MB of Wikipedia-like text, and understand exactly what's happening when Karpathy says "tokenization is 90% of the pain of building an LLM."
- Explain BPE's training algorithm in three sentences.
- Implement BPE training on a small corpus in <100 lines of pure Python.
- Encode arbitrary strings against a trained vocab using the greedy pair-merge algorithm.
- Explain why GPT-2 tokenizes bytes (not characters) and what happens if you tokenize characters instead.
- Recognize the four classic BPE pathologies: 'SolidGoldMagikarp', numbers-splitting, Unicode splits, whitespace handling.
- Read the GPT-2 vocab.bpe / encoder.json format and reproduce it with your own trainer.
Prerequisites
- S041 — char-level tokenizer, the predecessor.
- Basic Python
collections.Counteranddictfluency.
1 · The algorithm in three sentences
BPE training:
- Represent every text sample as a list of tokens, starting with every byte (or char) being its own token.
- Count how often each adjacent pair of tokens appears; find the most common pair.
- Add a new token to the vocab that represents "this pair merged"; replace every occurrence of the pair in the corpus with the new token. Repeat step 2 with the new corpus until vocab size reached.
That's it. The output of training is a vocab (list of tokens) and a merge list (ordered list of pairs that got merged, needed to encode new text later).
2 · Toy example — hand-trace
Corpus: "low low low low low lower lower newest newest widest widest widest" (Sennrich's original example, character-level for clarity).
Start with every char as its own token. Word boundaries matter, so we put </w> at the end of each word:
l o w </w> (×5)
l o w e r </w> (×2)
n e w e s t </w> (×2)
w i d e s t </w> (×3)Count adjacent pairs across all copies. The most common pair: ('e', 's') — appears in every "newest" (×2) and every "widest" (×3) = 5 times.
Merge: create new token es, replace e s with es everywhere:
l o w </w> (×5)
l o w e r </w> (×2)
n e w es t </w> (×2)
w i d es t </w> (×3)Next iteration. New pair counts: ('es', 't') = 5, ('l', 'o') = 7. Winner: ('l', 'o') → merge:
lo w </w> (×5)
lo w e r </w> (×2)
n e w es t </w> (×2)
w i d es t </w> (×3)Next: ('lo', 'w') = 7. Merge:
low </w> (×5)
low e r </w> (×2)
n e w es t </w> (×2)
w i d es t </w> (×3)After a few more iterations, low</w>, lower</w>, newest</w>, widest</w> all become single tokens. The vocab has learned the common words as atomic units while keeping the ability to build up rare words from smaller pieces.
That is BPE. Byte-level GPT-style BPE just does the same thing starting from raw bytes instead of characters — critical for handling arbitrary Unicode.
3 · Training BPE in ~50 lines
from collections import Counter, defaultdict
def get_pair_counts(word_freqs):
"""Given {word_tuple: freq}, count all adjacent pairs."""
pairs = Counter()
for word, freq in word_freqs.items():
for i in range(len(word) - 1):
pairs[(word[i], word[i+1])] += freq
return pairs
def merge_pair(pair, word_freqs):
"""Return new word_freqs with 'pair' merged into a single token."""
new_word_freqs = {}
a, b = pair
merged = a + b
for word, freq in word_freqs.items():
new_word = []
i = 0
while i < len(word):
if i < len(word) - 1 and word[i] == a and word[i+1] == b:
new_word.append(merged)
i += 2
else:
new_word.append(word[i])
i += 1
new_word_freqs[tuple(new_word)] = freq
return new_word_freqs
def train_bpe(corpus_text, vocab_size, verbose=False):
# 1. Split into words, count freqs, split each word into chars
word_freqs = Counter(corpus_text.split())
word_freqs = {tuple(w) + ('</w>',): c for w, c in word_freqs.items()}
vocab = set(ch for word in word_freqs for ch in word)
merges = []
while len(vocab) < vocab_size:
pair_counts = get_pair_counts(word_freqs)
if not pair_counts:
break
best_pair = max(pair_counts, key=pair_counts.get)
word_freqs = merge_pair(best_pair, word_freqs)
merges.append(best_pair)
vocab.add(''.join(best_pair))
if verbose:
print(f"merge {len(merges)}: {best_pair} (count={pair_counts[best_pair]})")
return vocab, mergesRun it on our toy corpus:
Fifty lines. That's a working (if slow) BPE trainer.
4 · Byte-level BPE — the GPT-2 way
Character-level BPE has a Unicode problem. What's the vocab? All possible characters? Chinese has 20k+ common chars, emoji is unbounded, private-use planes exist. You'd need a huge base vocab or drop out-of-vocab chars entirely.
GPT-2's fix: BPE at the BYTE level. Every Unicode character encodes as 1–4 UTF-8 bytes; work at the byte level and your base vocab is exactly 256 (bytes 0–255). Merges happen between bytes and byte-sequences. Every string can be tokenized; no OOV.
Twist: printable-ASCII bytes are used directly, but non-printable bytes (control chars, high UTF-8 bytes) get remapped to visually-representable Unicode code points via a bijective function (see bytes_to_unicode() in OpenAI's encoder.py). This is purely aesthetic — makes the vocab file human-readable when it contains characters like Ġ (which stands for the space character).
def bytes_to_unicode():
"""OpenAI's byte-to-visible-unicode mapping."""
bs = list(range(ord("!"), ord("~")+1)) + list(range(ord("¡"), ord("¬")+1)) + list(range(ord("®"), ord("ÿ")+1))
cs = bs[:]
n = 0
for b in range(2**8):
if b not in bs:
bs.append(b)
cs.append(2**8 + n)
n += 1
return dict(zip(bs, [chr(c) for c in cs]))If you see Ġthe in a GPT-2 vocab, that's the (leading-space + "the") after the byte-to-unicode remap. Not a special character. Just readability.
Download wikitext-2-raw-v1 from Hugging Face, keep only train.raw, and run your BPE trainer from Section 3 with num_merges=500. Print the last 20 merges added. You should see the model discovering things like th, he, ing, the, an, in — in that order. That ordering is not a coincidence: it's a fingerprint of English character-pair statistics. Save the merge list; you'll want it when comparing against SentencePiece tomorrow.
5 · Encoding with a trained BPE
Encoding a new string requires applying merges IN THE ORDER they were learned:
def encode(text, merges):
tokens = list(text) + ['</w>']
for pair in merges:
i = 0
while i < len(tokens) - 1:
if tokens[i] == pair[0] and tokens[i+1] == pair[1]:
tokens[i] = pair[0] + pair[1]
del tokens[i+1]
else:
i += 1
return tokensEach merge in the merge list is applied greedily to the current token sequence. Order matters — merging ('e', 's') first (an early learned pair) enables the later merge ('es', 't').
Real BPE encoders speed this up with a priority queue keyed by merge index. Our simple version is O(vocab_size · text_len) per encode, fine for teaching, too slow for production.
Use the ~50 lines from §3 to train a small BPE on Tiny Shakespeare (input.txt from S041):
with open('input.txt') as f: text = f.read()
vocab, merges = train_bpe(text, num_merges=500)
print("First 30 merges (in order they were learned):")
for i, (a, b) in enumerate(merges[:30]):
print(f" {i:3d}: {a!r} + {b!r} -> {(a+b)!r}")What you'll see, roughly in this order: e + (space-terminated e), t + h, th + e, o + u, s + , an + d, in + g. That's English writing itself into your vocab. Now measure the compression: how many chars per token do you get on the same text at 500 merges vs 2000 vs 5000? Plot it. You'll see diminishing returns kick in hard around 2K — which is exactly why char-level Tiny Shakespeare's 65-token vocab is educational but wasteful.
6 · The four classic BPE pathologies
Some tokens in GPT-2's vocab (like SolidGoldMagikarp, PsyNetMessage, petertodd) come from Reddit usernames or forum artifacts in the training data. They got merged into single tokens but almost never appear in normal text at inference. Result: the model has almost never seen these token IDs used, so its behavior on them is undefined and often weird — repeating them, producing gibberish, refusing to acknowledge them.
Discovered by researchers in 2023. Fix: audit your BPE vocab post-training for tokens with anomalously low frequency; either remove them or upweight them in fine-tuning.
GPT-2 splits "12345" as 12345 sometimes, 123 45 sometimes, 1 2 3 4 5 sometimes — depending on surrounding whitespace and what merges happened during training. This is why GPT-4 is bad at arithmetic: the same number gets tokenized differently in different contexts, so the model has to learn "12345" and "1 2 3 4 5" are the same quantity, which it doesn't do reliably.
Fix (used in Llama 3 and Qwen): split digits into individual tokens ALWAYS. Consistent tokenization for numbers.
GPT-2 tokenizes bytes, not characters. A single Chinese character can span 2–3 tokens. If sampling truncates mid-character (e.g., max_tokens hit) you get invalid UTF-8 that renders as ? or garbage. Always decode with errors='replace' and be aware that stopping conditions on \n don't map cleanly to "end of Chinese sentence".
BPE puts leading spaces INSIDE tokens: the is one token, the is a different one. "Hello" and " Hello" tokenize differently. Systems that strip leading/trailing whitespace before tokenizing produce different tokens than systems that don't. This breaks many prompt-engineering hacks and few-shot examples. Every LLM caller should know exactly how their tokenizer handles whitespace.
7 · How big should your vocab be?
- Too small (< 1k): tokens carry too little info, every input needs long sequences, attention becomes expensive.
- Sweet spot for English + code: ~32k-100k (Llama-2: 32k, GPT-2: 50k, GPT-4: ~100k, Gemma 3: 256k).
- Multilingual: bump to 100k-256k so non-Latin scripts get their own tokens instead of splitting into bytes.
- Too big (> 500k): huge embedding matrix, wastes params on rare tokens the model can't learn well.
- Trade-off: 2× vocab → half as many tokens per input → faster inference → but embedding table doubles in size.
8 · Pitfalls of BPE training
text.split() (whitespace split) is fine for prose. It loses ability to tokenize things without whitespace — CJK text, code without spaces, URLs. GPT-2 uses a specific regex pat = re.compile(r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""") that separates contractions, punctuation, and word chunks BEFORE BPE. Reproduce this or your BPE will merge across boundaries that hurt downstream training.
If your training corpus has near-duplicate documents (very common — the web has a lot of copy-paste), their over-representation biases merges toward those docs' vocab. Dedupe with MinHash (S049) before BPE training.
Retention scaffold
Recall questions
1. BPE training in three sentences.
Start with bytes/chars as tokens. Repeatedly find the most frequent adjacent pair, merge into a new vocab token, and update the corpus. Stop when vocab size reaches target.
2. Why byte-level BPE instead of char-level?
Base vocab of 256 (all bytes) means every Unicode character (any script, any emoji, any private-use codepoint) tokenizes without OOV. Char-level has an unbounded and unpredictable base vocab.
3. What are 'merges' and why do you need them for encoding?
Merges are the ordered list of pairs that got merged during training. Encoding new text requires applying them in the same order (greedily) to reproduce the same token sequence.
4. Name two BPE pathologies and how modern models fix them.
(a) Number-splitting inconsistency → force digit-level tokenization (Llama 3, Qwen). (b) SolidGoldMagikarp tokens → post-training vocab audit, remove low-frequency artifacts.
5. What does 'Ġthe' mean in a GPT-2 vocab file?
It's the (space + "the") after the byte-to-unicode remap that makes non-printable bytes visually representable. Ġ stands for the space byte.
The 2025 tokenizer landscape (what changed since GPT-2)
BPE hasn't gone away — but 2024–2025 pushed vocab sizes, byte-fallback strategies, and pre-tokenization regex to places GPT-2 never imagined. A quick tour, so you know what you're competing with when you ship a tokenizer in 2026:
- Llama 3 (Meta, 2024) — 128k-token BPE vocab (4× Llama 2's 32k), trained on a much more multilingual + code-heavy corpus. Result: ~15% fewer tokens per document on average vs Llama 2, which directly translates to ~15% cheaper inference and ~15% more usable context. Dubey et al. (2024), The Llama 3 Herd of Models, arxiv 2407.21783.
- DeepSeek-V3 (Dec 2024) — 129k BPE vocab, aggressive Chinese-character coverage; tokenizer alone is ~30% cheaper on Chinese than GPT-4's
cl100k_base. Whole-model tech report: arxiv 2412.19437. - Gemma 3 (Google, 2025) — 256k SentencePiece unigram vocab, the biggest widely-deployed vocab today. The multilingual gains are real; the embedding table is 256k × 2048 = 500M parameters on the 1B model — nearly half the model is the token table. There's a scaling-law twist here (see S047).
- Tekken (Mistral Nemo, 2024) — Mistral's byte-level BPE trained on 100+ languages plus Python/JS/C++. Reported ~30% better compression on non-English + code vs Llama 3's tokenizer at similar vocab size.
tokenmonster(Alasdair Forsythe, 2023–2024) — an alternative training algorithm that optimizes vocab against a language-model-like objective (rather than greedy pair-count). On matched vocab sizes it typically gives 5–15% better compression than BPE. Not yet used by a frontier model, but the open-source community loves it. https://github.com/alasdairforsythe/tokenmonster
The rule of thumb for 2026 is: larger vocab ≈ better compression ≈ cheaper inference, but embedding table starts eating your parameter budget. A 1B model with a 256k vocab has ~half its params locked in the token table, which is bad for capacity per FLOP. Karpathy's 2024 rant on this — search his X for "tokenization is at the heart of much weirdness in LLMs" — is required reading.
In Feb 2023, the LessWrong community discovered that GPT-2/3 had a dozen tokens ( SolidGoldMagikarp, petertodd, guiActiveUn) that, when prompted, made the model output nonsense, insults, or refuse to respond. The culprit: those tokens appeared in the BPE training corpus (Reddit usernames, scraped forums) but were filtered out of the actual LM training data. The embedding for them was therefore never trained — a random-init vector connected to nothing. Rumbelow & Watkins wrote it up as "glitch tokens". Every serious 2024+ tokenizer training pipeline now includes a frequency audit: any token that appears <0.001% in the actual training corpus is removed before training the model. Cheap lesson, expensive discovery.
Stretch prompt
Train BPE with vocab_size = 5000 on the first 10 MB of Wikipedia (grab from en.wikipedia.org/wiki/Special:Export). Compare token counts of a paragraph tokenized with your vocab vs GPT-2's tokenizer (from transformers import GPT2Tokenizer). You should be within ~10% — differences will be in how you handle unusual chars, punctuation, and byte remapping.
In your own words
"If a friend asked why tokenization matters — why we don't just feed characters or words to the model — what would I say?"
Spaced review
- S041 — char-level tokenizer we're now generalizing.
Next-session teaser
S045 compares BPE to SentencePiece unigram, the tokenizer scheme used by Llama, T5, and many modern models. Same goal (subword tokens), different training algorithm, subtly different tradeoffs.
Bring back tomorrow
- BPE: greedy pair merging until vocab size reached.
- Byte-level > char-level for universal Unicode support.
- Encoding = apply learned merges in order.
- Tokenization pathologies are the #1 cause of "why is my LLM bad at X" questions.