AI Knowledge Tree

Subword tokenization: BPE, WordPiece, SentencePiece

Implement BPE merge training on a toy corpus and encode a new word at inference; contrast WordPiece likelihood and SentencePiece unigram variants.

⏱ 35 minPrerequisites:N-gram language models

A neural network operates on tensors of floats; text is a sequence of Unicode code points. Tokenization is the deterministic, learned-once function that converts between them. The choice of tokenizer is permanent: once a model is trained, its tokenizer is frozen, because the embedding matrix has one row per vocabulary entry.

Why not words or characters?

Word-level: every misspelling, neologism, or rare inflection is out-of-vocabulary (OOV). Vocabulary size reaches millions; most entries appear too rarely to train good embeddings.

Character-level: no OOV problem, but sequences are 5-10x longer. Transformer attention costs O(T2)O(T^2) in sequence length, making character-level prohibitively expensive.

Subword (the winning middle): frequent words become single tokens (short sequences); rare words decompose into subword pieces (no OOV). Typical vocabulary: 30K-200K entries[3].

Byte-pair encoding (BPE)

BPE is a compression algorithm adapted for tokenization[1].

Training algorithm

Inputs: a text corpus and a target vocabulary size VV.

1. Initialise vocab with all bytes (256 entries for byte-level BPE).
2. Represent every word as a sequence of base vocabulary symbols.
3. Repeat until |vocab| == V:
     a. Count all adjacent symbol pairs across the corpus.
     b. Merge the most-frequent pair (a, b) into a new symbol ab.
     c. Replace every occurrence of (a, b) with ab in the corpus.
4. Output: the vocabulary and the ordered merge table.

The merge table is an ordered list of rules. Order matters: encoding new text applies rules in the same order they were learned.

Worked example

Corpus (word, frequency):

"low"    : 5   →  l o w </w>
"lower"  : 2   →  l o w e r </w>
"newest" : 6   →  n e w e s t </w>
"widest" : 3   →  w i d e s t </w>

Pair counts: (e, s) appears 6+3=96 + 3 = 9 times (highest). Merge to es. Next: (es, t) appears 99 times. Merge to est. Next: (est, </w>) appears 99 times. Merge to est</w>.

After enough merges, "newest" becomes the single token newest</w> and "lower" becomes low + er</w>.

Encoding new text

Apply the merge table in order to the character sequence of each new word:

"widest" → w i d e s t </w>
apply (e,s)→es:   w i d es t </w>
apply (es,t)→est: w i d est </w>
apply (est,</w>)→est</w>: w i d est</w>
result: ["w", "i", "d", "est</w>"]

BPE implementation

python · numpy
from collections import Counter

def get_pairs(corpus):
    pairs = Counter()
    for word, freq in corpus.items():
        syms = word.split()
        for i in range(len(syms) - 1):
            pairs[(syms[i], syms[i + 1])] += freq
    return pairs

def merge_pair(pair, corpus):
    a, b = pair
    ab = a + b
    new_corpus = {}
    for word, freq in corpus.items():
        syms = word.split()
        out, i = [], 0
        while i < len(syms):
            if i < len(syms) - 1 and syms[i] == a and syms[i + 1] == b:
                out.append(ab); i += 2
            else:
                out.append(syms[i]); i += 1
        new_corpus[" ".join(out)] = freq
    return new_corpus

corpus = {
    "l o w </w>": 5,
    "l o w e r </w>": 2,
    "n e w e s t </w>": 6,
    "w i d e s t </w>": 3,
}
merges = []
for _ in range(8):
    pairs = get_pairs(corpus)
    if not pairs:
        break
    best = max(pairs, key=pairs.get)
    corpus = merge_pair(best, corpus)
    merges.append(best)
    print(f"merge: {best[0]} + {best[1]} -> {best[0]+best[1]}")

print("\nFinal corpus:")
for word, freq in corpus.items():
    print(f"  {word!r}: {freq}")

WordPiece

WordPiece (used by BERT) chooses merges differently: instead of the most-frequent pair, it picks the pair (a,b)(a, b) that maximizes:

c(ab)c(a)⋅c(b).\frac{c(ab)}{c(a) \cdot c(b)}.

This is the pointwise mutual information (PMI) of the pair. Merging high-PMI pairs tends to produce more linguistically meaningful subwords than pure frequency.

WordPiece represents out-of-vocabulary words by marking internal subword pieces with ##: "playing" might tokenize as ["play", "##ing"].

SentencePiece and the unigram model

SentencePiece differs in two ways:

  1. It treats whitespace as a regular character (represented as ▁), so it can handle any language without a pre-tokenizer regex.
  2. It offers a unigram language model variant alongside BPE. The unigram model starts with a large vocabulary and prunes entries, keeping those that minimize[2]:
L=−∑s∈Clog⁡p(s),p(s)=∑x∈seg(s)∏xi∈xp(xi),\mathcal{L} = -\sum_{s \in \mathcal{C}} \log p(s), \quad p(s) = \sum_{\mathbf{x} \in \text{seg}(s)} \prod_{x_i \in \mathbf{x}} p(x_i),

where seg(s)\text{seg}(s) is the set of all segmentations of sentence ss and p(xi)p(x_i) is the unigram probability of piece xix_i. The sum-over-segmentations is computed with the forward algorithm.

Vocabulary sizes in practice

ModelTokenizerVocab size
GPT-2byte-level BPE50,257
GPT-4 (cl100k_base)byte-level BPE100,256
LLaMA-2SentencePiece BPE32,000
LLaMA-3SentencePiece BPE128,000

