Home โ€บ Blog โ€บ Language Modeling from Scratch, Part 2: Tokenization & Byte-Pair Encoding
CS336 Deep Dive ยท Part 2 of 19 ๐Ÿ”ก

Tokenization & Byte-Pair Encoding

Before a single matrix multiply happens, a language model has to turn text into numbers. This part covers how โ€” the algorithm virtually every frontier model still uses to do it, why it works the way it does, and a full from-scratch Python implementation you can run yourself, matching what Lecture 1 and assignment1-basics actually ask you to build.

FL
FrontierAGI Team
On sourcing: the paper citations below were independently verified (titles, authors, arXiv IDs, venues). The assignment details come from a direct fetch of assignment1-basics's README and its tests/adapters.py file, which together confirm the required function signatures and training corpora โ€” the assignment's actual point-value breakdown and exact vocabulary-size target live in a PDF handout that wasn't fetchable as text, so those specific numbers are flagged as not confirmed rather than guessed. The Python implementation in this article is original code written for this piece โ€” it is not copied from the CS336 assignment starter code or from OpenAI's reference implementation, though it follows the same well-documented algorithm both are built on.

Why Tokenization Exists

A neural network operates on numbers, not characters. The simplest possible mapping โ€” one token per character โ€” makes the vocabulary tiny but the sequences enormous, since even a short sentence becomes dozens of tokens, and the model has to learn "spelling" before it can learn anything about meaning. The opposite extreme โ€” one token per whole word โ€” keeps sequences short but explodes the vocabulary to the size of the language itself, and still fails the moment it meets a word it's never seen (a typo, a name, a word in another language, a brand-new term). Byte-pair encoding is the compromise the field converged on: a vocabulary of subword pieces, built so that common words end up as a single token and rare or unseen words decompose gracefully into smaller, already-known pieces.

Where BPE Comes From

