Byte-pair encoding

BPE builds a vocabulary by starting from single characters and repeatedly merging the most frequent adjacent pair into a new token. The ordered list of merges is the tokenizer. To encode new text, cut it into chunks with a regular expression, then replay the merges inside each chunk.

Basics used here
Key idea
Training: count adjacent pairs, merge the most frequent, repeat until the vocabulary is big enough. Encoding: pre-tokenise, then apply the learned merges in order
Real world
Inventing your own shorthand. You notice you write “th” constantly, so you give it one squiggle. Then “the” (squiggle + e) is so common it gets its own. After a while your list of squiggles, in the order you invented them, is your shorthand system.
In an LLM
GPT-2’s first merge was “Ġ” + “t” (a space then t), its seventh “Ġt” + “he”, making “ the”. After 50,000 merges, “ unbelievable” is one token, while the rarer “ hedgehogs” stops at three: “ hedge”, “h”, “ogs”.

The idea

(byte-pair encoding) started life in 1994 as a data-compression trick: find the most common pair of adjacent bytes in a file, replace it with a single unused byte, repeat. In 2016 Sennrich, Haddow and Birch used it to build vocabularies for translation models. GPT-2 made it the default for language models, and today GPT-4, Llama 3, Mistral, Qwen and most other LLMs use a version of it.

The training algorithm fits in four lines:

  1. Split every word of a training corpus into single characters.
  2. Count every pair of adjacent symbols, across the whole corpus.
  3. Merge the most frequent pair into one new symbol, everywhere it occurs, and add it to the vocabulary.
  4. Repeat from step 2 until the vocabulary is the size you want.

The output is not just a vocabulary. It is an ordered list of merges, and that order is used again when encoding new text.

Training, step by step

This is the example from Sennrich’s paper: a corpus where “low” appears 5 times, “lower” 2 times, “newest” 6 times and “widest” 3 times. Press play and watch the pair counts, the merges and the two curves. Then edit the corpus, or try the other examples.

0 merges
the corpus, as symbols
5×low_
2×lower_
6×newest_
3×widest_
pair counts (top 6 of 14)
es9
st9
t_9
we8
lo7
ow7
vocabulary · 11 = 11 starting symbols + 0 merges
low_ernstid
corpus length (95 symbols)vocabulary size (11)
lowest_
Start: every word is split into single characters plus an end-of-word marker. The corpus is 95 symbols long. The most frequent pair is “e” + “s” (9 times), highlighted in amber: it will be merged first.
BPE training on a tiny corpus. Each step counts every adjacent pair of symbols, merges the most frequent pair everywhere, and adds it to the vocabulary. _ marks the end of a word.

Follow the first few merges. “e” + “s” occurs 9 times (6 in “newest”, 3 in “widest”). Two other pairs, “s” + “t” and “t” + “”, also occur 9 times; when counts tie, the pair seen first wins, so “e” + “s” becomes the symbol “es”. Now “es” + “t” occurs 9 times, so “est” is next, then “est”, an ending that marks the end of a word. Only then does “low” get built. Each merge is chosen on the corpus as it is after the previous merges, which is why the order matters.

Two things happen together, as the chart shows: the corpus gets shorter (fewer, bigger symbols) and the vocabulary grows by one per merge. You stop when the vocabulary reaches the size you chose in advance: 50,257 for GPT-2, about 100,000 for GPT-4, 128,256 for Llama 3.

The box at the bottom encodes a word that is not in the corpus. With the merges learned from “low”, “newest” and “widest”, the unseen word “lowest” comes out as “low” + “est_”: BPE builds new words from the pieces it learned for old ones.

example · your own shorthand

Taking lecture notes, you notice you keep writing “th”. You invent a squiggle for it. Now “the” is squiggle + e, and it is so common you invent a second squiggle for the whole word. Then “ing”, then “tion”. Your list of squiggles, in the order you invented them, is your system. To write a new word, you apply your squiggles in the same order. Frequent things get short; rare things stay spelled out.

Encoding new text

Once trained, the tokenizer encodes any text by replaying the merges. Split a word into single symbols, then repeatedly merge the neighbouring pair that was learned earliest (the lowest merge number), until no neighbouring pair is in the list.

Here is GPT-2’s real tokenizer, with all 50,000 of its merges:

Loading GPT-2 data…
GPT-2’s merges, applied in order.

In “ unbelievable” the first merge is “l” + “e” (merge 38), then the space joins the “u” (merge 79). After twelve merges in all, the whole word is one token: it was common enough in GPT-2’s training text to be merged all the way. Turn off the space and the same letters end as four tokens, “un”, “bel”, “iev”, “able”: in GPT-2, “unbelievable” after a space and at the start of a line are different strings. The quirks page has more of these surprises.

