TAI BUI
Back to curriculum
BuildPython, Rust

Tokenizers: BPE, WordPiece, SentencePiece

Your LLM does not read English, it reads integers. The tokenizer decides whether those integers carry meaning or waste it.

TIME: ~90 minutesPREREQUISITES: Phase 05 (NLP Foundations)

Learning Objectives

  • Implement BPE, WordPiece, and Unigram tokenization algorithms from scratch and compare their merge strategies
  • Explain how vocabulary size affects model efficiency: too small creates long sequences, too large wastes embedding parameters
  • Analyze tokenization artifacts across languages and code, identifying where specific tokenizers break down
  • Use the tiktoken and sentencepiece libraries to tokenize text and inspect the resulting token IDs

The Problem

Your LLM does not read English. It does not read any language. It reads numbers.

The gap between "Hello, world!" and [15496, 11, 995, 0] is the tokenizer. Every word, every space, and every punctuation mark gets converted into an integer before the model sees it. That conversion is not neutral, because it bakes assumptions into the model that you cannot undo later.

Get it wrong and the model spends capacity on common words that take several tokens each, and your context window holds less text than it should. Get it right and the same window holds more meaning. Much of the gap between "this model handles code well" and "this model chokes on Python" comes down to how the tokenizer was trained.

API calls to GPT-4 or Claude are priced per token, and every token a model generates costs compute. The fewer tokens it takes to represent an output, the faster inference finishes end to end. Tokenization is not preprocessing. It is architecture.

Warm-up0/2
1. A zip tool shrinks a file by exploiting a property of the data. Which property?Warm-up
A short line of raw text, 'banana banana banana split', above a dashed box with a question mark asking what makes this file shrink.
Everyday setup: a text file about to be compressed.
2. How many different values can a single byte hold?Warm-up
Eight small boxes each holding a 0 or a 1, labelled one byte equals 8 bits, above a dashed box asking how many different bytes exist.
Everyday setup: a single byte, 8 bits.
Check: The Problem0/1
1. What is the primary purpose of a tokenizer in an LLM pipeline?Define
The text Hello, world! with an arrow into a box labelled tokenizer, then an arrow into a dashed box with a question mark, with a note about the gap between the sentence and a list of numbers.
The Problem section's own example sentence. The dashed box is what the question asks about.

The Concept

Three Approaches That Failed (and One That Won)

There are three obvious ways to convert text to numbers. Two of them break down at scale.

Word-level tokenization splits on spaces and punctuation. "The cat sat" becomes ["The", "cat", "sat"]. It is simple, but what about "tokenization"? Or "GPT-4o"? Or a German compound word like "Geschwindigkeitsbegrenzung"? Word-level needs a huge vocabulary to cover every word in every language. Miss a word and you get the [UNK] token, the model's way of saying "I have no idea what this is." Add code, URLs, scientific notation, and many other languages, and no fixed vocabulary keeps up.

Character-level tokenization goes the other direction. "hello" becomes ["h", "e", "l", "l", "o"]. The vocabulary is tiny (a few hundred characters) and there are no unknown tokens. The cost is sequence length. A sentence that is 10 word-level tokens can run to 50 character-level tokens, and the model has to learn that "t", "h", "e" together mean "the", which spends attention on something a word-level model gets for free.

Subword tokenization sits between the two. Common words stay whole, so "the" is one token. Rare words break into pieces, so "unhappiness" becomes ["un", "happi", "ness"]. The vocabulary stays manageable (30K to 128K tokens), sequences stay short, and unknown tokens mostly go away because any word can be built from smaller pieces.

Nearly every modern LLM uses subword tokenization, including GPT-2, GPT-4, BERT, and Llama 3. The real question is which algorithm.

Mermaid diagram

BPE: Byte Pair Encoding

BPE is a greedy compression algorithm repurposed for tokenization. The whole idea fits on an index card.

Start with individual characters. Count every adjacent pair in the training corpus. Merge the most frequent pair into a new token. Repeat until you reach your target vocabulary size.

Here is BPE running on a tiny corpus with the words "lower", "lowest", and "newest":

Corpus (with word frequencies):
  "lower"  x5
  "lowest" x2
  "newest" x6

Step 0 -- Start with characters:
  l o w e r       (x5)
  l o w e s t     (x2)
  n e w e s t     (x6)

