ml-models / Language models / Tokenization intro

Tokenization

A language model never sees letters or words. It sees integers, each one the ID of a token from a fixed vocabulary. Tokenization is the step that cuts text into those tokens. This page compares characters, words and subwords, runs byte-pair encoding by hand on a tiny corpus, contrasts it with WordPiece and the unigram model, and shows how the choice changes cost, arithmetic and fairness across languages.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Byte-pair encoding (BPE)
  5. WordPiece
  6. The unigram language model
  7. Byte-level BPE
  8. Special tokens
  9. Worked example
  10. BPE on a toy corpus
  11. WordPiece chooses differently
  12. Unigram segmentation of "lowest"
  13. Bytes, numbers and cost
  14. Implementation
  15. Tradeoffs
  16. Check yourself
  17. Connections
  18. Read first
  19. Read next
  20. References

Problem

A neural network takes numbers. To feed it text we need a function from strings to sequences of integers and back, so that each integer can index a row of an embedding table and so that the model's output can be a probability over a fixed list. The list must be small enough to fit (the embedding table and the output layer are both vocabulary-sized) yet expressive enough that any text, including names, typos, code and every language, can be written with it. Sequences should also be short, because a Transformer's cost grows with sequence length.

Intuition

There are three natural ways to cut text. Characters give a tiny vocabulary and never meet an unknown symbol, but sequences are long and each token carries little meaning. Words give short sequences, but the vocabulary is enormous and still misses new words ("unfriendliest"), which become an unknown token. Subwords sit between: frequent words are one token, rare words split into frequent pieces (un · friend · li · est). A subword tokenizer is learned from data: it keeps whatever pieces let it write the training text compactly.

The same word at three granularities The word lowest written as characters, six tokens l, o, w, e, s, t plus an end-of-word marker; as a word, one token that is not in a vocabulary built from low, lower, newest and widest, so it becomes unknown; and as subwords learned by BPE from that corpus, two tokens: low and est with the end marker. characters l o w e s t </w> 7 tokens words <unk> 1 token BPE subwords low est</w> 2 tokens "lowest" never appears in the corpus; BPE still writes it from pieces it learned

Mechanics

Byte-pair encoding (BPE)

BPE, adapted to word segmentation by Sennrich, Haddow and Birch, starts with every word split into characters plus an end-of-word marker (written </w> here). Then it repeats: count every adjacent pair of symbols across the corpus, weighted by word frequency; merge the most frequent pair into a new symbol everywhere; add that symbol to the vocabulary. The number of merges is the one hyperparameter, and it sets the vocabulary size. The output is an ordered list of merges. Encoding a new word replays those merges in the order they were learned, so a word the tokenizer never saw still comes out as known pieces, down to single characters in the worst case.

WordPiece

WordPiece (from Schuster and Nakajima's speech work, later used by Google's translation system and by BERT) builds its vocabulary the same way, merge by merge, but chooses the merge that most increases the likelihood of the training data under a unigram language model over the current units, not the most frequent pair. Frequent pairs of rare units gain the most. BERT marks pieces that continue a word with ## (play · ##ing) and encodes a word greedily, longest matching piece first.

The unigram language model

The unigram method (Kudo, 2018) works the other way round. It starts from a large set of candidate pieces, gives each a probability, and treats a segmentation's probability as the product of its pieces' probabilities. Encoding picks the most probable segmentation with the Viterbi algorithm. Training alternates expectation-maximization over the piece probabilities with pruning the pieces whose removal hurts the corpus likelihood least, keeping single characters so everything stays writable. Because it defines a distribution over segmentations, it can also sample one, a regularizer Kudo calls subword regularization. SentencePiece implements both BPE and unigram directly on raw text, treating the space as an ordinary symbol (shown as ▁), so no language-specific pre-splitting is needed and decoding is lossless.

Byte-level BPE

Unicode has well over a hundred thousand characters, so even a character base vocabulary is large and can still meet unseen characters. Byte-level BPE, used by GPT-2, runs BPE over the UTF-8 bytes of the text instead. The base vocabulary is exactly 256 byte values, so there is no unknown token: any string is some sequence of bytes. GPT-2's vocabulary has 50,257 entries: 256 bytes, 50,000 merges and one end-of-text token. The price is that characters outside ASCII start as several tokens and only shrink where the training data had enough of them to learn merges.

Special tokens

