Byte pair encoding from scratch
By the end: You can train BPE merges on a corpus, encode and decode with them, and explain why the tokenizer's behaviour on code, numbers and non-English text follows from how the merges were learned.
- The training loop: count adjacent pairs, merge the most frequent, repeat
- Why the merge order is the model and must be saved
- Encoding by replaying the merges, and decoding by concatenation
- Keeping the leading space inside the token
- Why byte-level BPE never fails on unseen input
- What this explains about token counts on code and other languages
New to tokenization? A model does not read letters, and it does not quite read words either. Before any text reaches it, the text is cut into tokens: short pieces of characters drawn from a fixed vocabulary the model was trained on. A common word like "the" is one token; "unhappiness" might arrive as three; a space or a bracket is often a token of its own. Why it matters
Every explanation of tokenization in this topic has treated the tokenizer as given: text goes in, tokens come out, and the mapping looks arbitrary. It is not arbitrary, and the algorithm behind it fits on a page. Writing it is the fastest way to stop being surprised by token counts, and it is the stage the from-scratch GPT project explicitly defers.
The idea#
Start with a vocabulary of single characters, which can represent anything. Count every adjacent pair in the corpus, merge the most frequent pair into a single new symbol, and repeat until the vocabulary is the size you wanted. Frequent sequences — common words, common word endings, common indentation — become single tokens because they were merged early; rare ones stay as several.
The output of training is not a list of tokens. It is an ordered list of merges, and the order is load-bearing: encoding replays the merges in the order they were learned, so a saved tokenizer is that list plus the character inventory it started from.
Splitting before merging#
Real tokenizers do not let merges cross word boundaries, because a token spanning two words would be learned from one corpus and useless in another. Text is first cut into chunks by a regular expression, and merges happen within a chunk. The convention every modern tokenizer follows is to keep a word's leading space attached to it, which is why " the" and "the" are different tokens and why a token count changes when you strip whitespace.
import re
from collections import Counter
def words(text):
# a word with its leading space, or a run of whitespace on its own
return re.findall(r" ?[^\s]+|\s+", text)
def merge_pair(symbols, pair):
a, b = pair
out, i = [], 0
while i < len(symbols):
if i + 1 < len(symbols) and symbols[i] == a and symbols[i + 1] == b:
out.append(a + b) # the two symbols become one
i += 2
else:
out.append(symbols[i])
i += 1
return tuple(out)Training#
Count pairs weighted by how often the word containing them appears, rather than walking the raw text, which is the difference between a loop that finishes and one that does not. Ties are broken deterministically, because a tokenizer that trains differently on two runs of the same corpus is a tokenizer you cannot ship.
def train(text, n_merges):
freqs = Counter(words(text)) # word -> how many times it occurs
split = {w: tuple(w) for w in freqs} # every word starts as its characters
merges = []
for _ in range(n_merges):
pairs = Counter()
for w, f in freqs.items():
s = split[w]
for pair in zip(s, s[1:]): # every adjacent pair in the word
pairs[pair] += f # weighted by the word's frequency
if not pairs:
break
# most frequent, ties broken on the pair itself so training is reproducible
best = max(pairs, key=lambda p: (pairs[p], p))
if pairs[best] < 2:
break # nothing left worth merging
merges.append(best)
split = {w: merge_pair(s, best) for w, s in split.items()}
return mergescorpus = (
"the cat sat on the mat. the cat sat on the hat. "
"a cat and a hat and a mat. the rat sat on the cat. "
"the fat cat sat. the fat rat sat. the mat and the hat."
)
for i, (a, b) in enumerate(train(corpus, 20)):
print(i, repr(a), "+", repr(b), "->", repr(a + b))
# 0 'a' + 't' -> 'at'
# 1 't' + 'h' -> 'th'
# 2 'th' + 'e' -> 'the'
# 3 ' ' + 'the' -> ' the'
# 4 'at' + '.' -> 'at.'
# 5 ' ' + 'a' -> ' a'
# ...
# 8 ' c' + 'at' -> ' cat'
# 11 ' s' + 'at' -> ' sat'Encoding and decoding#
Encoding replays the merge list against a fresh word. Decoding is concatenation and nothing else, which is the property that makes the leading-space convention work: the spaces are inside the tokens, so joining them reproduces the input exactly with no rules about when to insert a space.
def encode(text, merges):
out = []
for w in words(text):
s = tuple(w)
for pair in merges: # in the order learned - this is the whole model
if len(s) == 1:
break
s = merge_pair(s, pair)
out.extend(s)
return out
def decode(tokens):
return "".join(tokens) # the spaces are already in the tokens
merges = train(corpus, 20)
tokens = encode("the fat cat sat on a mat.", merges)
print(tokens)
# ['the', ' ', 'fat', ' cat', ' sat', ' on', ' a', ' mat.']
assert decode(tokens) == "the fat cat sat on a mat."That split of " fat" into a space and "fat" is the algorithm's whole character in one line. Nothing decided that "cat" deserves its space and "fat" does not; " cat" appeared more often in the training corpus and got its merge before the budget ran out. Every oddity in a real tokenizer has the same shape.
This encode is correct and slow
Replaying every merge against every word is O(merges x length). Real implementations give each merge a rank and repeatedly apply the lowest-ranked applicable pair within a word, which reaches the same answer while touching only the merges that are relevant. Write the slow version first, because it is obviously correct, and keep it as the reference the fast one is tested against.
Unseen text, and why bytes#
A word the tokenizer has never seen still encodes: the merges that apply are applied, and whatever is left stays as single characters. Nothing fails.
t = encode("the elephant sat on a submarine.", merges)
print(t)
# ['the', ' ', 'e', 'l', 'e', 'p', 'h', 'a', 'n', 't', ' sat', ' on', ' a',
# ' s', 'u', 'b', 'm', 'a', 'r', 'i', 'n', 'e', '.']
assert decode(t) == "the elephant sat on a submarine."That guarantee only holds if the starting inventory covers every possible input, and a character-level start does not: the corpus above has no vocabulary for an emoji or a Cyrillic letter. Real tokenizers start from the 256 possible bytes rather than from characters, so every conceivable input is representable and an unknown token never has to exist. The cost is that one character outside ASCII is several bytes, and so potentially several tokens, before any merges apply.
Turning tokens into ids#
The model needs integers. The vocabulary is the starting inventory followed by the merge results, in merge order, so ids are assigned once at training time and the mapping is part of the saved tokenizer.
vocab = sorted(set(corpus)) + [a + b for a, b in merges]
ids = {t: i for i, t in enumerate(vocab)}
print(len(vocab)) # 34
print([ids[t] for t in tokens]) # [16, 0, 31, 22, 25, 27, 19, 33]What this explains#
- Code costs more tokens per character than prose, because indentation, brackets and camel-case identifiers were a smaller share of the training corpus and earned fewer merges. Some tokenizers add explicit merges for runs of spaces, which is why the same code costs different amounts across models.
- Non-English text costs several times more, because byte-level starts mean a character outside ASCII is two to four bytes before merging, and those sequences were rare enough to earn few merges.
- Numbers tokenize unevenly, because "2023" may be one merged token while "2024" is two. This is a documented contributor to arithmetic errors, and it is why several tokenizers now force digits to split into fixed groups.
- A trailing space changes the token count and sometimes the output, because it becomes part of the next word's token rather than a token of its own.
- The same string tokenizes differently under different models, so a token count is only meaningful alongside the tokenizer that produced it.
Where people go wrong
Treating a space as a separate token and reconstructing text by joining tokens with spaces. It appears to work on simple English and then destroys punctuation, code indentation and any language that does not separate words with spaces. Keeping the leading space inside the token makes decoding a pure concatenation with no rules at all, which is why every production tokenizer does it.
If this comes up in an interview
Implement byte pair encoding: training, encoding and decoding.
Training: split the corpus into words, represent each as a tuple of bytes or characters, then repeatedly count every adjacent pair weighted by word frequency, merge the most frequent pair everywhere, and record it. The output is the ordered merge list. Encoding: split the input the same way and replay the merges in order against each word. Decoding: concatenate the token strings, which works exactly because leading spaces are kept inside the tokens.
Then they push further
Why does the order of the merge list matter?
Because later merges are built from the symbols earlier merges created. "the" only exists as a mergeable symbol after "t"+"h" and then "th"+"e" have happened, so " "+"the" cannot be applied before them. Replaying in a different order gives a different and generally worse segmentation, so the order is the model.
Why start from bytes rather than characters?
So that nothing is unrepresentable. There are 256 possible bytes and every input is a sequence of them, so an unknown token can never occur, whatever alphabet or emoji arrives. A character-level inventory is open-ended and would need an unknown token that the model cannot decode back.
Your encode loops over every merge for every word. How would you make it fast?
Give each merge a rank from its position in the list, then within a word repeatedly find the applicable pair with the lowest rank and apply it, stopping when no pair is applicable. That touches only relevant merges instead of all of them, and produces the same segmentation. Keep the slow version as the test oracle.
What gets marked up. Candidates who have written it keep the leading space inside the token and know decoding is plain concatenation. Handling a space as its own token, and then needing rules about when to re-insert it, is the sign of someone recalling the idea rather than having implemented it.
Why is a tokenizer trained rather than written?
Because which sequences deserve to be one token is a property of the corpus, not of the language. Frequent sequences get merged early and become single tokens; rare ones stay split. That is why the same tokenizer is efficient on the text it was trained on and expensive on code or on a language that was a small share of the training data, and why the merge list has to be shipped with the model.
Why do models struggle with arithmetic and with spelling questions?
Both follow from the segmentation. A number may be one token or three depending on whether that digit sequence was frequent enough to merge, so digits that should be treated positionally arrive in inconsistent groupings — several tokenizers now force digits into fixed groups for exactly this reason. Spelling questions fail for the same reason from the other direction: a word that arrives as a single token gives the model no direct view of its letters.
Build this: Train BPE on a few megabytes of real text — mixed prose and source code — targeting a thousand merges. Print the first fifty merges and the last fifty, and account for the difference. Then encode a paragraph of English, the same paragraph translated into a language written in another script, and a page of your own code, and compare tokens per character across the three. Finally, assert that decoding round-trips exactly on all three, including on text containing an emoji.
This is one section of Tokenization, 10 hours across 4 sections. Read it in full to get the whole topic in order.
Stop 3 of 50 on the AI Engineer route
0 of 50 topics done · 0h behind you, 603h to go
What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.