Step 1 -- Count adjacent pairs:
  (e,s): 8    (s,t): 8    (l,o): 7    (o,w): 7
  (w,e): 13   (e,r): 5    (n,e): 6    ...

Step 2 -- Merge most frequent pair (w,e) -> "we":
  l o we r        (x5)
  l o we s t      (x2)
  n e we s t      (x6)

Step 3 -- Recount after the merge. The pairs that matter now:
  (l,o): 7   (o,we): 7   (we,r): 5   (we,s): 8
  (s,t): 8   (n,e): 6    (e,we): 6

Step 4 -- (we,s) and (s,t) tie at 8. Pick the first one seen, (we,s) -> "wes":
  l o we r        (x5)
  l o wes t       (x2)
  n e wes t       (x6)

Step 5 -- Merge (wes,t) -> "west":
  l o we r        (x5)
  l o west        (x2)
  n e west        (x6)

...continue until target vocab size reached.

The merge table is the tokenizer. To encode new text, apply the merges in the order they were learned. The training corpus decides which merges exist, and that choice shapes everything the model sees afterward.

Mermaid diagram

Byte-Level BPE (GPT-2, GPT-3, GPT-4)

Standard BPE operates on Unicode characters. Byte-level BPE operates on raw bytes (0-255), which gives a base vocabulary of exactly 256 and handles any language or encoding without an unknown token.

GPT-2 introduced this approach. The base vocabulary covers every possible byte, and BPE merges build on top of it. OpenAI's tiktoken library implements byte-level BPE with these vocabulary sizes:

  • GPT-2: 50,257 tokens
  • GPT-3.5/GPT-4: ~100,256 tokens (cl100k_base encoding)
  • GPT-4o: 200,019 tokens (o200k_base encoding)

WordPiece (BERT)

WordPiece looks like BPE but picks merges differently. Instead of raw frequency, it maximizes the likelihood of the training data:

BPE merge criterion:      count(A, B)
WordPiece merge criterion: count(AB) / (count(A) * count(B))

BPE asks which pair appears most often. WordPiece asks which pair appears together more often than chance would predict. The two questions produce different vocabularies, because WordPiece favors merges where co-occurrence is surprising and not just frequent.

WordPiece also uses a "##" prefix for continuation subwords:

"unhappiness" -> ["un", "##happi", "##ness"]
"embedding"   -> ["em", "##bed", "##ding"]

The "##" prefix marks a piece that continues the previous token. BERT uses WordPiece with a vocabulary of 30,522 tokens. Not every BERT descendant follows it: RoBERTa, despite the name, uses BPE.

SentencePiece (Llama, T5)

SentencePiece treats the input as a raw stream of Unicode characters, including whitespace. There is no pre-tokenization step and no language-specific rule about word boundaries, so it works on Chinese, Japanese, Thai, and other languages where spaces do not separate words.

SentencePiece supports two algorithms:

  • BPE mode: same merge logic as standard BPE, applied to raw character sequences
  • Unigram mode: starts with a large vocabulary and repeatedly removes the tokens that least affect the overall likelihood. It is BPE run backwards: prune instead of merge.

Llama 2 uses SentencePiece BPE with a vocabulary of 32,000 tokens. T5 uses SentencePiece Unigram, also with 32,000 tokens. Llama 3 switched to a tiktoken-based byte-level BPE tokenizer with 128,256 tokens.

Vocabulary Size Tradeoffs

Vocabulary size is a real engineering decision with measurable consequences.

Mermaid diagram

Take 4,096-dimensional embeddings. With a 128K vocabulary the embedding matrix alone is 128,000 x 4,096 = 524 million parameters. With a 32K vocabulary it is 131 million. The tokenizer choice by itself moves roughly 390 million parameters.

Larger vocabularies also compress text harder. The same English paragraph takes fewer tokens with a 128K vocabulary than with a 32K one, and fewer tokens means fewer forward passes during generation. For a model serving heavy traffic, that is a direct cut in compute cost.

Vocabulary sizes keep growing. GPT-2 used 50,257, GPT-4 uses ~100K, Llama 3 uses 128K, and GPT-4o uses 200K.

ModelVocab SizeTokenizer TypeAvg Tokens per English Word
BERT30,522WordPiece~1.4
GPT-250,257Byte-level BPE~1.3
Llama 232,000SentencePiece BPE~1.4
GPT-4~100,256Byte-level BPE~1.2
Llama 3128,256Byte-level BPE (tiktoken)~1.1
GPT-4o200,019Byte-level BPE~1.0

