Tokenization & Vocabulary Design

How does byte-pair encoding build tokens from text?

The idea

Byte-pair encoding (BPE) builds tokens by repeatedly merging the most frequent adjacent pair. This character-level example starts with single characters; each merge becomes a rule used to encode new text.

Builds on Maximum Likelihood and Representation Learning & Embedding Geometry.

The picture

A model cannot take in letters or words as such. It reads tokens: entries in a fixed vocabulary, each with a number. Byte-pair encoding (BPE) builds that vocabulary from text, by counting.

Tiles. Every character starts as its own tile. BPE counts each pair of neighbours, takes the pair that occurs most often, and fuses every copy of it into one new tile. Then it counts again. Strings the text repeats grow into single tiles; strings it sees once can stay in pieces. The toy below stops when no pair occurs twice or its merge budget is reached; BPE training usually fixes a vocabulary or merge budget.

Rules. Each fusion is written down as a merge rule, and the list of rules, in order, is the tokenizer. Given new text, it splits it into characters and replays the rules. A familiar word comes out whole; an unfamiliar one comes out in fragments, provided its characters are in the starting vocabulary. Byte-level BPE avoids this character-coverage limit by starting from all 256 byte values.

Bytes. Characters are not the bottom layer. Text is stored as UTF-8 bytes: one per English letter, two to four for most other scripts and for emoji. A tokenizer that starts from bytes can spell anything with 256 symbols, but the same message can start out several times longer in one script than in another.

Fewer tokens means a shorter sequence for everything downstream: less computation, a smaller cache of past tokens, more text in the same context window. More tokens in the vocabulary means larger tables to store and learn.

Predict first

The text is "banana bandana banana": 21 characters, each its own tile. Before you touch the figure: which pair of neighbours will be glued together first?

The mathematics

Let the vocabulary be a set of strings (or byte sequences) V\mathcal V. A tokenizer maps an input string xx into a sequence of tokens (where nn is the token count):

x=concat(t1,…,tn),ti∈V.x = \mathrm{concat}(t_1,\dots,t_n), \qquad t_i \in \mathcal V.

BPE and unigram tokenization search in different ways

BPE (Sennrich et al., 2016) learns vocabulary entries by merging frequent adjacent symbols. Following Kudo's unigram-language-model formulation, unigram tokenization instead scores candidate segmentations with token probabilities p(t)p(t). For the fixed vocabulary V\mathcal V, these form a normalized distribution: p(t)≥0p(t)\ge 0 and ∑t∈Vp(t)=1\sum_{t\in\mathcal V}p(t)=1. In the display below, the left rule is one BPE merge step (fuse the most frequent adjacent pair a,ba,b into a new symbol abab); the right is the unigram choice, the segmentation of xx with the highest total log-probability:

(a,b)∗=arg⁡max⁡(a,b)  count(ab)⇒a b→ab,t^1:n=arg⁡max⁡t:  concat(t)=x  ∑i=1nlog⁡p(ti).(a,b)^* = \arg\max_{(a,b)}\;\mathrm{count}(ab) \Rightarrow a\,b \to ab, \qquad \hat t_{1:n} = \arg\max_{t:\;\mathrm{concat}(t)=x}\;\sum_{i=1}^n \log p(t_i).

The unigram path is found by dynamic programming over string positions (the Viterbi algorithm). The BPE merges are learned greedily, once, and then replayed in order as fixed rules on every new text.

Vocabulary size affects parameter count

If embeddings and output logits both use a ∣V∣×d|\mathcal V|\times d matrix, then token-related parameters scale like:

paramstoken≈2 ∣V∣ d.\mathrm{params}_{\mathrm{token}} \approx 2\,|\mathcal V|\,d.

If the input and output share one matrix (tied embeddings), this halves to ∣V∣ d|\mathcal V|\,d. The tokenizer is not just preprocessing: it sets part of the model's size, the length of every sequence, and which strings the model sees as one thing.

Try it

First a worked example, then one to finish, then problems on your own. Check your answer before you look at the solution. Answers typed here are not kept when you leave.

  1. 1 · Worked example

    The figure's merges, by hand

    Run BPE on "banana bandana banana" until it stops. Write the space as ·. When two pairs tie, the one that appears first in the text wins.

    1. Count. a+n 6, n+a 5, b+a 3, a+· 2, ·+b 2, n+d 1, d+a 1. Merge a+n into an: 21 tiles become 15.

    Say the next step to yourself first.
  2. 2 · Finish it

    Finish a second text

    The text is "the cat sat on the mat with the hat" (35 characters). The first three merges are done. Make the fourth: how many tokens are left after it?

    1. Merge 1. t+h, counted 4 times: 35 tokens become 31.

    2. Merge 2. a+t, counted 4 times: 31 become 27.

    3. Merge 3. th+e, counted 3 times: 27 become 24. The text now reads the · c at · s at · o n · the · m at · w i th · the · h at.

    4. Your turn.

  3. 3 · On your own

    Meeting a new word

    A trained tokenizer does not relearn on new text; it replays its merge rules in order. Take the four rules learned in the first exercise (a+n, then b+an, then an+a, then ban+ana) and apply them to "bandanas". Assume "s" is also in the starting character vocabulary. How many tokens come out?

  4. 4 · On your own

    What a vocabulary costs

    A model has a vocabulary of 50,000 tokens and vectors of width d=768d = 768. Its input embedding table and its output table are separate matrices. How many parameters do the two tables hold together, in millions?

The code

Both algorithms in plain Python, standard library only. learn_bpe_merges counts pairs inside words, weighted by how often each word occurs; unigram_map finds the most probable segmentation of one string.

from collections import Counter
import math

