TAI BUI
Back to curriculum
BuildPython

Building a Tokenizer from Scratch

Lesson 01 gave you a toy. This one handles Japanese, emoji, and Python with mixed tabs and spaces.

TIME: ~90 minutesPREREQUISITES: Phase 10, Lesson 01 (Tokenizers: BPE, WordPiece, SentencePiece)

Learning Objectives

  • Build a production-grade BPE tokenizer that handles Unicode, whitespace normalization, and special tokens
  • Implement byte-level fallback so the tokenizer can encode any input (including emoji, CJK, and code) without unknown tokens
  • Add pre-tokenization regex patterns that split text at word boundaries before applying BPE merges
  • Train a custom tokenizer on a corpus and evaluate its compression ratio against tiktoken on multilingual text

The Problem

Your BPE tokenizer from Lesson 01 works on English text. Now give it Japanese, or emoji, or Python code with mixed tabs and spaces.

It breaks.

BPE isn't the problem. The implementation is incomplete. A production tokenizer reads raw bytes whatever the encoding, normalizes Unicode before splitting, keeps special tokens out of the merge step, chains pre-tokenization with subword splitting, and runs fast enough that it doesn't bottleneck a training pipeline processing 15 trillion tokens.

GPT-2's tokenizer has 50,257 tokens. Llama 3 has 128,256. GPT-4 has roughly 100,000. The merge tables behind those vocabularies were trained on hundreds of gigabytes of text. The machinery around them (normalization, pre-tokenization, special token injection, chat template formatting) is what separates a tokenizer that handles "hello world" from one that handles the web.

You are going to build that machinery.

Warm-up0/3
1. In Lesson 01's BPE training loop, what does each merge step do?Warm-up
Four toy chunks, l o, l o, l o and l a, with a table of adjacent byte-pair counts: (l, o) appears 3 times and (l, a) appears once.
Toy example, not from the lesson's own corpus.
2. A Python string contains the character 'é'. How many bytes does it take once encoded as UTF-8?Warm-up
The character é feeding text.encode("utf-8"), with the resulting byte list marked by a question mark.
bytes_to_tokens is the Build It, Step 1 function.
3. In a regular expression, what does the pattern \s+ match?Warm-up
The pattern backslash-s-plus above a line of text, walk, then several spaces, then away, a tab character, then now.
Generic regex example, not from the lesson's code.

The Concept

The Full Pipeline

A production tokenizer is a pipeline of five stages, and each one solves a separate problem.

Mermaid diagram

Here is what each stage does:

StageWhat It DoesWhy It Matters
NormalizeNFKC Unicode, lowercase optional, strip accents optional"fi" ligature (U+FB01) becomes "fi" (two chars). Without this, same word gets different tokens.
Pre-TokenizeSplit text into chunks before BPEPrevents BPE from merging across word boundaries. "the cat" should never produce a token "e c".
BPE MergeApply learned merge rules to byte sequencesThe core compression. Turns raw bytes into subword tokens.
Special TokensInject [BOS], [EOS], [PAD], chat template markersThese tokens have fixed IDs. They never participate in BPE merges. The model needs them for structure.
ID MappingConvert token strings to integer IDsThe model sees integers, not strings.

Byte-Level BPE

Lesson 01's tokenizer operated on UTF-8 bytes, which was the right call. It left a gap, though: what happens when those bytes are not valid UTF-8?

Byte-level BPE closes it by treating each byte value (0-255) as a valid token, so the base vocabulary is exactly 256 entries. A text file, a binary, or a corrupted file can be tokenized without producing an unknown token.

GPT-2 added a trick: map each byte to a printable Unicode character so the vocabulary stays human-readable. Byte 0x20 (space) becomes the character "Ġ" in their mapping. This is purely cosmetic, and the algorithm doesn't care.

The bigger payoff is coverage. Chinese characters are 3 UTF-8 bytes each, and Japanese can take 3-4. Arabic, Devanagari, and emoji are also just byte sequences. BPE finds patterns in them the same way it finds patterns in English ASCII bytes.