The Multilingual Tax

Tokenizers trained mostly on English treat other languages badly. Korean text in GPT-2's tokenizer can average 2-3 tokens per word, and Chinese can be worse. A Korean user then gets a context window that holds far less text than an English user's, at the same price per token.

That is one reason Llama 3 quadrupled its vocabulary from 32K to 128K. More tokens for non-English scripts means more even compression across languages.

Check: The Concept0/27
1. What is a token in an LLM's vocabulary?Define
A vocabulary entry card reading id 258, bytes 'he ', with a note that it is 2 characters, not a whole word.
One real entry from a trained BPE vocabulary.
2. What does subword tokenization do differently from word-level tokenization?Define
'the' shown as one piece and 'unhappiness' shown as three pieces, un, happi, ness.
The lesson's own subword example from The Concept section.
3. How does BPE (Byte Pair Encoding) build its vocabulary?Define
Seven pair-count boxes from the toy corpus: (e,s) 8, (s,t) 8, (l,o) 7, (o,w) 7, (w,e) with a question mark, (e,r) 5, (n,e) 6, asking which pair merges first.
Counts from the lesson's own Step 1 table. One count is hidden.
4. What problem does byte-level BPE solve?Define
Four base-vocabulary entries, the letter A, the digit 0, and two raw byte values 195 and 159, with a note that every value 0 to 255 already has an entry before training starts.
From the Byte-Level BPE section: the base vocabulary is all 256 byte values.
5. How does WordPiece pick which pair to merge, and how does it mark a continuation subword?Define
'unhappiness' split into un, ##happi, ##ness, with ## marking a piece that continues the previous one.
WordPiece's example from the lesson's text.
6. What are SentencePiece's two algorithms, and how does Unigram mode differ from BPE mode?Define
Llama 2 using SentencePiece BPE at 32,000 tokens beside T5 using SentencePiece Unigram also at 32,000 tokens.
Model and vocabulary facts from the SentencePiece section.
7. What does a tokenizer's vocabulary size count?Define
Three cards, BERT at 30,522, GPT-2 at 50,257, Llama 3 at 128,256.
Values from the lesson's vocabulary-size table.
8. What does fertility measure for a tokenizer?Define
The German word Geschwindigkeitsbegrenzung mapped by an arrow to a box reading 9 tokens, with a note that this is one word measured by how many tokens it took.
Real tiktoken cl100k_base count from the lesson's own example word.
9. What is the merge table in BPE?Define
Four rows of the merge table: merge 1 makes 'e ', merge 2 makes 'at', merge 3 makes 'he ' from h plus 'e ', merge 4 makes 'the ' from t plus 'he '.
Real output of BPETokenizer.train on the Build It corpus, num_merges=40.
10. What is pre-tokenization?Define
Raw text feeding a box labelled pre-tokenization, then pieces, with a note that subword merges run inside each piece after this step.
Pipeline position of pre-tokenization, from the Key Terms table.
11. Why does character-level tokenization make the model's job harder even though it has no unknown tokens?Explain
Word-level at 10 tokens beside character-level at 50 tokens for the same sentence, with a dashed box asking why this costs the model more.
Token counts from the lesson's own comparison in The Concept.
12. Why can WordPiece and BPE learn different vocabularies from the same corpus?Explain
BPE labelled criterion A beside WordPiece labelled criterion B, with a note that different criteria on the same corpus give different vocabularies.
Setup only. The two criteria are what the question asks about.
13. Why does SentencePiece work on Chinese, Japanese and Thai without extra rules?Explain
A Thai greeting with no spaces between its characters, and a note that a split-on-spaces rule finds nothing to split on.
A real Thai word used as an example of a script with no word-separating spaces.
14. Why does vocabulary size create a tradeoff in LLM design?Explain
A 256-byte vocabulary turning 'hello' into 5 tokens beside a 200,000-entry vocabulary turning it into 1 token, with a note comparing the two embedding matrix sizes.
Illustrative token counts for 'hello' at two vocabulary sizes; matrix note is the lesson's own arithmetic pattern.
15. Why does a larger vocabulary cut compute cost when serving heavy traffic?Explain
A table with GPT-2 at 50,257 vocabulary and about 1.3 tokens per word, and GPT-4o at 200,019 vocabulary and about 1.0 tokens per word, above a dashed box asking what fewer tokens per word changes.
Rows from the lesson's own Vocabulary Size Tradeoffs table.
16. How does the tokenizer affect non-English language performance in LLMs?Explain
GPT-2's tokenizer averaging about 1.3 tokens per word on English beside 2 to 3 tokens per word on Korean, with a note that both come from the same training process.
Figures from the lesson's vocabulary table and Multilingual Tax section.
17. Of GPT-2 (50K vocabulary), GPT-4 (~100K) and GPT-4o (200K), which should need the fewest tokens for the same English paragraph, and why?Explain
Three bars for GPT-2 at 50,257, GPT-4 at 100,256, and GPT-4o at 200,019, with a dashed box asking tokens per word.
Vocabulary sizes from the lesson's own table. The dashed box is what the question asks about.
18. In the corpus 'lower' x5, 'lowest' x2, 'newest' x6, how many times does the adjacent pair (w,e) occur?Apply
The words lower (x5), lowest (x2) and newest (x6) each spelled out letter by letter with their frequency, asking how many times the pair (w,e) occurs.
The lesson's own toy corpus, spelled out letter by letter.
19. In that corpus, (w,e) is merged to 'we', then (we,s) to 'wes'. What does 'newest' look like after these two merges?Apply
The word newest spelled n e w e s t, with merge 1 turning (w,e) into 'we' and merge 2 turning (we,s) into 'wes' given as rules, and a dashed box asking what newest looks like now.
The two merge rules are given in the stem. The dashed box is what the question asks about.
20. At 4,096 dimensions, moving from a 32K to a 128K vocabulary changes the embedding matrix by about how many parameters?Apply
A small bar for a 32K vocabulary beside a much larger bar for a 128K vocabulary, both at 4,096 dimensions, with a dashed box asking for the difference.
Bar areas are illustrative of relative size. The dashed box is what the question asks about.
21. A word-level model prints [UNK] for 'GPT-4o' and a long German compound. Which change fixes this class of failure without needing a gigantic vocabulary?Analyze
'GPT-4o' and a long German compound word both producing UNK under a word-level tokenizer, with a dashed box asking for a fix that avoids a gigantic vocabulary.
Both words are named in the lesson's own word-level weaknesses. The dashed box is what the question asks about.
22. A teammate says RoBERTa must use WordPiece because it descends from BERT. What is wrong with that assumption?Analyze
BERT using WordPiece with a dashed line down to RoBERTa with its tokenizer unknown, next to a teammate's claim that RoBERTa must use WordPiece too.
The teammate's claim is the stem's setup. The dashed box is what the question asks about.
23. A Korean user and an English user pay the same price per token on a GPT-2-style tokenizer, and Korean averages 2-3 tokens per word. What follows?Analyze
An English user at about 1 token per word beside a Korean user at 2 to 3 tokens per word, both billed at the same price per token, with a dashed box asking what follows for the Korean user.
Fertility figures from the lesson's own table and Multilingual Tax section. The dashed box is what the question asks about.
24. A Korean-language chatbot runs on a tokenizer trained mostly on English, and users report long conversations getting cut off early. What is the most likely cause and fix?Analyze
A Korean chat transcript getting cut off early, with a dashed box asking for the most likely cause and fix.
Setup only. The dashed box is what the question asks about.
25. You must tokenize Thai and Japanese, where spaces do not separate words, without writing per-language rules and while keeping sequences short. Which fits?Design
A Japanese greeting with no spaces between its characters, a requirement of no per-language rules and short sequences, and a dashed box asking which design fits.
A real Japanese greeting used as an example of a script with no word-separating spaces. The dashed box is what the question asks about.
26. You are building a tokenizer for a code-heavy model whose users paste emoji, rare scripts and URLs. You need a guarantee of no unknown tokens. Which design fits?Design
A rocket emoji shown as four raw byte values, 240, 159, 154, 128, with a dashed box asking which design guarantees no unknown token.
Real UTF-8 byte values for the emoji. The dashed box is what the question asks about.
27. Your model serves heavy English traffic and you can spare memory. Which vocabulary decision does the tradeoff favor?Design
The lesson's own table of GPT-2, GPT-4 and GPT-4o vocabulary sizes and tokens per word, above a dashed box asking which way the tradeoff points.
Rows from the lesson's own Vocabulary Size Tradeoffs table. The dashed box is what the question asks about.

