S106 · Tokenization — BPE, WordPiece, SentencePiece
The unglamorous layer that decides what your model can even see. BPE, WordPiece, SentencePiece — how the choice of tokenizer costs OpenAI millions and breaks non-English languages.
🎯 Understand how BPE, WordPiece, and SentencePiece turn raw text into integer IDs — and why the choice of tokenizer determines cost, latency, and multilingual quality.
Why this session exists
Every LLM you use has a tokenizer sitting between you and the model. GPT-4 charges by tokens; whether your Hindi prompt is 20 tokens or 200 tokens depends entirely on how the tokenizer was built. A misconfigured tokenizer can double your inference bill overnight, silently break emoji handling, or make your model unable to spell. And when you fine-tune, the tokenizer is one of the two things you must NOT change (weights being the other). Understanding tokenization is the difference between "why does it hallucinate on this simple thing?" and knowing exactly why.
- Explain why char-level, word-level, and subword tokenizers each fail in specific ways.
- Walk through the BPE merge algorithm step-by-step from a small corpus.
- Recognise BPE, WordPiece, and SentencePiece from their output patterns.
- Train a tokenizer on your own corpus and evaluate its efficiency (tokens per word).
- Diagnose the four canonical tokenizer bugs: leading spaces, BOS/EOS tokens, byte fallback, and vocab mismatch.
Prerequisites
- S104 · Embeddings — you need to know that token IDs get embedded into vectors.
- S103 · RNNs & LSTMs — for context on why sequence models need discrete inputs.
- S045 · Text Processing — regex, unicode, encoding basics.
(a) Intuition · 5 min
Imagine you can only speak in Lego blocks. Someone hands you a bag of pre-made blocks — some are single letters, some are common syllables ("ing", "tion"), some are whole common words ("the", "and"), some are entire brand names ("Microsoft"). To describe anything, you must build it from those blocks.
Your bag has ~50,000 unique blocks. If you have a great match for a common word, one block does it. Rare or made-up words? You have to fall back to letter-blocks and glue several together.
That's tokenization. GPT-4's tokenizer has ~100K unique "blocks" (tokens). "hello" is one token. "supercalifragilisticexpialidocious" is 6 tokens (split into common English substrings). A Devanagari word might be 15 tokens because the tokenizer wasn't trained on much Hindi.
The blocks are chosen ONCE during tokenizer training — by scanning a giant corpus and finding the most-common merges. From then on, every input to the model gets sliced against that fixed vocabulary.
Why we don't use characters or words
- Character-level — every char is a token. Vocab tiny (~256), no OOV problem, BUT sequences are 4-5× longer than word-level. Model wastes capacity learning to spell 'the' every time. Death by sequence length.
- Word-level — one token per word. Sequences short, BUT vocab explodes (millions), and 'unhappily' and 'unhappiness' are totally unrelated tokens. Any word not in training vocab becomes <UNK> and information is lost.
- Subword (BPE / WordPiece / SentencePiece) — common words stay whole, rare words split into pieces. Fixed vocab (~30-100K), no OOVs (byte fallback), sequences reasonable. This is what every modern LLM uses.
A short history of "how do we chop text?"
- 1994Byte Pair Encoding inventedPhilip Gage publishes BPE as a compression algorithm in C Users Journal. Nothing to do with NLP.
- 2015BPE for NMT · Sennrich et al.Repurpose BPE as a subword tokenizer for neural translation. Solves OOV problem for rare words.
- 2016WordPiece · Google (Wu et al.)BERT's tokenizer. Same idea as BPE but merges by likelihood, not raw frequency.
- 2018SentencePiece · Google (Kudo)Language-agnostic — works directly on raw Unicode bytes, no language-specific preprocessing.
- 2020GPT-2 · Byte-level BPEOpenAI's twist — BPE on UTF-8 bytes, not chars. Guarantees no OOV, ever. LLaMA-2 and Mistral follow.
- 2024tiktoken · GPT-4 tokenizer~100K vocab, byte-level BPE. Handles 100+ languages but with wildly uneven efficiency.
(b) Visual walkthrough · 15 min
The BPE merge algorithm
Start with characters as your vocabulary. Repeatedly find the most-frequent adjacent pair and merge it into a new token. Stop when vocab reaches desired size.
BPE by hand on a tiny corpus
BPE vs WordPiece vs SentencePiece
Merge by frequency
- Simple greedy: most-common pair wins
- Deterministic segmentation at inference
- Byte-level variant handles any Unicode
- GPT-4, LLaMA-2, Mistral
Merge by likelihood
- Chooses merges maximising corpus likelihood
- Slightly better than BPE on some tasks
- Uses ## prefix to mark subword-continuation
- BERT, DistilBERT, ELECTRA
Prune from big vocab
- Start with huge vocab, prune tokens that hurt likelihood least
- Multiple segmentations per input possible (regularisation)
- Works on raw Unicode, no pre-tokenization needed
- T5, mT5, XLM-R, ALBERT
BPE on UTF-8 bytes
- Vocab starts with 256 bytes, not 100K+ chars
- Zero OOVs — ever
- Trade: emoji + non-English cost more tokens
- GPT-2, GPT-3, GPT-4, LLaMA-1
The four moving parts every tokenizer has
Anatomy of any modern tokenizer
How tokens map to model input
"A token is roughly a word. So if I need to fit 4,000 words in a context window of 8,192 tokens, I have plenty of room — and my cost estimate scales with word count."
A token is a byte-level subword unit chosen by a compression algorithm, and its ratio to words is a property of your data distribution, not of language. English prose lands near 1.3 tokens/word; JSON, code, GUIDs, base64, log lines, non-Latin scripts and rare proper nouns can run 3–10× worse. The same character count can differ by an order of magnitude in tokens.
Because the myth is true for the corpus the tokenizer was fit on. BPE merges are learned to minimise sequence length on training text, which is overwhelmingly English web prose — so on English prose the near-word intuition holds and gets reinforced daily. The first time it breaks is when you paste a stack trace, a Kusto query result, or Telugu text, and suddenly a "small" payload blows the window.
Tokenize four payloads of similar character length and compare:
import tiktoken
enc = tiktoken.get_encoding('cl100k_base')
samples = {
'prose': 'the quick brown fox jumps over the lazy dog ' * 8,
'json' : '{\"user_id\": \"a3f9c2\", \"ts\": 1712345678}' * 8,
'guid' : '9f1c2d3e-4b5a-6789-0abc-def012345678 ' * 8,
'b64' : 'SGVsbG8gd29ybGQgdGhpcyBpcyBiYXNlNjQ=' * 8,
}
for k, v in samples.items():
n = len(enc.encode(v))
print(f'{k:6s} chars={len(v):5d} tokens={n:5d} chars/token={len(v)/n:.2f}')Why does BPE greedily merge the most frequent adjacent pair, rather than, say, merging the longest repeated substring? And why does it never produce a token that crosses a rare boundary?
- 1The tokenizer must map arbitrary text to a fixed vocabulary of size V, with no out-of-vocabulary failures ever.forced by · the embedding matrix has exactly V rows; an unknown symbol has no vector and the model cannot run
- 2Guaranteeing that requires the vocabulary to contain a base alphabet that covers every possible input — in practice the 256 raw bytes.forced by · any text is a byte string, so a byte-complete base makes coverage total by construction
- 3Given byte-completeness, every extra vocabulary slot is pure compression: it exists only to shorten sequences, and each slot must be paid for with an embedding row plus a softmax output row.forced by · vocabulary size costs parameters linearly at both the input and output ends of the model
- 4Sequence length is what actually costs money: attention is quadratic in sequence length and every layer is linear in it. So the objective is "minimise expected tokens per document, subject to V slots".forced by · compute per request is dominated by sequence length, not by vocabulary size
- 5Under that objective, the marginal value of adding a merge equals the number of token-positions it removes from the corpus — which is exactly the frequency of that adjacent pair. Greedily taking the highest-frequency pair is the greedy solution to the compression objective.forced by · each merge of a pair occurring f times shortens the corpus by f tokens, so frequency is the gain function
Therefore BPE is not a linguistic algorithm at all — it is greedy corpus compression under a fixed budget, and word boundaries appear only as a side effect of frequency. Nothing in the derivation mentions morphemes.
And note what this predicts: text whose byte-pair statistics differ from the training corpus must tokenize worse, because none of its frequent pairs bought a slot. That is precisely why non-Latin scripts, minified code and hashes cost multiples more tokens per character — and why a domain-specific tokenizer is a real lever, not a micro-optimisation.
Picture the tokenizer as a zip codebook fitted once to a specific corpus and then frozen forever. Frequent byte sequences in that corpus got their own short code. Everything else gets spelled out, byte by byte, at full price.
The model never sees your text. It sees a sequence of codebook indices. Anything the codebook fragments badly, the model must reassemble internally — spending attention and layers on work the tokenizer should have done.
- Byte-level BPE never fails on unknown input; it just becomes expensive. Degradation is in cost and quality, never in a crash.
- Tokens-per-character is a property of your data, not the language. Measure it on your payloads before estimating cost or context budget.
- Leading whitespace is part of the token.
" the"and"the"are different IDs — this is why prompts ending in a trailing space behave oddly. - Tasks that need character-level reasoning (spelling, reversing strings, counting letters, arithmetic on long digits) are hard because characters are hidden inside tokens.
Fire this model the moment you see: a context-window overflow on text that "looked short" · a cost estimate built from word counts · a model that cannot count letters in a word · non-English or code-heavy inputs costing far more than expected · an embedding model behaving worse on IDs and SKUs than on prose.
Your workload is domain-heavy (logs, telemetry schemas, code). Do you reuse a general pretrained tokenizer, extend it with domain tokens, or train a domain tokenizer from scratch?
Almost always reuse. The tokenizer is the one component that is coupled to everything — weights, caches, evals, downstream services — so changing it is never a local change. Measure first: run your real payload distribution through the encoder and get an actual tokens-per-record number. If the fragmentation cost is a few percent, you have no problem; if a repeated schema key is eating a third of every request, fix that in the prompt format before you touch the vocabulary.
The senior move is usually neither of the three: restructure the payload. Dropping verbose JSON keys, stripping GUIDs to short IDs, or moving a repeated schema into the system prompt buys most of the win at none of the compatibility cost.
(c) Hands-on · 25 min
Train a BPE tokenizer from scratch on a small corpus, encode/decode text, and benchmark efficiency (tokens per word) against pretrained tokenizers.
# mini_bpe.py — a minimal BPE trainer + encoder.
# Trains on a text file, then encodes/decodes sample text.
# Also compares tokens-per-word against tiktoken (GPT-4 tokenizer).
import sys
import re
from collections import Counter
from pathlib import Path
VOCAB_TARGET = 500 # small for demo; production uses 30K-100K
END_OF_WORD = "</w>"
def tokenise_words(text: str) -> list[str]:
"""Simple pre-tokenizer: split on whitespace + punctuation boundaries."""
return re.findall(r"[a-zA-Z]+|[^a-zA-Z\s]", text.lower())
def get_stats(splits: dict[tuple[str, ...], int]) -> Counter:
"""Count all adjacent symbol pairs across all words, weighted by word frequency."""
pairs: Counter = Counter()
for word, freq in splits.items():
for i in range(len(word) - 1):
pairs[(word[i], word[i + 1])] += freq
return pairs
def merge_pair(splits: dict[tuple[str, ...], int], pair: tuple[str, str]) -> dict[tuple[str, ...], int]:
"""Merge all occurrences of `pair` in every word split."""
a, b = pair
merged = a + b
new_splits: dict[tuple[str, ...], int] = {}
for word, freq in splits.items():
new_word: list[str] = []
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_splits[tuple(new_word)] = freq
return new_splits
def train_bpe(corpus_path: str, vocab_target: int) -> tuple[list[str], list[tuple[str, str]]]:
text = Path(corpus_path).read_text(encoding="utf-8")
words = tokenise_words(text)
counts = Counter(words)
print(f"unique words: {len(counts):,}")
# Represent each word as tuple(chars) + end-of-word marker.
splits: dict[tuple[str, ...], int] = {
tuple(list(w) + [END_OF_WORD]): freq for w, freq in counts.items()
}
# Base vocab: all chars seen + EOW.
vocab: set[str] = {END_OF_WORD}
for word in splits:
vocab.update(word)
print(f"base vocab (chars): {len(vocab)}")
merges: list[tuple[str, str]] = []
while len(vocab) < vocab_target:
stats = get_stats(splits)
if not stats:
break
best_pair, best_count = stats.most_common(1)[0]
if best_count < 2:
break
splits = merge_pair(splits, best_pair)
merges.append(best_pair)
vocab.add(best_pair[0] + best_pair[1])
if len(merges) % 50 == 0:
print(f" merge {len(merges):4d}: {best_pair} ×{best_count} vocab={len(vocab)}")
return sorted(vocab), merges
def encode(text: str, merges: list[tuple[str, str]]) -> list[str]:
"""Apply merges in order to a new piece of text."""
words = tokenise_words(text)
encoded: list[str] = []
for word in words:
pieces: list[str] = list(word) + [END_OF_WORD]
# Apply merges greedily in order.
for a, b in merges:
merged = a + b
i = 0
while i < len(pieces) - 1:
if pieces[i] == a and pieces[i + 1] == b:
pieces = pieces[:i] + [merged] + pieces[i + 2 :]
else:
i += 1
encoded.extend(pieces)
return encoded
def compare_to_gpt4(text: str, our_tokens: list[str]) -> None:
try:
import tiktoken
except ImportError:
print("(pip install tiktoken to compare against GPT-4 tokenizer)")
return
enc = tiktoken.get_encoding("cl100k_base") # GPT-4
gpt4_tokens = enc.encode(text)
words = len(text.split())
print(f"\nText: {text[:60]!r}...")
print(f" words: {words}")
print(f" our tokens: {len(our_tokens)} ({len(our_tokens)/words:.2f} per word)")
print(f" GPT-4 tokens: {len(gpt4_tokens)} ({len(gpt4_tokens)/words:.2f} per word)")
def main(corpus_path: str) -> None:
vocab, merges = train_bpe(corpus_path, VOCAB_TARGET)
print(f"\nfinal vocab={len(vocab)} merges={len(merges)}")
demo = "The quick brown fox jumps over the lazy dog. Tokenization matters."
tokens = encode(demo, merges)
print(f"\nDemo encode: {demo}")
print(f" → {tokens[:30]}{'...' if len(tokens) > 30 else ''}")
print(f" → {len(tokens)} tokens for {len(demo.split())} words")
compare_to_gpt4(demo, tokens)
# Try something non-English if the corpus has it.
hindi = "नमस्ते दुनिया"
print(f"\nNon-English test: {hindi!r}")
compare_to_gpt4(hindi, encode(hindi, merges))
if __name__ == "__main__":
main(sys.argv[1])Anatomy of the script
What the interesting lines do
Modify main() to encode some torture-test strings:
You'll observe GPT-4 dominating on common patterns and matching yours on truly rare ones. Tokenizer efficiency = "how much your training corpus overlapped with real user text."
(d) Production reality · 15 min
GPT-3's tokenizer (r50k_base) was trained heavily on English web scrapes. When developers built apps for non-English users, they discovered inference costs were 3-5× higher than expected for the same message length. A Korean user's prompt might use 4× the tokens of an English equivalent — and get charged 4× per API call.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- Why is subword tokenization the industry default? What did char-level and word-level get wrong?
- Walk through BPE training in your head on 'low low lower newest'.
- Why does a Hindi prompt cost 4× more than an English one on GPT-4?
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.