Pre-Tokenization

Before BPE sees your text, split it into chunks. That stops the merge algorithm from creating tokens that span word boundaries.

GPT-2 uses this regex:

'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+

It splits on contractions ("don't" becomes "don" + "'t"), words with an optional leading space, numbers, punctuation, and whitespace. The leading space stays attached to the word, so "the cat" becomes ["the", " cat"], not ["the", " ", "cat"].

Llama uses SentencePiece, which skips the regex. It treats the raw byte stream as one long sequence and lets BPE find the boundaries. That is simpler, but BPE gets more freedom to create tokens that cross words.

The choice matters. GPT-2's regex blocks merges across a word boundary. SentencePiece allows them, which can compress better at the cost of less interpretable tokens.

Special Tokens

Production tokenizers reserve token IDs for structural markers:

TokenPurposeUsed By
[BOS] / <s>Beginning of sequenceLlama 3, GPT
[EOS] / </s>End of sequenceAll models
[PAD]Padding for batch alignmentBERT, T5
[UNK]Unknown token (byte-level BPE eliminates this)BERT, WordPiece
<|im_start|>Chat message boundary startChatGPT, Qwen
<|im_end|>Chat message boundary endChatGPT, Qwen
<|user|>User turn markerLlama 3
<|assistant|>Assistant turn markerLlama 3

BPE does not split special tokens. They are matched as exact strings before the merge algorithm runs and replaced with their fixed ID, and the surrounding text is tokenized normally.

Chat Templates

This is where most people get confused and most implementations break.

When you send messages to a chat model, the API takes a list of messages:

[
  {"role": "system", "content": "You are helpful."},
  {"role": "user", "content": "Hello"},
  {"role": "assistant", "content": "Hi there!"}
]

The model does not see JSON. It sees a flat token sequence, and the chat template converts messages into that sequence using special tokens. Each model does it differently:

Llama 3:
<|begin_of_text|><|start_header_id|>system<|end_header_id|>

You are helpful.<|eot_id|><|start_header_id|>user<|end_header_id|>

Hello<|eot_id|><|start_header_id|>assistant<|end_header_id|>

Hi there!<|eot_id|>

ChatGPT:
<|im_start|>system
You are helpful.<|im_end|>
<|im_start|>user
Hello<|im_end|>
<|im_start|>assistant
Hi there!<|im_end|>

Get the template wrong and the model produces garbage, because it was trained on one exact format. A missing newline, a swapped token, or an extra space puts the input outside the training distribution.

Speed

Pure Python is too slow for production tokenization.

tiktoken (OpenAI) is written in Rust with Python bindings. HuggingFace tokenizers is also Rust, and SentencePiece is C++. These get 10-100x speedups over pure Python.

For perspective: tokenizing 15 trillion tokens for Llama 3 pre-training at 1 million tokens per second (fast Python) would take 174 days. At 100 million tokens per second (Rust), it takes 1.7 days.

You build in Python here to understand the algorithm. In production you'd use a compiled implementation and touch only the Python wrapper.