Vocabularies reserve IDs for symbols that are not text: beginning and end of sequence (so the model learns where to start and when to stop), padding (to make a batch rectangular), unknown (for tokenizers that have one), separators and, in chat models, role markers. Padding needs care twice: its embedding should not be trained (nn.Embedding(padding_idx=…) keeps that row fixed and out of the gradient) and its positions must not count in the loss (CrossEntropyLoss(ignore_index=-100)). The attention mask stops real tokens from attending to padding.

From text to model input Two sentences pass through the tokenizer into IDs with beginning and end markers, then are padded to the same length with ID 0. Below each ID row is its attention mask, ones for real tokens and zero for padding, and the shifted training targets, with minus one hundred where the target is padding so the loss ignores it. The IDs index rows of the embedding table. ids mask targets 1 3 4 5 2 1 3 6 2 0 1 1 1 1 1 1 1 1 1 0 3 4 5 2 3 6 2 -100 nn.Embedding(V, d, padding_idx=0) CrossEntropyLoss(ignore_index=-100)

Vocabulary: <pad>=0, <bos>=1, <eos>=2, the=3, cat=4, sat=5, dog=6. Targets are the IDs shifted one step left, for teacher forcing.

ComponentShape/ParamsMeaningSource
nn.Embedding num_embeddings = V, embedding_dim = d; weight [V, d] The table the token IDs index; its size is set by the tokenizer's vocabulary. Embedding.__init__
padding_idx int, negative counts from the end That row starts at zero and gets no gradient, so padding stays a fixed vector. _fill_padding_idx_with_zero
Embedding.forward IDs [B, T] → [B, T, d] A row lookup through F.embedding. lines 188-197
F.embedding input, weight, padding_idx, max_norm The functional lookup the module calls. functional.py
CrossEntropyLoss(ignore_index) default -100 Target positions with this value add nothing to the loss: padding and other non-targets. __init__

Worked example

BPE on a toy corpus

Corpus word counts: low ×5, lower ×2, newest ×6, widest ×3. Split into characters plus </w>, that is 95 tokens over a base vocabulary of 11 symbols. Each merge takes the most frequent adjacent pair (ties go to the pair seen first):

MergePairCountVocabularyCorpus tokens
1e + s91286
2es + t91377
3est + </w>91468
4l + o71561
5lo + w71654
6n + e61748
7ne + w61842
8new + est</w>61936
9low + </w>52031
10w + i32128

Each merge shrinks the corpus by exactly its count. After 10 merges the words read low</w>; low · e · r · </w>; newest</w>; wi · d · est</w>. Replaying the merges on words the corpus never had: lowest → low · est</w>, newer → new · e · r · </w>, wider → wi · d · e · r · </w>.

WordPiece chooses differently

On the same starting corpus, the likelihood gain of each candidate first merge (change in the corpus log-likelihood, in nats, under a unigram model over the units) ranks the pairs like this:

PairCount (BPE score)Likelihood gain
s + t929.77
l + o724.99
t + </w>918.80
e + s918.02
o + w714.03
i + d313.32

BPE's first merge is e + s (count 9); WordPiece's is s + t. Both have count 9, but s and t never appear apart here, so merging them makes the unit counts much more predictable. l + o, with count 7, beats several pairs of count 9 for the same reason.

Unigram segmentation of "lowest"

With a toy piece vocabulary (l 0.04, o 0.04, w 0.04, e 0.06, s 0.04, t 0.04, lo 0.05, low 0.12, we 0.03, est 0.1, es 0.05, west 0.02, lowe 0.01), "lowest" has 14 segmentations. The most probable ones:

Segmentationlog pShare of all segmentations
low · est-4.4230.888
lo · west-6.9080.074
low · es · t-8.3350.018
lo · w · est-8.5170.015

Viterbi finds low · est (log p = ln 0.12 + ln 0.10 = -4.423) without listing all 14. Subword regularization would sample: about 7% of the time it would train on lo · west instead.

Bytes, numbers and cost

  • cat: 3 characters, 3 UTF-8 bytes [99, 97, 116]
  • café: 4 characters, 5 UTF-8 bytes [99, 97, 102, 195, 169]
  • 猫: 1 character, 3 UTF-8 bytes [231, 140, 171]
  • 🐈: 1 character, 4 UTF-8 bytes [240, 159, 144, 136]

Before any merges, byte-level BPE spends 3 tokens on the one-character Chinese word for cat and 4 on the emoji. Merges recover this only for scripts common in the training data, so the same sentence can cost several times more tokens in one language than another, which means more compute, a higher price per request and less text in the same context window.

