Byte Pair Encoding › BPE Implementation from Scratch
Language AI HandbookAll chapters Byte Pair Encoding › BPE Implementation from ScratchOne section · Sections in this chapter7 / 14 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Let's build a complete, self-contained BPE tokenizer class. This version handles the full pipeline: training, encoding to token strings, encoding to integer IDs, and decoding. Building a complete class is valuable because understanding every component makes it much easier to debug issues when you use production tokenizers.
The implementation follows the same algorithmic steps we traced above, packaged into a clean interface. The train method builds word frequencies, identifies the base character set, and runs the merge loop. The tokenize method applies merge rules to new text. The encode and decode methods handle the integer ID layer consumed by most models.
class BPETokenizer:
"""
A complete BPE tokenizer trained from raw text.
Supports encode (text -> IDs) and decode (IDs -> text).
"""
END_OF_WORD = "</w>"
def __init__(self, vocab_size=500):
self.vocab_size = vocab_size
self.merge_rules = [] # ordered list of (a, b) pairs
self.token_to_id = {} # str -> int
self.id_to_token = {} # int -> str
# ------------------------------------------------------------------ #
# Internal helpers #
# ------------------------------------------------------------------ #
def _build_word_freqs(self, texts):
wf = {}
for text in texts:
for word in text.lower().split():
key = " ".join(list(word)) + f" {self.END_OF_WORD}"
wf[key] = wf.get(key, 0) + 1
return wf
@staticmethod
def _count_pairs(wf):
pairs = {}
for word, freq in wf.items():
tokens = word.split()
for i in range(len(tokens) - 1):
p = (tokens[i], tokens[i + 1])
pairs[p] = pairs.get(p, 0) + freq
return pairs
@staticmethod
def _merge_pair(wf, pair):
merged = pair[0] + pair[1]
return {
word.replace(f"{pair[0]} {pair[1]}", merged): freq
for word, freq in wf.items()
}
# ------------------------------------------------------------------ #
# Training #
# ------------------------------------------------------------------ #
def train(self, texts):
wf = self._build_word_freqs(texts)
# Collect base characters
base_tokens = set()
for word in wf:
base_tokens.update(word.split())
num_merges = self.vocab_size - len(base_tokens)
for _ in range(max(0, num_merges)):
pairs = self._count_pairs(wf)
if not pairs:
break
best = max(pairs.items(), key=lambda x: x[1])[0]
self.merge_rules.append(best)
wf = self._merge_pair(wf, best)
# Build vocabulary from final token set
all_tokens = set(base_tokens)
for a, b in self.merge_rules:
all_tokens.add(a + b)
for idx, tok in enumerate(sorted(all_tokens)):
self.token_to_id[tok] = idx
self.id_to_token[idx] = tok
return self
# ------------------------------------------------------------------ #
# Encoding #
# ------------------------------------------------------------------ #
def _encode_word(self, word):
tokens = list(word)
for pair in self.merge_rules:
i = 0
while i < len(tokens) - 1:
if tokens[i] == pair[0] and tokens[i + 1] == pair[1]:
tokens = tokens[:i] + [pair[0] + pair[1]] + tokens[i + 2 :]
else:
i += 1
return tokens
def tokenize(self, text):
"""Return list of subword token strings."""
result = []
for word in text.lower().split():
result.extend(self._encode_word(word))
return result
def encode(self, text):
"""Return list of integer token IDs."""
unk_id = self.token_to_id.get("<unk>", 0)
return [self.token_to_id.get(t, unk_id) for t in self.tokenize(text)]
# ------------------------------------------------------------------ #
# Decoding #
# ------------------------------------------------------------------ #
def decode(self, ids):
"""Reconstruct text from integer IDs."""
tokens = [self.id_to_token.get(i, "<unk>") for i in ids]
return "".join(tokens).replace(self.END_OF_WORD, " ").strip()Now let's train this tokenizer on a realistic corpus and run some experiments:
training_corpus = [
"the cat sat on the mat",
"the dog ran in the park",
"cats and dogs are pets",
"the cat chased the dog",
"dogs like to run and play",
"cats prefer to sleep all day",
"the park has many dogs and cats",
"running dogs and sleeping cats",
"the fastest dog won the race",
"slower cats prefer the mat",
"cats run and dogs play in parks",
"the dog chased a cat running fast",
]
bpe = BPETokenizer(vocab_size=120)
bpe.train(training_corpus)Vocabulary size: 107 Merge rules learned: 86 First 10 merge rules: 1. 'e' + '</w>' -> 'e</w>' 2. 's' + '</w>' -> 's</w>' 3. 'a' + 't' -> 'at' 4. 't' + 'h' -> 'th' 5. 'th' + 'e</w>' -> 'the</w>' 6. 'c' + 'at' -> 'cat' 7. 'd' + 'o' -> 'do' 8. 'do' + 'g' -> 'dog' 9. 'n' + '</w>' -> 'n</w>' 10. 'd' + '</w>' -> 'd</w>'
test_inputs = [
"the cat ran",
"dogs chase cats",
"running and sleeping",
"unrecognized word", # novel word: not in training
]Tokenization results: ------------------------------------------------------------ Input : 'the cat ran' Tokens : ['th', 'e', 'cat', 'r', 'an'] IDs : [98, 29, 15, 75, 6] Decoded : 'thecatran' Input : 'dogs chase cats' Tokens : ['dog', 's', 'chase', 'cat', 's'] IDs : [26, 85, 20, 15, 85] Decoded : 'dogschasecats' Input : 'running and sleeping' Tokens : ['runn', 'ing', 'an', 'd', 'sleep', 'ing'] IDs : [83, 46, 6, 22, 91, 46] Decoded : 'runningandsleeping' Input : 'unrecognized word' Tokens : ['u', 'n', 'r', 'e', 'c', 'o', 'g', 'n', 'i', 'z', 'e', 'd', 'w', 'o', 'r', 'd'] IDs : [102, 57, 75, 29, 14, 59, 41, 57, 44, 0, 29, 22, 103, 59, 75, 22] Decoded : 'unrecogni edword'
Even "unrecognized" decomposes into meaningful subpieces. The tokenizer has never seen this word, but it applies every merge rule that matches and falls back to individual characters for any remaining unseen bigrams. The decoding is always lossless: concatenating the tokens reconstructs the original lowercase text exactly. Notice that the integer IDs assigned to tokens are stable across calls, which is what makes it possible for a neural network to learn consistent embeddings for each token.
Key Parameters
The main parameters to understand when using a BPE tokenizer are:
- vocab_size: The total number of tokens in the vocabulary, including base characters and all merged tokens. This is the single most important hyperparameter. Larger values produce shorter sequences but require more embedding parameters. Typical production models use 30,000 to 100,000 tokens.
- num_merges: Derived from vocab_size as , where is the number of base characters. You rarely set this directly.
- Corpus size and domain: Merge rules reflect the statistical patterns of the training text. A code corpus produces different rules than a news corpus. Domain mismatch between the tokenizer's training text and the model's input text can hurt downstream performance.