Build It

Step 1: Character-Level Tokenizer

Start at the foundation. A character-level tokenizer maps each character to its Unicode code point. It needs no training and has no unknown tokens.

python
class CharTokenizer:
    def encode(self, text):
        return [ord(c) for c in text]
 
    def decode(self, tokens):
        return "".join(chr(t) for t in tokens)

"hello" becomes [104, 101, 108, 108, 111]. Every character is its own token, and that is the baseline we improve on.

Step 2: BPE Tokenizer from Scratch

Now the real thing. We train on raw bytes (like GPT-2), count pairs, merge the most frequent, and record every merge in order. The merge table is the tokenizer.

python
from collections import Counter
 
class BPETokenizer:
    def __init__(self):
        self.merges = {}
        self.vocab = {}
 
    def _get_pairs(self, tokens):
        pairs = Counter()
        for i in range(len(tokens) - 1):
            pairs[(tokens[i], tokens[i + 1])] += 1
        return pairs
 
    def _merge_pair(self, tokens, pair, new_token):
        merged = []
        i = 0
        while i < len(tokens):
            if i < len(tokens) - 1 and tokens[i] == pair[0] and tokens[i + 1] == pair[1]:
                merged.append(new_token)
                i += 2
            else:
                merged.append(tokens[i])
                i += 1
        return merged
 
    def train(self, text, num_merges):
        tokens = list(text.encode("utf-8"))
        self.vocab = {i: bytes([i]) for i in range(256)}
 
        for i in range(num_merges):
            pairs = self._get_pairs(tokens)
            if not pairs:
                break
            best_pair = max(pairs, key=pairs.get)
            new_token = 256 + i
            tokens = self._merge_pair(tokens, best_pair, new_token)
            self.merges[best_pair] = new_token
            self.vocab[new_token] = self.vocab[best_pair[0]] + self.vocab[best_pair[1]]
 
        return self
 
    def encode(self, text):
        tokens = list(text.encode("utf-8"))
        for pair, new_token in self.merges.items():
            tokens = self._merge_pair(tokens, pair, new_token)
        return tokens
 
    def decode(self, tokens):
        byte_sequence = b"".join(self.vocab[t] for t in tokens)
        return byte_sequence.decode("utf-8", errors="replace")

