Skip to main content
Milestone #08Not StartedPhase 4Advanced14–18 hours · 1–2 weeks14 required tasks

BPE Tokenizer

Implement a trainable byte-level Byte Pair Encoding tokenizer from scratch in Python

PythonUnicode / UTF-8
Build Progress0 / 14 tasks (0%)
0%(0/14 steps)Not Started
Project Specification & Architecture

What You Will Build

Large Language Models do not read raw characters or whitespace-split words; they read subword tokens produced by Byte Pair Encoding (BPE). Starting from a base vocabulary of the 256 raw UTF-8 byte values (`0..255`), byte-level BPE iteratively counts adjacent token pairs across a training corpus, mints a new token ID (`256, 257, ...`) for the most frequent pair, and records the merge rule `(p0, p1) -> new_id`. Because every UTF-8 string is a sequence of bytes, a byte-level BPE tokenizer can encode and decode ANY text—including multilingual scripts, source code indentation, math symbols, and emojis—with zero `<unk>` out-of-vocabulary failures. In this project, you will implement a standalone, trainable BPE tokenizer from scratch in pure Python.

Why This Project Matters in the AI/LLM Stack

Tokenization directly governs an LLM's context window efficiency, arithmetic behavior, code formatting, and multilingual token fertility. Furthermore, you will use your custom BPE tokenizer directly in Project 11 to tokenize the pretraining corpus for `MyLLM v1`.

Learning Objectives

  • Convert arbitrary Unicode strings into raw UTF-8 byte sequences (`0..255`) and explain why byte-level BPE eliminates `<unk>` tokens.
  • Implement pair frequency counting, iterative merge execution, and vocabulary construction (`vocab: dict[int, bytes]` and `merges: dict[tuple[int, int], int]`).
  • Implement deterministic `.encode(text)` (applying learned merges in priority order) and `.decode(ids)` (concatenating byte chunks and decoding UTF-8 safely).
  • Support regex pre-tokenization splits and special control tokens (`<|endoftext|>`, `<|pad|>`) and verify lossless round-trip encoding/decoding across diverse inputs.

Prerequisites

  • Python `str.encode('utf-8')`, `bytes.decode('utf-8', errors='replace')`, dictionaries, and regular expressions
  • Phase 4 Module 4.1 & 4.2 (Tokenization & Subword Vocabulary Construction)

Required Mathematical Concepts

  • UTF-8 variable-length (1 to 4 bytes) character encoding
  • Greedy iterative pair frequency maximization: (a*, b*) = argmax_{(a,b)} count(a, b)
  • Compression ratio (bytes per token) and token fertility (tokens per word)
  • Priority ordering of merge rules during deterministic encoding
Interactive Engineering Checklist

Step-by-Step Implementation Tasks (14 Steps)

Check off each engineering task as your code passes its acceptance criteria. Progress is saved automatically in your browser.

Stage 1 of 30/6 tasks complete

UTF-8 Byte Representation & Pair Frequency Counting

Convert text to UTF-8 byte integers (`0..255`), count adjacent pairs, and implement the single-step pair replacement primitive.

Core

Python strings are sequences of Unicode code points (`ord(ch)`), while `.encode('utf-8')` maps them into raw bytes (`0..255`).

Implementation: Write an exploratory script/module demonstrating how ASCII characters use 1 UTF-8 byte while accented letters, CJK characters, and emojis use 2–4 bytes, and why a raw Unicode code-point vocabulary would exceed 150,000 entries.
Expected Outcome: Helper functions converting `str -> list[int]` (`list(text.encode('utf-8'))`) and `bytes -> str`.
Acceptance Check: Correctly handles multi-byte UTF-8 sequences like `'Hello 世界 🚀'`.
Core

Training a representative BPE merge table requires a corpus containing prose, punctuation, numbers, and code or dialogue.

Implementation: Prepare a training corpus (`data/tokenizer_corpus.txt`) and a diverse evaluation test suite containing English prose, Python code snippets, numbers, whitespace runs, and multilingual Unicode strings.
Expected Outcome: Training text corpus and structured test fixture strings.
Acceptance Check: Test fixtures include edge cases: empty string `''`, single characters, repeated spaces, and emojis.
Core