Check: The Concept0/16
1. A word-level tokenizer meets a word it never saw in training. What typically happens, and how does a base vocabulary of all 256 byte values avoid it?Define
A 5-entry word-level vocabulary and a 256-entry byte-level vocabulary, each facing the unseen word quux.
quux is a made-up word for the example.
2. What does the GPT-2 pre-tokenization regex do before any BPE merge runs?Define
The sentence Hello, world! Don't stop. feeding a box labelled pre-tokenization regex, which produces an unresolved set of chunks.
Sentence is the lesson's own pre-tokenization example.
3. What does NFKC normalization do to text before tokenization?Define
The fi ligature character and a fullwidth A character, both with arrows into a box labelled NFKC whose output is marked with a question mark.
Both characters are named in the lesson's Concept section.
4. What are special tokens in a production tokenizer?Define
Four marker strings from the lesson's table: [BOS], [EOS], user marker and assistant marker, with a question about what they are and how they are treated.
Markers copied from the lesson's Special Tokens table.
5. What is a chat template?Define
A three-message role and content list feeding an unresolved box, with a note that the model never reads the JSON directly.
Messages are the lesson's own Chat Templates example.
6. What is tiktoken?Define
Three lines of Python that import tiktoken, load the cl100k_base encoding and call encode, with a question about what tiktoken is.
Code copied from the lesson's Use It section.
7. Two users type the same word, one with the 'fi' ligature (U+FB01) and one with plain 'f' and 'i'. Which pipeline stage makes the tokenizer treat them the same?Explain
Two spellings of a word, one with the fi ligature and one with plain f and i, above the five unhighlighted pipeline stages: Normalize, Pre-Tokenize, BPE Merge, Special Tokens, ID Mapping.
Pipeline stages are the lesson's Concept, The Full Pipeline table.
8. Why are special tokens like <|end|> matched as exact strings before BPE runs?Explain
The string marker less-than-pipe-end-pipe-greater-than with two candidate paths, an ordinary BPE path and an exact-match path, both with unresolved outcomes.
The marker is the lesson's own <|end|> example.
9. Why can byte-level BPE encode Chinese, Arabic and emoji without extra handling for each script?Explain
Three scripts side by side: Chinese characters, an Arabic word, and a fire emoji, with a question about why no extra per-script handling is needed.
Scripts named in the lesson's Byte-Level BPE section.
10. GPT-2 maps byte 0x20 (space) to the printable character 'Ġ'. What does this mapping change about how BPE works?Explain
Byte 0x20, the space character, mapped to the printable character G with a dot above it, and a question about the base vocabulary size and what changes about BPE.
The mapping is stated directly in the lesson's Byte-Level BPE section.
11. How does Llama's SentencePiece approach differ from GPT-2's regex pre-tokenization?Explain
GPT-2 with its regex pre-tokenizer and Llama 3 with SentencePiece, each with an unresolved box for what happens before BPE runs.
Models named in the lesson's Pre-Tokenization section.
12. Why do production tokenizers use Rust or C++ instead of pure Python?Explain
Pure Python at one million tokens per second next to an unresolved alternative implementation and its rate.
Python's rate is the lesson's own Concept, Speed figure.
13. Roughly how long would tokenizing Llama 3's 15 trillion pre-training tokens take at 1 million tokens/second (fast Python) versus 100 million tokens/second (Rust)?Apply
Two rates, 1 million and 100 million tokens per second, each with an unresolved number of days to process 15 trillion tokens.
Rates and corpus size are the lesson's own Concept, Speed figures.
14. You format a Llama 3 prompt with ChatGPT's <|im_start|> and <|im_end|> markers, and the model replies with rambling, off-format text. What is the most likely cause?Analyze
Llama 3's marker set and ChatGPT's marker set side by side, with ChatGPT's marker set highlighted as the one actually used to format a prompt sent to a Llama 3 model.
Marker sets are the lesson's own two chat template examples.
15. Your pure-Python tokenizer runs at 1 million tokens per second and the corpus has 15 trillion tokens. Which change addresses the bottleneck?Design
A pure-Python tokenizer running at one million tokens per second next to a warning icon and an unresolved box for the fix, with a corpus of fifteen trillion tokens.
Rate and scale are the lesson's own Concept, Speed figures.
16. You must add Llama 3 chat formatting to ProductionTokenizer. Which design fits?Design
SpecialTokenHandler and a list of role and content messages, both feeding an unresolved box for how chat formatting should be added.
Classes and example drawn from the lesson's Build It and Chat Templates sections.

Build It

Step 1: Byte-Level Encoding

The foundation: turn a string into a list of byte values, and turn a list of byte values back into a string.

python
def bytes_to_tokens(text):
    return list(text.encode("utf-8"))
 