The training loop is the core of BPE: count pairs, merge the winner, repeat. Each merge shrinks the token list, and after num_merges rounds the vocabulary has grown from 256 base bytes to 256 + num_merges.

Encoding applies merges in the exact order they were learned, and the order matters. On the corpus above, the third merge builds "he " and the fourth builds "the " from "t" + "he ". Encoding has to apply the third merge first, or the fourth has nothing to build on.

Decoding runs the other way. Look up each token ID in the vocabulary, concatenate the bytes, and decode them as UTF-8.

Step 3: Encode and Decode Roundtrip

python
corpus = (
    "The cat sat on the mat. The cat ate the rat. "
    "The dog sat on the log. The dog ate the frog. "
    "Natural language processing is the study of how computers "
    "understand and generate human language. "
    "Tokenization is the first step in any NLP pipeline."
)
 
tokenizer = BPETokenizer()
tokenizer.train(corpus, num_merges=40)
 
test_sentences = [
    "The cat sat on the mat.",
    "Natural language processing",
    "tokenization pipeline",
    "unhappiness",
]
 
for sentence in test_sentences:
    encoded = tokenizer.encode(sentence)
    decoded = tokenizer.decode(encoded)
    raw_bytes = len(sentence.encode("utf-8"))
    ratio = len(encoded) / raw_bytes
    print(f"'{sentence}'")
    print(f"  Tokens: {len(encoded)} (from {raw_bytes} bytes) -- ratio: {ratio:.2f}")
    print(f"  Roundtrip: {'PASS' if decoded == sentence else 'FAIL'}")

The compression ratio tells you how well the tokenizer is doing. A ratio of 0.50 means it produced half as many tokens as raw bytes, and lower is better. On the training corpus the ratio will look good. On text the corpus never saw, like "unhappiness", it will be worse, because unseen patterns fall back to raw bytes.

Step 4: Compare with tiktoken

