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.
"BPE learns the meaningful subwords of a language. It discovers morphemes — prefixes, suffixes, roots — because those are the units that recur, so the vocabulary ends up linguistically sensible."
BPE optimises one thing only: it repeatedly merges the pair of adjacent symbols that occurs most often in the training corpus. It has no notion of a morpheme, a word boundary, or meaning. That it sometimes produces morpheme-like pieces is a side effect of frequent morphemes being frequent character pairs — a correlation, not an objective. Where the two diverge, frequency always wins: common words survive whole regardless of internal structure, rare but morphologically transparent words get shredded into fragments that cut across morpheme boundaries, and the vocabulary is dominated by whatever the training corpus happened to contain heavily.
Because the outputs are so often plausible. Inspect a trained vocabulary and you find ing, tion, un, pre — exactly the morphemes you were hoping for, sitting right there. Confirmation is immediate and abundant, because English's frequent morphemes really are frequent character pairs. The belief only breaks on the cases where the alignment fails: numbers segmented inconsistently so that arithmetic behaves erratically, a language underrepresented in the corpus needing many more tokens per word than English, or a chemical name shattered into a dozen meaningless pieces. Each of these is baffling under the morpheme story and immediately predictable under the frequency story.
Feed BPE a corpus where frequency and structure disagree, and watch which one it follows:
# A corpus where a meaningless pair is very frequent and a real
# morpheme boundary is rare.
corpus = ['qx ' * 500, 'unhappiness '] # 'qx' beats 'un|happi|ness'
vocab = train_bpe(corpus, num_merges=50)
print([m for m in vocab.merges[:10]])
# 'q'+'x' merges first. No morpheme in sight.
# On a real tokenizer, check the frequency story on numbers:
for n in ('1', '12', '123', '1234', '12345'):
print(n, tok.encode(n))
# Segmentation is inconsistent across lengths because it follows
# corpus frequency of digit strings, not place value -- which is
# why digit arithmetic is unreasonably hard for these models.Why do modern tokenizers operate on bytes rather than Unicode characters? Characters are the natural unit of text — bytes seem like a step backwards into encoding details.
- 1A vocabulary must be finite and fixed before training, and every possible input must map into it, because an unrepresentable input has no encoding at all.forced by · the embedding table has a fixed number of rows chosen at model-definition time
- 2Unicode defines over a hundred thousand assigned code points and the standard keeps growing. Making each a base symbol would consume the entire vocabulary budget on base units before a single merge is learned.forced by · the vocabulary must contain all base symbols plus all merged symbols, and the base set alone would exhaust a typical 32k or 50k budget many times over
- 3Restricting the base set to characters actually seen in the training corpus fixes the size but reintroduces the unknown-token problem: any character not in that corpus — an emoji released later, a script you did not sample, a corrupted byte — becomes unrepresentable and must be replaced by a placeholder, destroying information irrecoverably.forced by · a lossy encoding means the model cannot reproduce the input, which breaks any task requiring exact output
- 4Bytes have exactly 256 possible values, always, by definition. Any text in any encoding, any binary data, any malformed input is a sequence of bytes, so a byte-level base vocabulary is both tiny and provably complete.forced by · UTF-8 is a total function from text to byte sequences, so byte coverage is universal coverage
- 5Therefore taking bytes as the base and letting BPE merge upward gives complete coverage for 256 slots, leaving essentially the whole budget for learned merges — and any unseen character simply costs more tokens rather than being lost.forced by · graceful degradation in token count is strictly better than an information-destroying fallback
Therefore byte-level is not a low-level implementation detail but the resolution of a hard constraint: it is the only base alphabet that is simultaneously small and complete.
The prediction to check: since a character outside the merge-rich part of the vocabulary decomposes into its raw UTF-8 bytes, a script that was rare in the training corpus should cost several tokens per character rather than one. Encode the same sentence in English and in a low-resource script and compare token counts — the ratio is often several-fold. That ratio is not a curiosity: it is directly a cost multiplier for API users of those languages, a proportional reduction in effective context window, and a reason model quality is worse there. A tokenizer decision made for encoding-completeness reasons turns into an equity issue, and the derivation shows you exactly why.
You are compressing a corpus and allowed a dictionary of V entries. Start with the 256 bytes. Then repeat: scan the whole corpus, find the adjacent pair that appears most often, add it as a new entry, replace every occurrence. Stop at V.
That is the entire algorithm — it is a greedy compressor, and the "learning" is nothing more than counting. Encoding later applies the learned merges in the order they were learned, which is why the merge list is the model and must be applied in sequence, not as an unordered set.
- Objective is frequency, full stop. Anything linguistic in the output is coincidence arising from frequent things being frequent.
- Merges are ordered and must be applied in training order at encode time. Treating them as a set produces a different, wrong segmentation.
- Training is over unique words with counts, not over the raw stream — otherwise every merge scan re-reads the entire corpus and training is unaffordably slow.
- Vocabulary size trades sequence length against embedding parameters: bigger vocabulary means fewer tokens per document but a larger embedding and output matrix, and rarer tokens each seen less often during training.
Fire this model the moment you see: a model failing at arithmetic or character-level tasks like counting letters · non-English text costing far more tokens than expected · a rare word behaving strangely · anyone claiming a tokenizer "understands" morphology · a decision about vocabulary size · unexpected behaviour on a token that is a whitespace-plus-word pair.
You are choosing a vocabulary size for a model you will train from scratch. Small, large, or inherited?
Inherit unless you can point at a concrete mismatch, and measure the mismatch before deciding: encode a representative sample of your data with the candidate tokenizer and compute tokens per word or per line. If that number is much worse than for general text, you have a real reason to train your own; if it is comparable, you do not.
The point people miss is that vocabulary size is not a quality knob you tune upward. It is a budget allocation between embedding parameters and sequence length, and both sides have real costs. Pick it from the arithmetic of your model size and your data, then leave it alone — it is not where your remaining quality is hiding.
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.