def tokens_to_text(token_bytes):
    return bytes(token_bytes).decode("utf-8", errors="replace")

Test on multilingual text to see the byte counts:

python
texts = [
    ("English", "hello"),
    ("Chinese", "你好"),
    ("Emoji", "🔥"),
    ("Mixed", "hello你好🔥"),
]
 
for label, text in texts:
    b = bytes_to_tokens(text)
    print(f"{label}: {len(text)} chars -> {len(b)} bytes -> {b}")

"hello" is 5 bytes. "你好" is 6 bytes (3 per character). The fire emoji is 4 bytes. The byte-level tokenizer doesn't care what language it is. Bytes are bytes.

Step 2: Pre-Tokenizer with Regex

Split text into chunks with the GPT-2 regex. BPE tokenizes each chunk independently.

python
import re
 
try:
    import regex
    GPT2_PATTERN = regex.compile(
        r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
    )
except ImportError:
    GPT2_PATTERN = re.compile(
        r"""'(?:[sdmt]|ll|ve|re)| ?[a-zA-Z]+| ?[0-9]+| ?[^\s\w]+|\s+(?!\S)|\s+"""
    )
 
def pre_tokenize(text):
    return [match.group() for match in GPT2_PATTERN.finditer(text)]

The regex module supports Unicode property escapes (\p{L} for letters, \p{N} for numbers). The standard library re module does not, so the code falls back to ASCII character classes. For multilingual text, install regex.

Try it:

python
print(pre_tokenize("Hello, world! Don't stop."))
# ['Hello', ',', ' world', '!', ' Don', "'t", ' stop', '.']

The leading space stays attached to every word after the first, contractions split at the apostrophe, and punctuation becomes its own chunk. BPE can't merge across those boundaries.

Step 3: BPE on Byte Sequences

The core algorithm from Lesson 01, now run on each pre-tokenized chunk independently.

python
from collections import Counter
 
def get_byte_pairs(chunks):
    pairs = Counter()
    for chunk in chunks:
        byte_seq = list(chunk.encode("utf-8"))
        for i in range(len(byte_seq) - 1):
            pairs[(byte_seq[i], byte_seq[i + 1])] += 1
    return pairs
 
def apply_merge(byte_seq, pair, new_id):
    merged = []
    i = 0
    while i < len(byte_seq):
        if i < len(byte_seq) - 1 and byte_seq[i] == pair[0] and byte_seq[i + 1] == pair[1]:
            merged.append(new_id)
            i += 2
        else:
            merged.append(byte_seq[i])
            i += 1
    return merged

Step 4: Special Token Handling

Special tokens need exact matching and fixed IDs, and they bypass BPE.

python
class SpecialTokenHandler:
    def __init__(self):
        self.special_tokens = {}
        self.pattern = None
 
    def add_token(self, token_str, token_id):
        self.special_tokens[token_str] = token_id
        escaped = [re.escape(t) for t in sorted(self.special_tokens.keys(), key=len, reverse=True)]
        self.pattern = re.compile("|".join(escaped))
 
    def split_with_specials(self, text):
        if not self.pattern:
            return [(text, False)]
        parts = []
        last_end = 0
        for match in self.pattern.finditer(text):
            if match.start() > last_end:
                parts.append((text[last_end:match.start()], False))
            parts.append((match.group(), True))
            last_end = match.end()
        if last_end < len(text):
            parts.append((text[last_end:], False))
        return parts

Step 5: Full Tokenizer Class

Chain the stages: normalize, split on special tokens, pre-tokenize, BPE merge, map to IDs.

python
import unicodedata
 