Initializing token IDs `0..255` to the 256 single-byte values `bytes([i])` guarantees completeness over all valid UTF-8 strings.

Implementation: Initialize `self.vocab: dict[int, bytes] = {idx: bytes([idx]) for idx in range(256)}`.
Expected Outcome: Base vocabulary of size 256 mapping each integer `0..255` to its 1-byte `bytes` object.
Acceptance Check: Any raw byte sequence can be decoded immediately before any merges are trained.
Core

At each step of BPE training, we count how often every adjacent pair `(ids[i], ids[i+1])` appears across the corpus.

Implementation: Implement `get_pair_stats(ids: list[int], counts: dict[tuple[int, int], int] | None = None) -> dict[tuple[int, int], int]` that tallies frequencies of all consecutive pairs.
Expected Outcome: For `[1, 2, 3, 1, 2]`, `get_pair_stats` returns `{(1, 2): 2, (2, 3): 1, (3, 1): 1}`.
Acceptance Check: Supports accumulating counts across multiple pre-tokenized text chunks.
Core

Selecting the pair with the highest count (with deterministic tie-breaking) ensures reproducible tokenizer training.

Implementation: Find the most frequent pair `top_pair = max(stats, key=lambda p: (stats[p], -p[0], -p[1]))` and stop early if the maximum pair count is `< 2` or no pairs remain.
Expected Outcome: Deterministic selection of the next merge pair `(p0, p1)`.
Acceptance Check: Running training twice on the same corpus produces the exact same sequence of merges.
Core

Given a sequence `ids`, a target `pair = (p0, p1)`, and a new token ID `new_id`, `merge_ids(ids, pair, new_id)` replaces all non-overlapping occurrences of `(p0, p1)` from left to right with `new_id`.

Implementation: Implement `merge_ids(ids: list[int], pair: tuple[int, int], new_id: int) -> list[int]` using a `while i < len(ids)` pointer loop.
Expected Outcome: `merge_ids([5, 6, 6, 7, 9, 1], (6, 7), 99)` returns `[5, 6, 99, 9, 1]`.
Acceptance Check: Correctly handles overlapping triples like `[6, 6, 6]` with pair `(6, 6)` -> `[99, 6]`.
Stage 2 of 30/3 tasks complete

Merge Vocabulary Training, Encoding & Decoding

Train the ordered merge table up to target `vocab_size`, build byte vocabularies, and implement `.encode()` and `.decode()`.

Intermediate

Each merge mints token ID `256 + step` whose byte sequence is the concatenation `vocab[p0] + vocab[p1]`.

Implementation: Implement `BPETokenizer.train(text: str, vocab_size: int)` running `num_merges = vocab_size - 256` iterations, recording `self.merges[(p0, p1)] = new_id` and `self.vocab[new_id] = self.vocab[p0] + self.vocab[p1]`.
Expected Outcome: Trained tokenizer with `vocab_size` entries and ordered `merges` dictionary.
Acceptance Check: Supports saving and loading the trained `.json` vocabulary and merge table to disk.
Advanced

During `.encode(text)`, you MUST apply merges in the order they were learned (lowest merge index `self.merges[pair]` first), NOT by most frequent pair inside the test string!

Implementation: Implement `encode(self, text: str) -> list[int]`: convert `text` to UTF-8 byte IDs, repeatedly find the adjacent pair in `ids` with the lowest rank in `self.merges`, and merge it until no mergeable pairs remain.
Expected Outcome: Encodes any string into a compressed list of subword integer IDs.
Acceptance Check: Encoding an empty string `''` returns `[]`; encoding a single byte returns `[byte_val]`.
Core

Decoding maps each integer token ID `idx` to its `bytes` chunk `self.vocab[idx]`, joins them with `b''.join(...)`, and decodes UTF-8.

Implementation: Implement `decode(self, ids: list[int]) -> str` using `b''.join(self.vocab[i] for i in ids).decode('utf-8', errors='replace')`.
Expected Outcome: Reconstructs the original Python string from a list of token IDs.
Acceptance Check: Uses `errors='replace'` so partial multi-byte UTF-8 tokens sampled by an untrained model never raise `UnicodeDecodeError`.
Stage 3 of 30/5 tasks complete

Edge Cases, Unicode Safety, Special Tokens & Round-Trip Tests