python
import tiktoken
 
enc = tiktoken.get_encoding("cl100k_base")
 
texts = [
    "The cat sat on the mat.",
    "unhappiness",
    "Hello, world!",
    "def fibonacci(n): return n if n < 2 else fibonacci(n-1) + fibonacci(n-2)",
    "Geschwindigkeitsbegrenzung",
]
 
for text in texts:
    our_tokens = tokenizer.encode(text)
    tiktoken_tokens = enc.encode(text)
    tiktoken_pieces = [enc.decode([t]) for t in tiktoken_tokens]
    print(f"'{text}'")
    print(f"  Our BPE:   {len(our_tokens)} tokens")
    print(f"  tiktoken:  {len(tiktoken_tokens)} tokens -> {tiktoken_pieces}")

tiktoken uses the same core algorithm, trained on a vastly larger corpus with roughly 100K merges. The difference is the training data and the number of merges. A tokenizer trained on one paragraph with 40 merges will not keep up with that, but the mechanism is the same.

Step 5: Vocabulary Analysis

python
def analyze_vocabulary(tokenizer, test_texts):
    total_tokens = 0
    total_chars = 0
    token_usage = Counter()
 
    for text in test_texts:
        encoded = tokenizer.encode(text)
        total_tokens += len(encoded)
        total_chars += len(text)
        for t in encoded:
            token_usage[t] += 1
 
    print(f"Vocabulary size: {len(tokenizer.vocab)}")
    print(f"Total tokens across all texts: {total_tokens}")
    print(f"Total characters: {total_chars}")
    print(f"Avg tokens per character: {total_tokens / total_chars:.2f}")
 
    print(f"\nMost used tokens:")
    for token_id, count in token_usage.most_common(10):
        token_bytes = tokenizer.vocab[token_id]
        display = token_bytes.decode("utf-8", errors="replace")
        print(f"  Token {token_id:4d}: '{display}' (used {count} times)")
 
    unused = [t for t in tokenizer.vocab if t not in token_usage]
    print(f"\nUnused tokens: {len(unused)} out of {len(tokenizer.vocab)}")

This shows the Zipf-style skew in your vocabulary. A few tokens dominate, starting with the space, and most tokens are rarely used. In this code, the earliest merges get the lowest new IDs, so the patterns learned first are the ones that were most frequent.