class ProductionTokenizer:
    def __init__(self):
        self.merges = {}
        self.vocab = {i: bytes([i]) for i in range(256)}
        self.special_handler = SpecialTokenHandler()
        self.next_id = 256
 
    def normalize(self, text):
        return unicodedata.normalize("NFKC", text)
 
    def train(self, text, num_merges):
        text = self.normalize(text)
        chunks = pre_tokenize(text)
        chunk_bytes = [list(chunk.encode("utf-8")) for chunk in chunks]
 
        for i in range(num_merges):
            pairs = Counter()
            for seq in chunk_bytes:
                for j in range(len(seq) - 1):
                    pairs[(seq[j], seq[j + 1])] += 1
            if not pairs:
                break
            best = max(pairs, key=pairs.get)
            new_id = self.next_id
            self.next_id += 1
            self.merges[best] = new_id
            self.vocab[new_id] = self.vocab[best[0]] + self.vocab[best[1]]
            chunk_bytes = [apply_merge(seq, best, new_id) for seq in chunk_bytes]
 
    def add_special_token(self, token_str):
        token_id = self.next_id
        self.next_id += 1
        self.special_handler.add_token(token_str, token_id)
        self.vocab[token_id] = token_str.encode("utf-8")
        return token_id
 
    def encode(self, text):
        text = self.normalize(text)
        parts = self.special_handler.split_with_specials(text)
        all_ids = []
        for part_text, is_special in parts:
            if is_special:
                all_ids.append(self.special_handler.special_tokens[part_text])
            else:
                for chunk in pre_tokenize(part_text):
                    byte_seq = list(chunk.encode("utf-8"))
                    for pair, new_id in self.merges.items():
                        byte_seq = apply_merge(byte_seq, pair, new_id)
                    all_ids.extend(byte_seq)
        return all_ids
 
    def decode(self, ids):
        byte_parts = []
        for token_id in ids:
            if token_id in self.vocab:
                byte_parts.append(self.vocab[token_id])
        return b"".join(byte_parts).decode("utf-8", errors="replace")
 
    def vocab_size(self):
        return len(self.vocab)

Step 6: Multilingual Test

The real test: English, Chinese, emoji, and code.

python
corpus = (
    "The quick brown fox jumps over the lazy dog. "
    "The quick brown fox runs through the forest. "
    "Machine learning models process natural language. "
    "Deep learning transforms how we build software. "
    "def train(model, data): return model.fit(data) "
    "def predict(model, x): return model(x) "
)
 
tok = ProductionTokenizer()
tok.train(corpus, num_merges=50)
 
bos = tok.add_special_token("<|begin|>")
eos = tok.add_special_token("<|end|>")
 
test_texts = [
    "The quick brown fox.",
    "你好世界",
    "Hello 🌍 World",
    "def foo(x): return x + 1",
    f"<|begin|>Hello<|end|>",
]
 
for text in test_texts:
    ids = tok.encode(text)
    decoded = tok.decode(ids)
    print(f"Input:   {text}")
    print(f"Tokens:  {len(ids)} ids")
    print(f"Decoded: {decoded}")
    print()

Chinese characters produce 3 bytes each and the emoji produces 4. Neither crashes the tokenizer or produces an unknown token. That is what byte-level BPE buys you.