Handle pre-tokenization regex splits, special tokens, arbitrary Unicode, round-trip tests, and comparison against `tiktoken`.

Advanced

Regex pre-tokenization (like GPT-2 / GPT-4's split pattern) prevents BPE from merging across word boundaries and punctuation (e.g., preventing `'dog.'`, `'dog!'`, and `'dog?'` from becoming 3 unrelated tokens).

Implementation: Add regex pre-tokenization splitting text into words, numbers, punctuation, and whitespace chunks before applying byte-level merges within each chunk, and support special tokens (e.g., `<|endoftext|>`, `<|pad|>`, `<|user|>`, `<|assistant|>`).
Expected Outcome: Punctuation and contractions split cleanly, and registered special tokens encode to single dedicated IDs.
Acceptance Check: Never produces an `<unk>` token on unseen words, emojis, or control strings.
Core

Multi-byte Unicode characters (emojis, accented characters, non-Latin scripts) split into 2–4 raw UTF-8 bytes that recombine seamlessly during `b''.join` decoding.

Implementation: Test encoding and decoding strings containing emojis (`'🤖🧠🔥'`), mathematical symbols (`'∇L(θ) ∈ ℝⁿ'`), and multilingual text (`'こんにちは世界'`).
Expected Outcome: 100% exact string restoration across all Unicode test cases.
Acceptance Check: `tokenizer.decode(tokenizer.encode(s)) == s` holds for all valid UTF-8 strings.
Core

The fundamental correctness invariant of a lossless tokenizer is `decode(encode(text)) == text`.

Implementation: Write an automated `pytest` suite (`tests/test_bpe_tokenizer.py`) testing round-trip identity on empty strings, ASCII, Unicode, Python source code with indentation, and strings containing special tokens.
Expected Outcome: All round-trip identity assertions pass.
Acceptance Check: Also asserts compression ratio (`len(utf8_bytes) / len(encoded_ids) > 1.5`) on held-out prose.
Core

Comparing your tokenizer's token boundaries and compression ratio against GPT-2 / GPT-4 (`tiktoken` or Hugging Face `tokenizers`) highlights how vocabulary size (`512` vs `50,257`) affects sequence length.

Implementation: Write a comparison script measuring token count and bytes-per-token on sample paragraphs and Python code for your trained BPE tokenizer vs a reference tokenizer configuration.
Expected Outcome: Quantitative table of token counts, compression ratios, and visual token boundary splits.
Acceptance Check: Shows exact subword segmentation on sample English and Python code strings.
Core

BPE tokenization introduces well-known LLM quirks: inconsistent integer splitting (`'12345'`), trailing-space sensitivity (`'hello '` vs `' hello'`), and higher token fertility on non-English languages.

Implementation: Document your tokenizer's architecture, merge examples, compression ratio, and known BPE failure modes (e.g., trailing whitespace, arithmetic digit grouping, multilingual fertility) in `README.md`.
Expected Outcome: Complete technical documentation of your standalone BPE tokenizer.
Acceptance Check: Demonstrates at least 2 concrete examples of tokenization quirks (such as trailing whitespace or multi-digit numbers).
Reference Architecture & Verification Code

Starter & Verification Code Examples

Core Byte-Level BPE Merge & Priority-Ordered Encoding

Shows the exact left-to-right `merge_ids` primitive and rank-ordered `.encode()` loop.

def get_pair_stats(ids: list[int]) -> dict[tuple[int, int], int]:
    counts: dict[tuple[int, int], int] = {}
    for pair in zip(ids, ids[1:]):
        counts[pair] = counts.get(pair, 0) + 1
    return counts

def merge_ids(ids: list[int], pair: tuple[int, int], new_id: int) -> list[int]:
    new_ids: list[int] = []
    i = 0
    while i < len(ids):
        if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]:
            new_ids.append(new_id)
            i += 2
        else:
            new_ids.append(ids[i])
            i += 1
    return new_ids

def encode_bytes(raw_bytes: bytes, merges: dict[tuple[int, int], int]) -> list[int]:
    ids = list(raw_bytes)
    while len(ids) >= 2:
        stats = get_pair_stats(ids)
        pair = min(stats, key=lambda p: merges.get(p, float("inf")))
        if pair not in merges:
            break
        ids = merge_ids(ids, pair, merges[pair])
    return ids