def learn_bpe_merges(corpus, num_merges=3):
    vocab = {tuple(word): freq for word, freq in corpus.items()}
    merges = []
    for _ in range(num_merges):
        counts = Counter()
        for symbols, freq in vocab.items():
            for pair in zip(symbols, symbols[1:]):
                counts[pair] += freq
        if not counts:
            break
        best = max(counts, key=counts.get)
        merges.append(best)
        merged_vocab = {}
        for symbols, freq in vocab.items():
            out, i = [], 0
            while i < len(symbols):
                if i + 1 < len(symbols) and (symbols[i], symbols[i + 1]) == best:
                    out.append(symbols[i] + symbols[i + 1])
                    i += 2
                else:
                    out.append(symbols[i])
                    i += 1
            merged_vocab[tuple(out)] = freq
        vocab = merged_vocab
    return merges, vocab

def unigram_map(x, tok_logp):
    n, NEG = len(x), -1e30
    dp, back = [NEG] * (n + 1), [None] * (n + 1)
    dp[0] = 0.0
    buckets = {}
    for tok, lp in tok_logp.items():
        buckets.setdefault(tok[0], []).append((tok, lp))
    for i in range(n):
        if dp[i] <= NEG / 2:
            continue
        for tok, lp in buckets.get(x[i], []):
            if x.startswith(tok, i):
                j, s = i + len(tok), dp[i] + lp
                if s > dp[j]:
                    dp[j], back[j] = s, (i, tok)
    if back[n] is None:
        return None
    out, j = [], n
    while j > 0:
        i, tok = back[j]
        out.append(tok)
        j = i
    return out[::-1], dp[n]

merges, vocab = learn_bpe_merges({"low": 5, "lower": 2, "new": 6, "newer": 3}, num_merges=4)
print("bpe merges:", merges)
print("bpe vocab:", vocab)

raw_weights = {
    "the": 0.08, "there": 0.02, "re": 0.05, "th": 0.06,
    "e": 0.07, "r": 0.03, "t": 0.02, "h": 0.02,
}
normalizer = sum(raw_weights.values())
probs = {t: weight / normalizer for t, weight in raw_weights.items()}
assert all(p > 0.0 for p in probs.values())
assert abs(sum(probs.values()) - 1.0) < 1e-12
tok_logp = {t: math.log(p) for t, p in probs.items()}
x = "there"
res = unigram_map(x, tok_logp)
assert res is not None, "No valid segmentation"
seg, score = res
print("x:", x)
print("raw-weight normalizer:", round(normalizer, 3))
print("probability sum:", round(sum(probs.values()), 12))
print("seg:", seg, "tokens:", len(seg), "logp:", round(score, 3))

On its four-word corpus BPE learns ne, new, lo, low, in that order. The unigram model keeps "there" as a single token, with log-probability −2.862, because every split into smaller pieces scores lower.

Keep it

  1. Why can this toy not shrink the displayed string "3.14159265358979323846", however many merges it is allowed?

    Answer from memory, then check

    A merge needs a pair of neighbours that occurs at least twice, and no adjacent pair of digits repeats in that string. Rare strings (IDs, hashes, unfamiliar languages) meet the same wall in real tokenizers and come out as many short tokens.

  2. A trained tokenizer meets a word it never saw. What happens?

    Answer from memory, then check

    It splits the word into its smallest units and replays its merge rules in order. The word comes out as several familiar pieces, in the worst case single characters or bytes, provided those units are in its vocabulary. A character tokenizer can encounter an unknown character; one starting from all 256 bytes can encode any UTF-8 text.

  3. What does doubling the vocabulary buy, and what does it cost?

    Answer from memory, then check

    More strings become single tokens, so sequences get shorter: less attention compute, a smaller cache, more text per context window. The price is larger embedding and output tables (about 2∣V∣d2|\mathcal V|d parameters, or ∣V∣d|\mathcal V|d if tied) and fewer training examples for each rare token.

  4. In "aaaaaa", a+a is counted 5 times, yet merging it removes only 3 tiles. Why, and which pairs can do this?

    Answer from memory, then check

    Neighbouring copies share an "a", so only every other copy can merge: aa aa aa. Only a token paired with itself can overlap like this; any other pair removes exactly as many tiles as it was counted.

  5. Why can the same message cost several times more tokens in Hindi than in English?

    Answer from memory, then check

    Each Devanagari character is 3 UTF-8 bytes where an English letter is 1, and the merges were mostly learned from English text, so fewer long Hindi tokens exist to pack those bytes back together.

  6. BPE and unigram tokenizers both split text into vocabulary items. How does each decide the split?

    Answer from memory, then check

    BPE replays merge rules learned greedily, in the order they were learned. Unigram keeps a probability for every token and picks the segmentation with the highest total log-probability, found by dynamic programming.

Recalling an answer from memory makes it last longer than rereading it (Roediger and Karpicke, 2006). Add these 6 cards and they come back tomorrow, then after gaps of 3, 7, 16, 35 and 75 days while you still remember them. They stay in this browser only.

The edge

Nobody has settled what a model's atoms should be. Merges learned mostly from English split other languages into more pieces, so the same message can cost several times as many tokens, and so more money and more context, in some languages than in others (Petrov et al., 2023). Byte-level models drop the learned vocabulary and read raw UTF-8 (ByT5: Xue et al., 2022), at the price of much longer sequences. Newer designs group bytes into patches whose length follows how hard the next byte is to predict (Pagnoni et al., 2025). Whether a fixed tokenizer is a necessary compromise or an avoidable handicap is still open.

Next thoughtThe text is now a row of tokens, each looked up as a vector that passes through layer after layer. What puts each layer's input on a steady scale, using only that token's own numbers?Layer Normalization & RMSNorm →
The tokenizer microscope: unigram and byte-level modes, Unicode normalization, cost estimates and the token-count challenge