Check: Build It0/12
1. What is the merge table in a trained BPE tokenizer?Define
Three attributes of a trained tokenizer side by side: self.vocab, self.merges with an unresolved description, and special_tokens, with a question about which one is the merge table.
Attribute names from the lesson's ProductionTokenizer class.
2. What does len(bytes_to_tokens('你好')) return?Apply
The two characters 你好 feeding bytes_to_tokens, with the resulting byte count marked by a question mark.
bytes_to_tokens is the Build It, Step 1 function.
3. What does len(bytes_to_tokens('hello你好🔥')) return?Apply
The mixed string hello, two Chinese characters and a fire emoji feeding bytes_to_tokens, with the total byte count marked by a question mark.
bytes_to_tokens is the Build It, Step 1 function.
4. You run the GPT-2 regex on 'The price is $3.14 per unit.' Which chunks cover the '$3.14' part?Apply
The sentence's chunks for The, space price, space is and space per, space unit, period shown resolved, with a single dashed placeholder standing in for however many chunks cover the dollar 3.14 portion.
Resolved chunks are real pre_tokenize output; the placeholder does not reveal a chunk count.
5. Only the standard library re module is available (no regex package). What does pre_tokenize('Hello 你好 world') return?Apply
Hello space Chinese characters space world feeding pre_tokenize while a warning states the regex package is not installed, with the output chunks left unresolved behind a dashed placeholder.
The warning reflects the lesson's own fallback behavior when the regex package is missing.
6. With apply_merge from Step 3, what does apply_merge([7, 7, 7], (7, 7), 300) return?Apply
Three byte values, 7, 7 and 7, with a merge rule (7, 7) to 300, and an unresolved result below.
apply_merge is the Build It, Step 3 function.
7. After add_token('<|end|>', 300), what does split_with_specials('Hi<|end|>Bye') return?Apply
Python calling add_token to register the end marker with ID 300, then split_with_specials on Hi end-marker Bye, with an unresolved return value.
split_with_specials is the Build It, Step 4 method.
8. In ProductionTokenizer.encode, in what order are the steps applied to an input string?Apply
Four step names, apply merges, normalize, pre-tokenize and split on specials, scattered on the canvas with no implied order.
Step names from the lesson's ProductionTokenizer.encode method.
9. A ProductionTokenizer trains with num_merges=50, then add_special_token is called twice. What does vocab_size() return, assuming all 50 merges are learned?Apply
Three sources feeding a vocabulary: 256 base bytes, a train call with 50 requested merges and an unresolved learned count, and add_special_token called twice, with vocab_size left unresolved.
Setup matches the lesson's own Build It, Steps 5 and 6.
10. You test a tokenizer on 'Hello 你好 world' in an environment without the regex package. The English words look fine. Which check catches the problem?Analyze
A note that the English half of a test on Hello space Chinese characters space world looks fine, with an open question about which check catches a hidden problem.
Setup matches the lesson's Author's Note scenario.
11. You encode then decode a string containing the 'fi' ligature, and the round-trip check reports FAIL although the tokenizer worked. Why?Analyze
The ligature input file (with the fi ligature) flowing through encode and decode to an unresolved PASS or FAIL outcome.
Input is the lesson's own fi-ligature example from the Author's Note.
12. Bad token IDs enter a data pipeline and the decoded text quietly loses pieces, with no error. What causes this, and what is the fix?Analyze
A sequence of token IDs with one highlighted value, 99999, that is not a valid vocabulary entry, and a question about what decode does with it.
99999 stands in for an out-of-range ID; the surrounding IDs are from the lesson's own hello world example.

Use It

Comparing Real Tokenizers

Load the real tokenizers from Llama 3, GPT-4, and Mistral and see how each handles the same multilingual paragraph.

python
import tiktoken
 
gpt4_enc = tiktoken.get_encoding("cl100k_base")
 
test_paragraph = "Machine learning is powerful. 机器学习很强大。 L'apprentissage automatique est puissant. 🤖💪"
 
tokens = gpt4_enc.encode(test_paragraph)
pieces = [gpt4_enc.decode([t]) for t in tokens]
print(f"GPT-4 ({len(tokens)} tokens): {pieces}")
python
from transformers import AutoTokenizer
 
llama_tok = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B")
mistral_tok = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-v0.1")
 
for name, tok in [("Llama 3", llama_tok), ("Mistral", mistral_tok)]:
    tokens = tok.encode(test_paragraph)
    pieces = tok.convert_ids_to_tokens(tokens)
    print(f"{name} ({len(tokens)} tokens): {pieces[:20]}...")

The token counts differ for the same text. Llama 3's 128K vocabulary merges common patterns more aggressively, GPT-4's 100K sits in the middle, and Mistral's 32K produces more tokens but has a smaller embedding layer.

The tradeoff stays the same: a larger vocabulary gives shorter sequences and more parameters.