Neural Machine Translation of Rare Words with Subword Units HIGHACL 2016
Rico Sennrich, Barry Haddow, Alexandra Birch โ€” arXiv:1508.07909
Adapts a 1994 data-compression algorithm (Philip Gage's byte-pair encoding, originally designed to compress arbitrary byte sequences by repeatedly replacing the most frequent adjacent pair with a new symbol) to solve the open-vocabulary problem in neural machine translation. Rather than a fixed word vocabulary that fails on rare or unseen words, the model works over a vocabulary of variable-length subword units, letting rare words decompose into combinations of smaller, frequently-seen pieces instead of being replaced with an unknown-word token. The paper reports gains of 1.1 and 1.3 BLEU on WMT15 Englishโ†’German and Englishโ†’Russian respectively.

The core idea, unchanged since 2016: start with a base vocabulary (individual characters, or in the byte-level variant below, individual bytes), then repeatedly find the most frequent adjacent pair of symbols across your training corpus and merge it into one new symbol. Do this enough times and you end up with a vocabulary where common patterns โ€” whole words, common prefixes and suffixes, frequent punctuation sequences โ€” collapse into single tokens, while everything else still decomposes into pieces the model has actually seen before.

Byte-Level BPE: GPT-2's Contribution

Language Models are Unsupervised Multitask Learners HIGHOpenAI, Feb 2019
Alec Radford, Jeffrey Wu, Rewon Child, David Luan, Dario Amodei, Ilya Sutskever โ€” OpenAI technical report (not an arXiv preprint)
The GPT-2 paper's tokenization contribution, easy to overlook next to its headline results: instead of running BPE over Unicode characters, run it over raw UTF-8 bytes. Since any string can be represented as a sequence of bytes, and there are only 256 possible byte values, this gives a base vocabulary of exactly 256 symbols that can represent literally any input text โ€” no unknown-token fallback is ever needed, for any language, emoji, or malformed input. GPT-2's full vocabulary after merging reaches 50,257 tokens.
Why bytes instead of characters matters in practice: a character-level vocabulary still has to decide what counts as a "character" โ€” do you include every Unicode code point, including ones your training corpus never contained? Byte-level BPE sidesteps the question entirely. There are exactly 256 possible bytes, full stop, so the base vocabulary is fixed and complete before you've even looked at any training data. This is the version essentially every modern open and closed frontier model tokenizer builds on, including the tokenizer this series' own assignment asks you to implement.

Training a BPE Tokenizer: Learning Merges

"Training" a BPE tokenizer doesn't involve gradient descent โ€” it's a deterministic, count-based algorithm run once over a text corpus, producing two artifacts: a vocabulary (a mapping from integer token IDs to byte sequences) and an ordered list of merges (which pairs got combined, and in what order). The algorithm:

1
Start from the base vocabulary. All 256 possible byte values, each its own token. Represent every word in the training corpus as a sequence of these byte-tokens.
2
Count adjacent pairs. Across the entire corpus, count how often each pair of adjacent tokens occurs.
3
Merge the most frequent pair. Create a new token representing that pair, add it to the vocabulary, and replace every occurrence of that pair in the corpus with the new merged token.
4
Repeat until you hit your target vocabulary size. Each merge grows the vocabulary by exactly one token, so the number of merges you run is simply target vocabulary size minus the base vocabulary size (minus any reserved special tokens).

From-Scratch Implementation

Here's a complete, original implementation of BPE training โ€” readable over fast, deliberately, since the point of building it yourself is understanding the algorithm, not winning a speed benchmark (that comes later, with tiktoken):

# Original from-scratch BPE trainer for this series.
# Not copied from any CS336 assignment file or OpenAI's encoder.py --
# written independently to illustrate the algorithm clearly.

from collections import Counter

def get_pair_counts(word_freqs):
    """Count frequency of every adjacent token pair across all words."""
    pair_counts = Counter()
    for word, freq in word_freqs.items():
        for i in range(len(word) - 1):
            pair_counts[(word[i], word[i + 1])] += freq
    return pair_counts

def merge_pair(word_freqs, pair):
    """Replace every occurrence of `pair` with a single merged token."""
    merged_token = pair[0] + pair[1]
    new_word_freqs = {}
    for word, freq in word_freqs.items():
        new_word = []
        i = 0
        while i < len(word):
            if i < len(word) - 1 and (word[i], word[i + 1]) == pair:
                new_word.append(merged_token)
                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_words, vocab_size, num_base_bytes=256):
    """
    corpus_words: dict mapping each word (as bytes) -> frequency in the corpus
    vocab_size:   target vocabulary size (base bytes + merges)
    Returns:      list of merges, in the order they were learned
    """
    # Represent each word as a tuple of single-byte tokens to start
    word_freqs = {
        tuple(bytes([b]) for b in word): freq
        for word, freq in corpus_words.items()
    }

    num_merges = vocab_size - num_base_bytes
    merges = []

    for _ in range(num_merges):
        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(word_freqs, best_pair)
        merges.append(best_pair)

    return merges

# --- try it on a tiny toy corpus ---
corpus = {
    b"low": 5, b"lower": 2, b"newest": 6, b"widest": 3,
}
learned_merges = train_bpe(corpus, vocab_size=256 + 10)
for i, (a, b) in enumerate(learned_merges):
    print(f"merge {i}: {a!r} + {b!r} -> {a+b!r}")
On this toy corpus, the first few merges typically combine e+s (from "newest"/"widest"), then es+t, then l+o (from "low"/"lower") โ€” the algorithm finds the shared subword structure purely from co-occurrence counts, with no linguistic rules built in.

Encoding and Decoding

Training gives you a vocabulary and an ordered merge list. Using that tokenizer on new text is a separate, much cheaper operation: apply the learned merges, in the exact order they were learned, to any new input.

# Original encode/decode implementation, built on the vocabulary
# and merges produced by train_bpe() above.

def encode(text, merges):
    """Turn a string into a list of byte-string tokens, applying merges in order."""
    word = tuple(bytes([b]) for b in text.encode("utf-8"))

    for pair in merges:  # merges must be applied in learned order
        new_word = []
        i = 0
        while i < len(word):
            if i < len(word) - 1 and (word[i], word[i + 1]) == pair:
                new_word.append(word[i] + word[i + 1])
                i += 2
            else:
                new_word.append(word[i])
                i += 1
        word = tuple(new_word)

    return list(word)

