TAI BUI
Back to curriculum
CapstonePython

BPE Tokenizer From Scratch

Bytes in, ids out, ids back to the same bytes. Build the tokenizer that every modern text model still starts from.

TIME: ~90 minutesPREREQUISITES: Phase 04 lessons, Phase 07 transformer lessons

Learning Objectives

  • Train a Byte-Pair Encoding vocabulary from a raw text corpus by repeatedly merging the most frequent adjacent symbol pair.
  • Implement a deterministic merge table and apply it to fresh text to produce a stream of subword ids.
  • Round-trip arbitrary UTF-8 input to ids and back without information loss.
  • Reserve and protect special tokens (`<|endoftext|>`, `<|pad|>`) so they survive training and decoding.
  • Reason about why a byte-level alphabet is the right floor for a general-purpose tokenizer.

The frame

A tokenizer is the map from a string to a list of integers and back. A language model never sees text, only those integers, so a mistake in this layer means every loss curve in the training run is measuring the wrong thing.

Byte-Pair Encoding (BPE) is the dominant family of subword tokenizers for general text models. It starts from a known alphabet, finds the adjacent symbol pair that appears most often in the training corpus, and merges that pair into a new symbol. It repeats until the vocabulary reaches the target size. Encoding new text reuses the same merge list in the same order.

We'll build the byte-level variant. The alphabet is the 256 raw bytes, not Unicode code points, which is what lets the tokenizer handle any UTF-8 input without falling back to an unknown token.

Check: The frame0/2
1. What is a tokenizer in a language-model pipeline?Define
2. What is Byte-Pair Encoding (BPE)?Define

The pipeline

Mermaid diagram

Training and inference share one merge table, and that sharing is the contract. Change the merge order at inference and you decode a different stream of ids.

Check: The pipeline0/2
1. What is the merge table in a BPE tokenizer?Define
2. You reorder the saved merge table before running inference with a model trained on the original ids. What happens?Explain

The byte alphabet

The first 256 ids are reserved for the raw bytes 0x00 through 0xFF, so every input string can be expressed in the vocabulary before any merge happens. A small range after the byte block holds the special tokens. The training loop never proposes those ids as merge targets, because we keep them out of the pretokenized stream entirely.

The pretokenizer splits the corpus into runs of non-whitespace and runs of whitespace before training sees it, so punctuation stays attached to its word. Skip that split and the merge step happily learns merges that cross word boundaries (the space between two words included), so the vocabulary fills up with whole common phrases. With the split, merges stay inside a word and the result generalizes.

Check: The byte alphabet0/10
1. What is the byte-level alphabet of this tokenizer?Define
2. What does the pretokenizer do?Define
3. Why does a byte-level BPE tokenizer start from a 256-symbol alphabet?Explain
4. Why can the training loop never propose a special-token id as a merge target?Explain
5. Why does the pretokenizer keep each whitespace run as its own chunk instead of dropping it?Explain
6. What chunks does the pretokenizer produce for the text "hi, there"?Apply
7. A tokenizer is initialized with the two default special tokens. What id does the first learned merge receive?Apply
8. initialize is called twice in a row on one tokenizer with the two default specials. What is vocab_size afterward?Apply
9. You train with a pretokenizer-free loop and the learned vocabulary fills with whole common phrases. What is the likely cause?Analyze
10. A teammate proposes adding an <|unk|> special token to catch words unseen in training. What should you tell them?Design

The training loop

Each training step does three things. It walks every word in the corpus and counts how often each adjacent pair of current symbols appears, weighted by how often the word itself appears. It picks the pair with the highest count. Then it rewrites every occurrence of that pair into a single new symbol, whose id is the next free slot in the vocabulary, and records the merge.

Mermaid diagram

The cost of each step is linear in the size of the corpus expressed as a list of symbol sequences. For a million words and a target vocabulary of ten thousand ids, the loop runs to completion in seconds because the symbol sequences shrink as merges land.

Check: The training loop0/5
1. What does each BPE training step do?Define
2. Why is each pair's count weighted by how often its word appears?Explain
3. A training step merges the pair (1, 2) into new id 300. What does the symbol sequence (1, 2, 1, 2, 3) become?Apply
4. The pair (5, 5) is merged into id 300. What does the sequence (5, 5, 5) become?Apply
5. Pairs (104, 101) and (97, 110) each occur 7 times, the highest count in the corpus. Which merges first?Apply

Encoding fresh text

Encoding applies the merge table in the same order it was learned, and it never calls the pair counter. For a fresh word, the encoder starts from the byte split and scans the current sequence for the lowest-ranked merge, meaning the earliest one that applies. It performs that merge and scans again. The loop ends when no merge in the table applies to the current sequence.