Check: Use It0/4
1. What does fertility measure for a tokenizer?Define
The sentence the dog ran fast, 4 words, with an arrow to an unresolved number of tokens, and the formula fertility equals tokens divided by words.
Illustrative sentence, not from the lesson's own corpus.
2. Why do Llama 3, GPT-4 and Mistral return different token counts for the same multilingual paragraph?Explain
One multilingual paragraph feeding three tokenizers, Llama 3, GPT-4 and Mistral, each with an unresolved token count.
Paragraph trimmed from the lesson's Use It example.
3. In GPT-4, fertility is about 1.3 for English and 2-3 for Korean. What does that gap mean in practice?Explain
A bar chart of fertility for English, 1.3, and Korean, shown around 2.5, with the formula tokens divided by words.
Figures are the lesson's own numbers (English 1.3, Korean 2-3, midpoint shown).
4. A team's priority is a small embedding layer, and they can accept longer token sequences. Which choice follows the lesson's vocabulary tradeoff?Design
A team's two stated priorities, a small embedding layer and accepting longer sequences, with a question about which vocabulary size follows.
Priorities are the ones given in the stem.

Ship It

The output is a prompt for building and debugging production tokenizers: outputs/prompt-tokenizer-builder.md.

Author's Note

I'd check whether regex is installed before running main.py. Without it, pre_tokenize falls back to ASCII character classes, and pre_tokenize("Hello 你好 world") returns ['Hello', ' ', ' world']. The Chinese is dropped before BPE sees it. With regex installed the same call returns ['Hello', ' 你好', ' world']. A multilingual test can look fine on the English half and quietly lose the rest.

The round-trip line in the demo is the check I'd trust most. It decodes each output and prints PASS or FAIL against the input, which catches that dropped-text case. One caveat: encode runs NFKC first, so an input with a ligature or a fullwidth letter decodes to its normalized form and reports FAIL, even though the tokenizer did its job.

On a bigger corpus I'd change two things. encode applies the full merge list to each chunk in order, so cost grows with merge count times chunk length, and I'd cache results per chunk before anything else. And decode skips IDs it doesn't recognize instead of raising, which would hide a bad ID in a data pipeline. I'd make it raise.

I'd leave the chat template exercise for last. The code has no template method, so you're building it on top of SpecialTokenHandler, and the exact token strings come from the Llama 3 format shown above.

Exercises

  1. Easy: Add a get_token_bytes(id) method that shows the raw bytes for any token ID. Use it to inspect what your most common merged tokens actually represent.
  2. Medium: Implement the Llama-style pre-tokenizer that splits on whitespace and digits but keeps leading spaces. Compare its vocabulary with the GPT-2 regex approach on the same corpus.
  3. Hard: Add a chat template method that takes a list of {"role": ..., "content": ...} messages and produces the correct token sequence for the Llama 3 chat format. Test it against the HuggingFace implementation.

Key Terms

TermWhat people sayWhat it actually means
Byte-level BPE"Tokenizer that works on bytes"BPE with a base vocabulary of 256 byte values -- handles any input without unknown tokens
Pre-tokenization"Splitting before BPE"Regex or rule-based splitting that prevents BPE from merging across word boundaries
NFKC normalization"Unicode cleanup"Canonical decomposition followed by compatibility composition -- "fi" ligature becomes "fi", fullwidth "A" becomes "A"
Chat template"How messages become tokens"The exact format for converting a list of role/content messages into a flat token sequence -- model-specific and must match training format
Special tokens"Control tokens"Reserved token IDs that bypass BPE -- [BOS], [EOS], [PAD], chat markers -- matched exactly before merge
Fertility"Tokens per word"Ratio of output tokens to input words -- 1.3 for English in GPT-4, 2-3 for Korean, higher means wasted context
tiktoken"OpenAI tokenizer"Rust BPE implementation with Python bindings -- 10-100x faster than pure Python
Merge table"The vocabulary"Ordered list of byte-pair merges learned during training -- this IS the tokenizer's learned knowledge

Further Reading

Code

Ships With