def decode(tokens):
    """Tokens are byte strings -- concatenate and decode back to text."""
    return b"".join(tokens).decode("utf-8", errors="replace")

# --- round-trip check ---
tokens = encode("lowest", learned_merges)
print("tokens:", tokens)
print("round-trip:", decode(tokens) == "lowest")
Note that "lowest" never appeared in the toy training corpus above, yet it still encodes and decodes correctly โ€” it decomposes into pieces the tokenizer already learned from "low," "lower," and "newest"/"widest," which is the entire point of subword tokenization: graceful handling of words the tokenizer never directly saw.
A subtlety worth being deliberate about: merge order matters during encoding, not just training. If two different pairs could both apply to a piece of text, you have to apply merges in the exact sequence they were learned โ€” not, say, by re-finding the "most frequent pair in this specific input," which would need a return trip through the whole training corpus's statistics and defeat the purpose of having a fixed, reusable tokenizer at all.

What assignment1-basics Actually Asks

The public assignment1-basics repository's test harness (tests/adapters.py) confirms two required functions matching exactly the two halves covered above: run_train_bpe(input_path, vocab_size, special_tokens), which returns a vocabulary and merge list from a raw text corpus, and get_tokenizer(vocab, merges, special_tokens), which returns a tokenizer object implementing encode/decode using those learned merges. The README confirms the assignment trains on real corpora, not toy data โ€” it includes direct download commands for the TinyStories dataset (a corpus of simple, short children's stories, commonly used for small-model experiments) and a Stanford-hosted subsample of OpenWebText. The specific target vocabulary size isn't stated in the README or test file โ€” that detail lives in a PDF assignment handout not fetchable as plain text in this research pass, so this piece won't state a specific number as fact.

Where the toy implementation above would need to grow up for the real assignment: the naive approach shown here re-scans every word for pair counts on every single merge iteration, which is far too slow for a real corpus with millions of words and thousands of merges. A production-grade implementation (and almost certainly what the assignment expects for full credit) maintains an incrementally-updated priority structure โ€” recomputing counts only for pairs affected by the most recent merge, rather than rescanning everything from scratch each time.

tiktoken and Production Tokenizers

tiktoken HIGHOpenAI, open source
OpenAI's production BPE tokenizer library, with its core merge and encoding loop implemented in Rust and called from Python โ€” reported to run roughly 3โ€“6x faster than comparable pure-Python BPE implementations, largely by avoiding Python's GIL-bound inner loop that the toy implementation above runs directly. This is the gap between "an implementation that teaches you the algorithm" (what this article and the assignment are for) and "an implementation you'd actually put in a training pipeline processing billions of tokens."

If you want to see a real, battle-tested reference implementation of byte-level BPE encoding logic specifically (including the byte-to-unicode lookup table trick GPT-2 uses to make raw bytes printable for debugging), OpenAI's original GPT-2 repository's encoder.py is the commonly-cited canonical source โ€” worth reading after you've built your own version above, as a way to compare your understanding against a production reference rather than as a starting point to copy from.

โš ๏ธ Confidence Notes and Gaps

The Sennrich et al. and GPT-2 paper citations are high confidence, independently verified against arXiv/ACL Anthology and OpenAI's own hosted PDF respectively. The assignment1-basics details (required function signatures, training corpora) are high confidence, confirmed by direct fetch of the repository's own README and test file. The specific target vocabulary size for the assignment was not confirmed โ€” it lives in a PDF handout not fetchable as plain text in this research pass, and this article deliberately doesn't state a number it couldn't verify. The tiktoken speed comparison (3โ€“6x) should be treated as a general, reported figure rather than a benchmark independently reproduced here.
Continuing the series? Part 3 โ€” PyTorch & Resource Accounting covers FLOPs, memory, and arithmetic intensity hands-on with assignment1-basics, before Part 4 โ€” Architectures & Hyperparameters covers what changed since the original 2017 Transformer โ€” Pre-LN, RMSNorm, RoPE, and SwiGLU.

๐Ÿ”— Full Reference List