Tokenization Engineering & BPE Algorithm
Master subword tokenization theory, derive the Byte-Pair Encoding (BPE) compression algorithm, analyze Byte-level BBPE zero-OOV guarantees, and inspect zero-dependency implementations in Python and Rust.
Neural networks cannot process raw unicode text strings directly; they require discrete numerical integer IDs mapped to continuous dense embeddings.
The bridge between continuous language models and human text is Tokenization. Modern language models rely on Byte-Pair Encoding (BPE) to balance vocabulary size with sequence length, eliminating Out-Of-Vocabulary (OOV) failures.
Initializing vocabulary with 256 raw bytes guarantees any valid UTF-8 unicode string can be tokenized.
1. Character vs Word vs Subword Tradeoffs#
| Tokenization Scheme | Typical Vocabulary Size | Sequence Length | OOV Robustness | Expressive Semantics |
|---|---|---|---|---|
| Character-Level | Minimal () | Very Long () | Perfect ( OOV) | Poor (individual characters carry minimal semantic meaning) |
| Word-Level | Massive () | Minimal () | Catastrophic (any unknown word becomes [UNK]) | Excellent for known words; fails on typos and morphology |
| Subword (BPE) | Optimal () | Balanced () | Perfect (decomposes unseen words into roots/suffixes) | Excellent (morphological compositionality) |
2. The Byte-Pair Encoding (BPE) Algorithm#
Originally introduced by Philip Gage (1994) as a data compression heuristic, Sennrich et al. (2016) adapted Byte-Pair Encoding for neural language models.
Step 1: Base Vocabulary Initialization
Initialize vocabulary with all unique characters occurring in the training corpus, plus a dedicated end-of-word marker </w>:
Every word in the corpus is represented as a sequence of space-separated characters:
"low"l o w </w>"lower"l o w e r </w>"newest"n e w e s t </w>
Step 2: Compute Pair Frequencies
Count the occurrence frequency of all adjacent symbol pairs across the corpus, weighted by word frequencies:
Step 3: Greedy Merge
Select the symbol pair that achieves the maximum frequency:
Create a new token by concatenating the pair: .
Step 4: Vocabulary Expansion & Replacement
Add to vocabulary: . Replace all consecutive occurrences of u* v* across the corpus with atomic token u*v*.
Repeat Steps 2 through 4 until exactly merge operations have completed, reaching desired vocabulary size .
3. Byte-Level BPE (BBPE)#
Standard character BPE fails on non-Latin scripts, mathematical symbols, or emojis if those characters were missing from the training corpus.
Radford et al. (2019) introduced Byte-level BPE (BBPE) in GPT-2:
- The base vocabulary is initialized strictly with the 256 possible bytes ( through ).
- Because any unicode character (including rare alphabets, specialized domain formulas, and emojis) is encoded as a sequence of 1 to 4 UTF-8 bytes, BBPE guarantees zero Out-of-Vocabulary (OOV) tokens.
- Frequent words (e.g.
" the") merge into single token IDs, while rare characters decompose into constituent byte sequences.
4. From-Scratch Implementation: Zero Dependencies#
The following implementations build a complete BPE Tokenizer (Training and Tokenization) from scratch in pure Python and pure Rust.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108from typing import List, Dict, Tuple from collections import defaultdict class BPETokenizerFromScratch: def __init__(self, target_vocab_size: int = 15): self.target_vocab_size = target_vocab_size self.merges: List[Tuple[str, str]] = [] self.vocab: List[str] = [] @staticmethod def _get_pair_counts(corpus: Dict[Tuple[str, ...], int]) -> Dict[Tuple[str, str], int]: counts: Dict[Tuple[str, str], int] = defaultdict(int) for word_tuple, freq in corpus.items(): for i in range(len(word_tuple) - 1): pair = (word_tuple[i], word_tuple[i + 1]) counts[pair] += freq return counts @staticmethod def _merge_pair( pair: Tuple[str, str], corpus: Dict[Tuple[str, ...], int] ) -> Dict[Tuple[str, ...], int]: first, second = pair new_corpus: Dict[Tuple[str, ...], int] = {} for word_tuple, freq in corpus.items(): new_word = [] i = 0 while i < len(word_tuple): if i < len(word_tuple) - 1 and word_tuple[i] == first and word_tuple[i + 1] == second: new_word.append(first + second) i += 2 else: new_word.append(word_tuple[i]) i += 1 new_corpus[tuple(new_word)] = freq return new_corpus def fit(self, texts: List[str]): # 1. Initialize corpus with word frequency counts raw_words = [w for text in texts for w in text.strip().split()] word_counts = defaultdict(int) for w in raw_words: # Append end-of-word indicator '</w>' word_tuple = tuple(list(w) + ["</w>"]) word_counts[word_tuple] += 1 # 2. Base character vocabulary vocab_set = set() for word_tuple in word_counts: vocab_set.update(word_tuple) self.vocab = sorted(list(vocab_set)) # 3. Iterative merge until target size reached corpus = dict(word_counts) num_merges = self.target_vocab_size - len(self.vocab) for _ in range(max(0, num_merges)): pair_counts = self._get_pair_counts(corpus) if not pair_counts: break # Find most frequent pair best_pair = max(pair_counts, key=pair_counts.get) self.merges.append(best_pair) self.vocab.append(best_pair[0] + best_pair[1]) corpus = self._merge_pair(best_pair, corpus) def tokenize(self, word: str) -> List[str]: # Start with individual characters + end marker tokens = list(word) + ["</w>"] # Apply learned merges in rank order for p1, p2 in self.merges: merged_token = p1 + p2 i = 0 new_tokens = [] while i < len(tokens): if i < len(tokens) - 1 and tokens[i] == p1 and tokens[i + 1] == p2: new_tokens.append(merged_token) i += 2 else: new_tokens.append(tokens[i]) i += 1 tokens = new_tokens return tokens if __name__ == "__main__": training_data = [ "low low low low low", "lower lower", "newest newest newest newest newest newest", "wider wider" ] tokenizer = BPETokenizerFromScratch(target_vocab_size=18) tokenizer.fit(training_data) print("Learned BPE Merges in Order:") for rank, pair in enumerate(tokenizer.merges): print(f" Rank {rank+1}: {pair[0]} + {pair[1]} -> {pair[0]+pair[1]}") print("\nTokenizing novel word 'lowest':") tokenized = tokenizer.tokenize("lowest") print(" Tokens:", tokenized)
5. Active Knowledge Checks#
What fatal failure of word-level tokenization is completely resolved by Byte-Pair Encoding (BPE) subword segmentation?
Why does Byte-level BPE (BBPE) use the 256 byte values (0x00 to 0xFF) as its base vocabulary instead of unicode characters?