Numbers have the same issue. Chunking digits into groups of three from the left gives 12345 → 123 · 45 but 1234 → 123 · 4: the token 123 means 12,300 in one and 1,230 in the other, so place value is not visible from the token. Grouping from the right, 1234567 → 1 · 234 · 567, lines groups up with thousands; splitting every digit makes every number position-consistent at the cost of longer sequences. Learned BPE merges over digits give neither guarantee, one reason language models are clumsy at arithmetic.

Implementation

from collections import Counter

def train_bpe(word_counts, num_merges):
    words = {tuple(w) + ("</w>",): c for w, c in word_counts.items()}
    merges = []
    for _ in range(num_merges):
        pairs = Counter()
        for word, c in words.items():
            for a, b in zip(word, word[1:]):
                pairs[a, b] += c
        if not pairs:
            break
        best = max(pairs, key=pairs.get)       # most frequent pair
        merges.append(best)
        words = {merge(w, best): c for w, c in words.items()}
    return merges

def merge(word, pair):
    out, i = [], 0
    while i < len(word):
        if word[i:i + 2] == pair:
            out.append(word[i] + word[i + 1]); i += 2
        else:
            out.append(word[i]); i += 1
    return tuple(out)

def encode(word, merges):
    tokens = tuple(word) + ("</w>",)
    for pair in merges:                         # replay in learned order
        tokens = merge(tokens, pair)
    return tokens

# into the model: ids [B, T] -> vectors [B, T, d]
import torch.nn as nn
emb = nn.Embedding(num_embeddings=50257, embedding_dim=768, padding_idx=0)

Production tokenizers apply merges by rank with a priority queue instead of looping over the whole merge list, and pre-split text (on spaces, punctuation or character classes) before BPE so merges never cross those boundaries. The vocabulary size and num_embeddings must match: an ID at or above it fails the lookup.

Tradeoffs

  • Vocabulary size. A bigger vocabulary means shorter sequences (cheaper attention, more text per context window) but bigger embedding and output layers and rarer, less-trained tokens.
  • BPE versus unigram. BPE is simple and deterministic. Unigram gives a probability for every segmentation, enabling sampling as a regularizer; WordPiece sits between, merging by likelihood.
  • Bytes versus characters. Byte-level vocabularies can write anything with no unknown token, but non-Latin scripts start at 2-4 tokens per character.
  • Fairness and cost. A tokenizer trained mostly on English text makes other languages longer, so they pay more and fit less in context.
  • Frozen choice. The tokenizer is fixed before training; changing it means retraining or at least re-learning the embedding table. Its quirks (spaces attached to words, odd number splits) become the model's quirks.

Check yourself

Why can a BPE tokenizer encode a word it never saw?

Its base vocabulary contains every character (or every byte), and merges only ever combine existing symbols. In the worst case the word stays split into single characters or bytes.

Why does each merge shrink the corpus by exactly its count?

Every occurrence of the pair turns two symbols into one, so the token count drops by the number of occurrences, the pair's weighted count (9 for the first merge here). Overlapping pairs like a a a are the one exception: only non-overlapping occurrences merge.

What two things must happen to padding tokens in training?

They must not be attended to (the attention mask) and must not count in the loss (ignore_index); keeping their embedding fixed with padding_idx is a common extra.

Connections

Read first

Nothing: this page is a starting point.

Read next

No page builds on this one yet.

References

  • Sennrich, Haddow and Birch. Neural Machine Translation of Rare Words with Subword Units. ACL, 2016 (arXiv 1508.07909).
  • Kudo and Richardson. SentencePiece: A simple and language independent subword tokenizer and detokenizer for Neural Text Processing. EMNLP demo, 2018 (arXiv 1808.06226).
  • Kudo. Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates. ACL, 2018 (arXiv 1804.10959).
  • Schuster and Nakajima. Japanese and Korean Voice Search. ICASSP, 2012 (the original WordPiece).
  • Wu et al. Google's Neural Machine Translation System: Bridging the Gap between Human and Machine Translation. 2016 (arXiv 1609.08144).
  • Devlin, Chang, Lee and Toutanova. BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. NAACL, 2019 (arXiv 1810.04805).
  • Radford, Wu, Child, Luan, Amodei and Sutskever. Language Models are Unsupervised Multitask Learners (GPT-2, byte-level BPE). OpenAI, 2019.
  • PyTorch v2.15.0: Embedding, F.embedding, CrossEntropyLoss.