Verification & Acceptance Suite

How to Test Your Implementation

Verify pair frequency counting, overlapping merge edge cases, merge-priority encoding order, and lossless round-trip identity across ASCII and Unicode.

$ pytest tests/test_bpe_tokenizer.py -vTest Runner

Lossless Encode/Decode Round-Trip Across Unicode & Code

Prove `decode(encode(s)) == s` across ASCII, multi-byte UTF-8 emojis/math, and indented Python code.

Steps: Train tokenizer (`vocab_size=320`) on sample corpus, then test round-trip on 10 diverse strings including `'def f(x):\n return x 🚀 ∑'`.

Pass Criteria: `decode(encode(s)) == s` is True for 100% of test strings.

Overlapping Pair Merge Invariant (`[6, 6, 6]`)

Ensure left-to-right non-overlapping replacement handles consecutive identical tokens properly.

Steps: Call `merge_ids([6, 6, 6, 6], (6, 6), 256)` and `merge_ids([6, 6, 6], (6, 6), 256)`.

Pass Criteria: Returns `[256, 256]` and `[256, 6]` respectively.

Overall Project Acceptance Criteria

  • The tokenizer demonstrates reliable, 100% lossless round-trip encoding and decoding (`decode(encode(s)) == s`) for all declared supported UTF-8 inputs.
  • Base 256 byte vocabulary and iterative pair merges implemented from scratch in pure Python.
  • Encoding applies merges strictly in learned priority order rather than test-string frequency order.
  • Includes automated round-trip tests covering ASCII, multi-byte Unicode, source code, and special tokens.
Progressive Spoiler-Free Guidance

Expandable Hints & Debugging Tips

Try implementing each stage on your own first. Expand a hint below if you want to verify tensor shapes, mathematical identities, or common edge cases.

Optional Bonus Extensions

Optional Stretch Challenges

Optional · Does Not Block Completion

Compare Tokenizer Vocabulary Sizes (512 vs 2,048 vs 8,192)

Train 3 tokenizers with vocabulary sizes 512, 2,048, and 8,192 on the same corpus and plot the compression ratio (bytes/token) and encoding speed.

What You Learn: How increasing `vocab_size` trades embedding table parameters `(V × d_model)` for shorter sequence lengths `T`.

Individual Digit Splitting Rule (Llama-3 / GPT-4 Style)

Add a regex rule that prevents numbers longer than 1 or 3 digits from merging together, and explain why this helps LLMs perform multi-digit addition.

What You Learn: How pre-tokenization regex design directly impacts LLM mathematical reasoning.

Expected Deliverable

What Your Completed Repository Should Contain

A standalone trainable BPE tokenizer.

Deliverable Artifacts

  • Pure-Python `BPETokenizer` with `.train()`, `.encode()`, `.decode()`, `.save()`, and `.load()`
  • Regex pre-tokenization and special control token support
  • Automated round-trip test suite across ASCII, Unicode, and source code

After Completing This Project, You Should Be Able To...

  • Explain why byte-level BPE over UTF-8 bytes (`0..255`) achieves open-vocabulary coverage without `<unk>` tokens.
  • Implement iterative BPE pair counting and merge table construction from scratch in Python.
  • Implement rank-ordered BPE encoding, regex pre-tokenization, special token handling, and fault-tolerant UTF-8 decoding.
  • Analyze how vocabulary size and pre-tokenization rules impact compression ratio and LLM behavior.
Portfolio & Repository Tracking

GitHub Repository Submission

Link your public or private GitHub repository URL to keep a structured portfolio of your from-scratch AI builds. Stored locally in your browser (self-reported; no GitHub tokens or remote code execution).

Status: Not Linked

Suggested Repository: bpe-tokenizer-from-scratch

Recommended Directory Layout:

tokenizer/bpe.py
tokenizer/regex_patterns.py
tests/test_bpe_tokenizer.py
README.md

Recommended README Documentation Checklist

  • Explanation of UTF-8 byte-level BPE training and encoding priority
  • Compression ratio and vocabulary size benchmark table
  • Examples of subword segmentation and known BPE edge cases
Local Engineering Notebook

Personal Implementation Notes