Ordering by rank is what makes encoding deterministic and consistent with training on the same input. A merge learned first sits at the top of the table and gets applied first. If two merges could apply at the same position, the lower-rank one wins.

Check: Encoding fresh text0/2
1. When encoding new text, in what order are merges applied?Define
2. Why does encoding replay the learned merges in rank order instead of recounting pairs on the new text?Explain

Special tokens

Special tokens are reserved ids that the byte stream can never produce. We assign them by hand, and two are enough for this lesson.

  • <|endoftext|> separates documents during pretraining. It tells the model "a new document starts here, do not let the previous one's context leak in."
  • <|pad|> fills out short sequences so a batch can be a rectangular tensor. The loss mask hides it during training.

The encoder takes a flag that allows special tokens in the input. With the flag off, the strings <|endoftext|> and <|pad|> are tokenized as the bytes that spell them out. With the flag on, the literal strings map to their reserved ids and no merge touches them.

Check: Special tokens0/6
1. What is a special token?Define
2. What is the distinct job of <|pad|> compared with <|endoftext|>?Define
3. What does encode return for the string <|endoftext|> when allow_special is False?Apply
4. With allow_special on and the specials holding ids 256 and 257, what does encoding the text <|endoftext|><|pad|> return?Apply
5. A scraped web page that happens to contain the text <|endoftext|> is encoded with allow_special on. What goes wrong?Analyze
6. You pack many documents into one training stream and pad short sequences into rectangular batches. Which setup follows the lesson's design?Design

Round-trip guarantee

Encoding then decoding must return the input bytes exactly. The decoder concatenates the byte expansion of every id in order. Every id is either a raw byte or the concatenation of two previously known ids, so the recursive expansion always terminates in raw bytes, and those bytes spell the UTF-8 string you started with.

The test suite checks this on an unseen sentence, on a sentence with a Unicode emoji, and on a sentence containing a literal <|endoftext|> token.

Check: Round-trip guarantee0/4
1. What does the round-trip guarantee promise?Define
2. Why does the decoder never need an unknown-token id?Explain
3. What does encoding the empty string return?Apply
4. Your only round-trip test is one plain English sentence, and it passes. Which extra inputs would expose bugs that this test cannot?Analyze

What this lesson does not do

There's no regex-driven pretokenizer in the style of the largest production tokenizers here. The pretokenizer is a single \S+|\s+ split on whitespace runs, which is enough to produce sensible merges on a small training corpus, and the contract with the rest of the lesson chain stays the same. The next lesson treats the tokenizer as a black box and builds the sliding-window dataset on top of it.

The pair counter isn't parallelized either. A Python loop over a corpus of a few thousand words finishes in well under a second. For larger corpora, the obvious move is to count pairs per word in parallel and reduce.

Check: What this lesson does not do0/2
1. Why is the single whitespace-run split acceptable as the pretokenizer in this build?Explain
2. Your Python pair counter is too slow once the corpus reaches billions of words. Which change keeps the merge-table contract intact?Design
Author's Note

I'd write the round-trip test before the merge loop and treat it as the spec. A sentence that survives encode then decode means the ids expand back to the exact bytes I started with, and I'd add the emoji sentence and the literal <|endoftext|> sentence early, since those are the inputs a plain unseen sentence won't exercise.

I'd also keep the merge table as the one object training and inference both use. If encoding applies merges in a different order than training learned them, the ids you get change, even though decoding would still hand back the same bytes.

How to read the code

main.py is built around one class and three entry points. BPETokenizer holds the vocabulary, the merge table, and the special-token table. train is the training loop, encode is the inference path, and decode is the byte concatenation. The demo at the bottom trains a small tokenizer on a built-in corpus, encodes a held-out sentence, decodes the ids back, and prints both. The tests in code/tests/test_bpe.py pin the round-trip property, the special-token reservation, and the merge ordering.

Run the demo, then change the demo's target (320 in the file) to a larger value and watch the encoded length of the held-out sentence drop. Training stops early once no pair occurs at least twice, so on the small built-in corpus the final vocabulary can fall short of the target. That curve is the BPE compression curve.

Warm-up0/3
1. A neural language model can only consume numbers. What must happen to raw text before it reaches the model?Warm-up
2. A word-level vocabulary is built from a training corpus. What goes wrong when a word it never saw shows up at test time?Warm-up
3. In UTF-8, how many bytes does one character take?Warm-up
Check: How to read the code0/3
1. What happens to the encoded length of a fixed sentence as the target vocabulary grows?Explain
2. The demo targets a vocabulary of 320 on a tokenizer with 256 bytes and 2 special tokens, and training reaches the target. How many merges were learned?Apply
3. You ask for a target vocabulary of 600 on a tiny corpus, but training ends with a smaller vocabulary. Why can that happen?Analyze

Code