Check: Build It0/13
1. How is compression ratio defined, and which direction is better?Define
Two sentences with their real token and byte counts from main.py: 7 tokens from 23 bytes, and 10 tokens from 11 bytes.
Real figures from main.py's Step 3 output.
2. Why must a BPE encoder apply learned merges in the order they were created?Explain
Merge 3 turning h plus 'e ' into 'he ', and merge 4 turning t plus 'he ' into 'the ', with the question must these run in exactly this order.
Real merges from BPETokenizer.train on the Build It corpus.
3. What is the practical difference between the scratch BPETokenizer in Build It and tiktoken?Explain
Our BPETokenizer in Python on a roughly 230 byte corpus with 40 merges beside tiktoken's cl100k_base with its language, corpus and merge count unknown, asking whether the algorithm is the same.
Our corpus size and merge count are real. tiktoken's details are what the question asks about.
4. After BPETokenizer().train(corpus, num_merges=40) on the Build It corpus, how many entries does tokenizer.vocab hold?Apply
256 base byte entries before training, next to the call BPETokenizer().train(corpus, num_merges=40), and a dashed box asking how many entries tokenizer.vocab has.
256 base bytes is given. The dashed box is what the question asks about.
5. In BPETokenizer.encode, what happens to the text 'the ' if the fourth merge (t + 'he ') runs before the third merge has created 'he '?Apply
Merge 3 not yet run, so 'he ' does not exist, while merge 4 tries to combine t with 'he ', and a dashed box asking what happens to 'the '.
The hypothetical ordering is given in the stem. The dashed box is what the question asks about.
6. On a freshly trained BPETokenizer, what does decode([72, 105]) return?Apply
Vocabulary entries for id 72 holding the byte H and id 105 holding the byte i, with a dashed box asking what decode of 72, 105 equals.
Base-byte vocabulary facts (id = the byte's own value). The dashed box is what the question asks about.
7. A sentence is 24 bytes and encodes to 12 tokens. Compared with 'unhappiness' (10 tokens from 11 bytes, ratio 0.91), which is true?Apply
The baseline 'unhappiness' at 10 tokens from 11 bytes, ratio 0.91, beside a new sentence at 12 tokens from 24 bytes with its ratio unknown.
The baseline figures are given in the stem. The dashed ratio is what the question asks about.
8. In main.py, what does the roundtrip check print for 'unhappiness', which the training corpus never contains?Apply
'unhappiness' feeding into an arrow toward a dashed box reading Roundtrip, with a question mark, noting the training corpus never contains this word.
The dashed box is what the question asks about.
9. In main.py's vocabulary analysis on the tiny corpus, which tokens are the most used?Apply
A table with vocabulary size 306, total unique tokens used 73, unused tokens 233, above a dashed box asking which tokens are most used.
Real stats from main.py's demo_vocabulary_analysis. The dashed box is what the question asks about.
10. bpe.rs picks the last maximum from a HashMap with random iteration order. What can happen on two runs over the same corpus?Apply
Python's max over pairs beside Rust's max_by_key over a HashMap of pairs, with a dashed box asking whether two runs on the same corpus give the same result.
Code excerpted from bpe.py and bpe.rs. The dashed box is what the question asks about.
11. What does the timing loop over 100K encodes in bpe.rs measure?Apply
The real loop from bpe.rs calling tokenizer.encode 100,000 times, beside a dashed box asking what it measures.
Real code from bpe.rs. The dashed box is what the question asks about.
12. Your scratch BPE shows tokens where a space is glued to the next word, and merges span word boundaries. Which missing step explains this?Analyze
A real merge, space plus l, producing ' l', a space fused onto the start of the next word, beside a dashed box asking what step is missing.
Real merge from BPETokenizer.train on the Build It corpus. The dashed box is what the question asks about.
13. You trained scratch BPE on one paragraph with 40 merges. On production text it gives ratios near 0.9, mostly single bytes. What is the most likely cause?Analyze
A tokenizer trained on one roughly 230 byte paragraph with 40 merges, then run on production text at a ratio near 0.9, with a dashed box asking why it is mostly single bytes.
Corpus size and merge count match the Build It corpus. The dashed box is what the question asks about.

Use It

Your scratch BPE works. Here is what the production tools look like.

tiktoken (OpenAI)

python
import tiktoken
 
enc = tiktoken.get_encoding("cl100k_base")
 
text = "Tokenizers convert text to integers"
tokens = enc.encode(text)
print(f"Tokens: {tokens}")
print(f"Pieces: {[enc.decode([t]) for t in tokens]}")
print(f"Roundtrip: {enc.decode(tokens)}")

tiktoken is written in Rust with Python bindings. It runs the same BPE algorithm with an implementation built for speed.

Hugging Face tokenizers

python
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import ByteLevel
 
tokenizer = Tokenizer(BPE())
tokenizer.pre_tokenizer = ByteLevel()
 
trainer = BpeTrainer(vocab_size=1000, special_tokens=["<pad>", "<eos>", "<unk>"])
tokenizer.train(["corpus.txt"], trainer)
 
output = tokenizer.encode("The cat sat on the mat.")
print(f"Tokens: {output.tokens}")
print(f"IDs: {output.ids}")

The Hugging Face tokenizers library is also Rust under the hood, and it is built to train BPE on large corpora quickly. Reach for it when you train your own model.

Loading Llama's Tokenizer

python
from transformers import AutoTokenizer
 
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B")
 
text = "Tokenizers are the unsung heroes of LLMs"
tokens = tokenizer.encode(text)
print(f"Token IDs: {tokens}")
print(f"Tokens: {tokenizer.convert_ids_to_tokens(tokens)}")
print(f"Vocab size: {tokenizer.vocab_size}")
 
multilingual = ["Hello world", "Hola mundo", "Bonjour le monde"]
for text in multilingual:
    ids = tokenizer.encode(text)
    print(f"'{text}' -> {len(ids)} tokens")

Llama 3's 128K vocabulary should compress non-English text better than GPT-2's 50K vocabulary. Check it yourself by encoding the same sentence in several languages and counting the tokens.

Check: Use It0/1
1. You need to train a BPE tokenizer on a multi-gigabyte corpus, and the scratch code recounts every pair on every merge. What do you use?Design
The real train loop from bpe.py recounting every pair on every merge, beside a dashed box asking which tool to use for a multi-gigabyte corpus.
Code excerpted from bpe.py's train method. The dashed box is what the question asks about.

Ship It

This lesson produces outputs/prompt-tokenizer-analyzer.md, a reusable prompt that analyzes tokenization efficiency for a text and model combination. Feed it a text sample and it tells you which model's tokenizer handles it best.

Author's Note

I'd run main.py before reading the BPE code again. It prints a Roundtrip: PASS or FAIL line for every sentence, and "unhappiness" passes even though the corpus never contains it. That works because the base vocabulary is all 256 bytes, so nothing is ever out of vocabulary. It just compresses badly: in my run of main.py that word comes out as 10 tokens from 11 bytes, a ratio of 0.91.

My bet is that encode is the part worth staring at. It loops over self.merges in insertion order and replays each merge across the whole token list, so the dict's ordering is the tokenizer. On the Build It corpus, the fourth merge builds "the " from "t" + "he ", which only exists because the third merge built "he ". Training also recounts every pair from scratch on every merge, and encoding makes one full pass per merge. That is fine for a paragraph and wrong for real data.

Two things are missing, and both matter more than they look. There is no pre-tokenization, so merges can cross word boundaries and a space can fuse onto the next word. Ties are also broken by max, which keeps the first pair it saw, so the merge order depends on where a pair first appears in the text. On this tiny corpus the merges are thin too: in the main.py vocabulary analysis, the ten most used tokens are all single bytes, led by the space. bpe.rs ports the same loop to Rust and adds a timing loop over 100K encodes. It breaks ties differently, though. It takes the last maximum from a HashMap with random iteration order, so merge order can change between runs. I'd treat it as a way to measure the cost, not a fix for it.

Exercises

  1. Modify the BPE tokenizer to print the vocabulary at each merge step. Watch how "h" + "e " becomes "he ", then "t" + "he " becomes "the ". Track how common English words get assembled piece by piece.

  2. Add special tokens (<pad>, <eos>, <unk>) to the BPE tokenizer. Assign them IDs 0, 1, 2 and shift all other tokens accordingly. Implement a pre-tokenization step that splits on whitespace before running BPE.

  3. Implement the WordPiece merge criterion (likelihood ratio instead of frequency). Train both BPE and WordPiece on the same corpus with the same number of merges. Compare the resulting vocabularies. Which one produces more linguistically meaningful subwords?

  4. Build a multilingual tokenizer efficiency benchmark. Take 10 sentences in English, Spanish, Chinese, Korean, and Arabic. Tokenize each with tiktoken (cl100k_base) and measure the average tokens per character. Quantify the "multilingual tax" for each language.

  5. Train your BPE tokenizer on a larger corpus (download a Wikipedia article). Tune the number of merges to achieve a compression ratio within 10% of tiktoken on that same text. This forces you to understand the relationship between corpus size, merge count, and compression quality.

Key Terms

TermWhat people sayWhat it actually means
Token"A word"A unit in the model's vocabulary -- could be a character, subword, word, or multi-word chunk
BPE"Some compression thing"Byte Pair Encoding -- iteratively merge the most frequent adjacent pair of tokens until the target vocabulary size is reached
WordPiece"BERT's tokenizer"Like BPE but merges maximize the likelihood ratio count(AB)/(count(A)*count(B)) instead of raw frequency
SentencePiece"A tokenizer library"A language-agnostic tokenizer that operates on raw Unicode without pre-tokenization, supporting BPE and Unigram algorithms
Vocabulary size"How many words it knows"The total number of unique tokens: GPT-2 has 50,257, BERT has 30,522, Llama 3 has 128,256
Fertility"Not a tokenizer term"Average number of tokens per word -- measures tokenizer efficiency across languages (1.0 is perfect, 3.0 means the model works three times harder)
Byte-level BPE"GPT's tokenizer"BPE operating on raw bytes (0-255) instead of Unicode characters, guaranteeing no unknown tokens for any input
Merge table"The tokenizer file"Ordered list of pair merges learned during training -- this IS the tokenizer, and order matters
Pre-tokenization"Splitting on spaces"Rules applied before subword tokenization: whitespace splitting, digit separation, punctuation handling
Compression ratio"How efficient the tokenizer is"Tokens produced divided by input bytes -- lower means better compression and faster inference

Further Reading

Code

Ships With