Larger vocabularies cover non-Latin scripts more efficiently (a Chinese character needs 3 bytes; without a specific vocabulary entry it costs 3 tokens). The tradeoff: each vocabulary entry adds one row to the embedding matrix and the LM head.

Exercises

Exercise 1. Continue the worked BPE example by hand. After merging (e,s) -> es, (es,t) -> est, and (est,</w>) -> est</w>, find the next most frequent pair across the corpus and state the merge. Corpus (word, frequency): l o w </w>:5, l o w e r </w>:2, n e w e s t </w>:6, w i d e s t </w>:3.

Solution

Step 1. Apply the three merges already done. "newest" becomes n e w est</w> (the e s t </w> tail collapses to est</w>), and "widest" becomes w i d est</w>. "low" and "lower" contain no e s pair, so they are unchanged: l o w </w> and l o w e r </w>.

Step 2. Count adjacent pairs over the current symbol sequences, weighting by word frequency:

  • (l, o): appears in "low" (5) and "lower" (2) gives 5+2=75 + 2 = 7.
  • (o, w): "low" (5) and "lower" (2) gives 77.
  • (w, </w>): "low" only gives 55.
  • (w, e): "lower" (2) and "newest" (n e w est</w> has w then est</w>, not e) gives 22.
  • (n, e), (e, w), (w, est</w>): each from "newest" only, 66.
  • (w, i), (i, d), (d, est</w>): each from "widest" only, 33.

Step 3. The maximum is the tie between (l, o) and (o, w) at 77, both above the 66 from "newest". Breaking the tie by first-encountered (the convention max uses on insertion order), the next merge is (l, o) -> lo, with frequency 77.

Exercise 2. WordPiece scores a candidate merge (a,b)(a, b) by c(ab)c(a) c(b)\frac{c(ab)}{c(a)\,c(b)} rather than raw frequency c(ab)c(ab). Consider two candidate pairs: pair P with c(ab)=100c(ab) = 100, c(a)=1000c(a) = 1000, c(b)=500c(b) = 500; pair Q with c(ab)=30c(ab) = 30, c(a)=40c(a) = 40, c(b)=50c(b) = 50. Which does raw-frequency BPE merge first, and which does WordPiece merge first?

Solution

Step 1. Raw-frequency BPE picks the larger c(ab)c(ab). Pair P has 100100 versus pair Q's 3030, so BPE merges P first.

Step 2. WordPiece computes the score c(ab)c(a) c(b)\frac{c(ab)}{c(a)\,c(b)} for each:

score(P)=1001000⋅500=100500000=2×10−4,\text{score}(P) = \frac{100}{1000 \cdot 500} = \frac{100}{500000} = 2 \times 10^{-4},score(Q)=3040⋅50=302000=1.5×10−2.\text{score}(Q) = \frac{30}{40 \cdot 50} = \frac{30}{2000} = 1.5 \times 10^{-2}.

Step 3. score(Q)=0.015\text{score}(Q) = 0.015 is 7575 times larger than score(P)=0.0002\text{score}(P) = 0.0002, so WordPiece merges Q first. The reason is that P's components aa and bb are each very common on their own, so co-occurring is unremarkable; Q's components are rare individually but almost always appear together, which is the high-PMI signal WordPiece rewards.

Exercise 3 (code). Implement BPE training on the worked corpus and confirm that the first three learned merges are (e,s), (es,t), (est,</w>), matching the hand derivation.

Solution
python · numpy
from collections import Counter

def get_pairs(corpus):
    pairs = Counter()
    for word, freq in corpus.items():
        syms = word.split()
        for i in range(len(syms) - 1):
            pairs[(syms[i], syms[i + 1])] += freq
    return pairs

def merge_pair(pair, corpus):
    a, b = pair
    ab = a + b
    new_corpus = {}
    for word, freq in corpus.items():
        syms = word.split()
        out, i = [], 0
        while i < len(syms):
            if i < len(syms) - 1 and syms[i] == a and syms[i + 1] == b:
                out.append(ab); i += 2
            else:
                out.append(syms[i]); i += 1
        new_corpus[" ".join(out)] = freq
    return new_corpus

corpus = {
    "l o w </w>": 5,
    "l o w e r </w>": 2,
    "n e w e s t </w>": 6,
    "w i d e s t </w>": 3,
}
merges = []
for _ in range(3):
    pairs = get_pairs(corpus)
    best = max(pairs, key=pairs.get)
    corpus = merge_pair(best, corpus)
    merges.append(best)

assert merges == [("e", "s"), ("es", "t"), ("est", "</w>")]
print("first three merges:", merges)

The (e,s) pair wins the first round at frequency 9 (newest 6 + widest 3); each subsequent suffix pair inherits the same 9 occurrences, so the suffix est</w> is built greedily, exactly as derived by hand.

The next subsection moves to neural models that consume these token sequences.


References

  1. [1]Sennrich, R., Haddow, B., & Birch, A. (2016). "Neural machine translation of rare words with subword units (BPE)". ACL. arXiv:1508.07909↩
  2. [2]Kudo, T. (2018). "Subword regularization: improving NMT models with multiple subword candidates". ACL. The unigram-LM tokenizer and its segmentation likelihood. arXiv:1804.10959↩
  3. [3]Jurafsky, D., & Martin, J. H. (2024). Speech and Language Processing (3rd ed. draft). Subword tokenization and the BPE algorithm.↩