example · “ tokenization” in GPT-2

GPT-2 applies eleven merges to “ tokenization”. The first is “Ġ” + “t” (merge 1, the commonest pair in its training text), then “o” + “n” (merge 6) and “a” + “t” (merge 10). Later ones build “ation” (merge 86), “ization” (merge 1,379) and finally “ token” (merge 10,986). Then it stops at two tokens, “ token” and “ization”: both pieces are common, but the whole word was not common enough in 2019 web text to earn a merge of its own.

Pre-tokenization: where merges may not go

Run BPE on raw text and it will happily learn merges like “dog.” and “dog!” and “dog?”, wasting vocabulary on every combination of word and punctuation. So before BPE runs, text is split into chunks by a fixed rule, and merges never cross a chunk boundary. This step is called .

In GPT-2 the rule is a regular expression that separates letters, numbers, punctuation and whitespace, with a space allowed to stick to the front of the following chunk:

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

\p{L} is any letter, \p{N} any digit. Later tokenizers tweaked it. GPT-4’s splits numbers into groups of at most three digits; GPT-4o’s also starts a new chunk at a capital letter. Compare them:

16 chunks (GPT-4 (cl100k): 17, GPT-4o (o200k): 16)
I'LL·pay·$1234567·for·HelloWorld's·café...·OK?↵↵··Done!
Letters, numbers and punctuation never share a chunk. A single space sticks to the front of the next chunk. English contractions (’s, ’t, ’ll, …) are split off, but only in lower case. Runs of digits stay together, however long.
Pre-tokenisation splits text into chunks with a fixed regular expression; BPE then runs inside each chunk, so no token can cross a chunk boundary. These are the exact rules of three OpenAI tokenizers.

These rules matter more than they look. Splitting numbers into fixed groups of three makes arithmetic more regular for the model. Keeping a space at the front of words is why “ cat” and “cat” are different tokens. And because the GPT-2 rule only knows lower-case contractions, “I'LL” is cut into “I”, “'”, “LL”.

going deeper

The algorithm in code

The training loop from Sennrich et al. (2016), lightly modernised:

from collections import Counter
 
def train_bpe(word_freqs, num_merges):
    # word_freqs: {("l","o","w","</w>"): 5, ...}
    merges = []
    for _ in range(num_merges):
        pairs = Counter()
        for word, f in word_freqs.items():
            for a, b in zip(word, word[1:]):
                pairs[a, b] += f
        if not pairs:
            break
        best = max(pairs, key=pairs.get)
        merges.append(best)
        word_freqs = {merge(w, best): f for w, f in word_freqs.items()}
    return merges
 
def encode_word(symbols, ranks):            # ranks: {(a, b): merge index}
    while len(symbols) > 1:
        pairs = [(ranks.get(p, float("inf")), i) for i, p in enumerate(zip(symbols, symbols[1:]))]
        rank, i = min(pairs)
        if rank == float("inf"):
            break
        symbols = merge(symbols, symbols[i:i + 2])
    return symbols

Encoding by “lowest rank first” gives the same result as replaying all merges in training order, and is much faster. Real implementations (Hugging Face tokenizers, OpenAI’s tiktoken) also keep pair counts up to date incrementally during training instead of recounting the corpus after every merge.

GPT-2’s numbers

GPT-2’s vocabulary is 256+50,000+1=50,257256 + 50{,}000 + 1 = 50{,}257: the 256 possible bytes, one token per merge, and an end-of-text token. The ID of the token made by merge rr (counting from 0) is 256+r256 + r, so “ the” (merge 7, r=6r = 6) has ID 262.

In papers

Related

  • why-tokens - A model needs a fixed list of units to read and write. Characters make the text very long; whole words need a huge list and still meet words they have never seen. Subword tokens sit in between, and every modern LLM uses them.
  • bytes - Computers store text as Unicode code points packed into UTF-8 bytes, 1 to 4 per character. Byte-level BPE starts from the 256 possible bytes, so no text is ever unknown. The price is that scripts with more bytes per character, and less training data, cost many more tokens.
  • wordpiece-unigram - BPE is not the only way to build a subword vocabulary. WordPiece (BERT) merges by likelihood and splits words greedily, longest piece first. Unigram (T5, ALBERT) gives every piece a probability and picks the most probable split with the Viterbi algorithm. SentencePiece is the library that trains both on raw text, spaces included.
  • tokenizer-quirks - A model never sees letters or digits, only token IDs. That one fact explains a surprising list of failures: shaky arithmetic, miscounted letters, words that change meaning with a space or a capital letter, and “glitch tokens” that made GPT-3 behave strangely. Here are real examples from seven tokenizers.