Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Two services receive the same visible string, prompt cache failed unexpectedly. Service A normalizes and tokenizes with artifact A; service B uses artifact B. Their logs still print identical text, but their integer sequences can differ, so the model looks up different embedding rows before it performs any attention.
The mismatch happens before model computation. Tokenization is part of the model input contract, not a cosmetic preprocessing step. A common subword method, Byte Pair Encoding (BPE), learns merges between frequent adjacent pieces.
The previous lesson argued that methods which improve with data and compute tend to outlast hand-written rules. A frozen word list is a rule: it knows only strings that earned entries. A subword vocabulary learns reusable fragments from corpus counts, then serves them through a fixed artifact.
Byte Pair Encoding (BPE), WordPiece, and SentencePiece make different choices about fragments, boundaries, and fallbacks. We'll follow one status line from characters to IDs, trigger its failure modes, and measure the tradeoffs.


An embedding is a vector stored at a token ID. Each integer is an address, not a universal meaning. ID 421 can point to different vectors under two tokenizers, while a reserved ID can mark a sequence boundary instead of a text fragment.
Pair each model checkpoint with its exact tokenizer artifact, including its normalizer, vocabulary, merge rules, special-token map, and fallback behavior. The next lesson teaches those vectors.
Choose pieces between characters and words
A word-level tokenizer could keep unexpectedly as one item, but it needs a policy for every rare identifier, version string, and prompt marker.
A character-level tokenizer keeps a small base vocabulary and can represent rare text, but it turns a short status line into a long sequence. Subword tokenization keeps frequent fragments whole while retaining smaller fallback pieces for the rest.
The running fixture is that status line, including its three spaces:
1prompt cache failed unexpectedlyBefore looking at a trainer, predict what each unit gives up. Words keep this line short until a missing word needs an unknown-token policy. Characters preserve coverage but spend positions. Subwords try to keep both costs bounded.
| Token unit | Example pieces | What it buys you | What it costs |
|---|---|---|---|
| Character | p r o m p t <space> ... | Every character, including whitespace, is representable | Long input sequence |
| Word | prompt, cache, failed, unexpectedly | Short common strings | Rare words and IDs need fallbacks |
| Subword | prompt, cache, failed, un, expect, ed, ly | Compact common patterns with fallback parts | Requires a learned vocabulary |

The seven-piece split is a teaching fixture, not a learned result. Concatenating the pieces rebuilds the line only because leading spaces stay attached to later tokens. The trainer will later show which fragments this corpus earns, so you can compare a candidate split with a deployed artifact.
1message = "prompt cache failed unexpectedly"
2
3characters = list(message)
4words = message.split()
5subwords = ["prompt", " cache", " failed", " un", "expect", "ed", "ly"]
6
7print("characters:", len(characters))
8print("words:", len(words))
9print("candidate subwords:", len(subwords), [piece.replace(" ", "·") for piece in subwords])
10assert "".join(subwords) == message1characters: 32
2words: 4
3candidate subwords: 7 ['prompt', '·cache', '·failed', '·un', 'expect', 'ed', 'ly']Four words look cheapest until unexpectedly is missing from the vocabulary. Subwords spend extra vocabulary rows to preserve a fallback path. That sequence-versus-coverage decision drives the merge algorithms next.
Train byte pair encoding from counts
Philip Gage introduced BPE as a compression algorithm that repeatedly replaces the most frequent adjacent pair of bytes with a new symbol.[1] Sennrich, Haddow, and Birch adapted the same loop to open-vocabulary neural machine translation: start from characters inside pre-separated words, merge frequent adjacent pairs, and reuse the resulting pieces for rare words.[2]
The learned merge list is the contract. Training chooses it once; serving replays it.
For teaching, start with characters inside pre-separated code terms. This isolates the merge loop. A production tokenizer also decides how to split on whitespace and punctuation before merging, because boundaries control which pairs can become vocabulary entries.

Use weighted adjacent-pair counts to predict the first winner. Suppose a code-text corpus contains these term counts:
| Term | Count |
|---|---|
code | 3 |
coder | 2 |
cope | 2 |
token | 2 |
prompt | 1 |
At the start, c + o appears seven times: three in code, two in coder, and two in cope. It wins because term frequencies weight each occurrence.
Merge it into co, then recount. co + d and d + e tie at five. The toy trainer encounters co + d first, so it becomes cod; a production trainer needs an explicit deterministic tie-break.
Recount once more. cod + e now wins at five, producing code. Every later round sees the pieces created by earlier rounds, not the original characters.
The miniature trainer below mirrors those state transitions. It stores each term as a tuple of current pieces, counts adjacent pairs, merges the winner everywhere, and prints the first three rules.
1from collections import Counter
2
3frequencies = {
4 "code": 3,
5 "coder": 2,
6 "cope": 2,
7 "token": 2,
8 "prompt": 1,
9}
10state = {tuple(word): count for word, count in frequencies.items()}
11
12def count_pairs(words: dict[tuple[str, ...], int]) -> Counter[tuple[str, str]]:
13 pairs: Counter[tuple[str, str]] = Counter()
14 for pieces, count in words.items():
15 for pair in zip(pieces, pieces[1:]):
16 pairs[pair] += count
17 return pairs
18
19def merge_pair(
20 pieces: tuple[str, ...], pair: tuple[str, str]
21) -> tuple[str, ...]:
22 merged: list[str] = []
23 i = 0
24 while i < len(pieces):
25 if i + 1 < len(pieces) and pieces[i : i + 2] == pair:
26 merged.append("".join(pair))
27 i += 2
28 else:
29 merged.append(pieces[i])
30 i += 1
31 return tuple(merged)
32
33merges: list[tuple[str, str]] = []
34for step in range(3):
35 pair, count = count_pairs(state).most_common(1)[0]
36 state = {merge_pair(pieces, pair): freq for pieces, freq in state.items()}
37 merges.append(pair)
38 print(step + 1, pair, "->", "".join(pair), "count", count)
39
40print("learned merges:", merges)11 ('c', 'o') -> co count 7
22 ('co', 'd') -> cod count 5
33 ('cod', 'e') -> code count 5
4learned merges: [('c', 'o'), ('co', 'd'), ('cod', 'e')]Those merges aren't a linguistic analysis. BPE doesn't know that code relates to programming. It knows only that a boundary occurs often enough to compress. If the corpus changes, the winning pairs and every downstream ID can change with it.
Replay merges on a new term
Now vary the input while keeping the artifact fixed. Training chooses an ordered merge list once. Encoding a new input doesn't recount a new corpus; it replays those learned rules. That separation keeps the model's IDs stable.
Take the three rules above and apply them to new terms. coder benefits from the common stem. coper gets only the co merge because the corpus never earned cope as one piece in the first three steps.
1def apply_rule(pieces: list[str], pair: tuple[str, str]) -> list[str]:
2 result: list[str] = []
3 i = 0
4 while i < len(pieces):
5 if i + 1 < len(pieces) and tuple(pieces[i : i + 2]) == pair:
6 result.append("".join(pair))
7 i += 2
8 else:
9 result.append(pieces[i])
10 i += 1
11 return result
12
13rules = [("c", "o"), ("co", "d"), ("cod", "e")]
14
15for term in ["coder", "codec", "coper"]:
16 pieces = list(term)
17 for rule in rules:
18 pieces = apply_rule(pieces, rule)
19 print(term, "->", pieces)1coder -> ['code', 'r']
2codec -> ['code', 'c']
3coper -> ['co', 'p', 'e', 'r']During BPE encoding, why don't you count pairs in the user's new message?
Answer
Pair counting belongs to tokenizer training. At inference time the model needs the same stable input contract used during its training, so the encoder replays the fixed ordered merge list.
Use bytes as a complete base alphabet
Character-starting BPE still has a coverage hole: a character absent from its base vocabulary needs an unknown-token policy. GPT-2 used a byte-level BPE variant instead. Its base alphabet represents the 256 UTF-8 byte values, then learned merges build larger pieces over that base.[3]
Because any Unicode string has a UTF-8 byte representation, every input remains representable without an unknown character token.
Some tokenizers use an explicit byte fallback only when ordinary pieces can't encode an input. Llama 2's SentencePiece BPE does that with a 32k vocabulary: unknown UTF-8 characters are decomposed into bytes.[4]
The coverage goal matches GPT-2's byte alphabet, but the fallback path runs only for inputs ordinary pieces can't spell.
Before choosing between these policies, test what each one promises. The next snippet doesn't train merges; it isolates the common foundation. A status note containing Japanese characters and an emoji is reversible through raw UTF-8 byte values.
1message = "関数✨"
2byte_ids = list(message.encode("utf-8"))
3reconstructed = bytes(byte_ids).decode("utf-8")
4
5print("byte count:", len(byte_ids))
6print("first byte ids:", byte_ids[:8])
7print("round trip:", reconstructed)
8assert reconstructed == message
9assert all(0 <= value <= 255 for value in byte_ids)1byte count: 9
2first byte ids: [233, 150, 162, 230, 149, 176, 226, 156]
3round trip: 関数✨The round trip proves coverage, not compactness. If training data rarely covers a script or emoji sequence, several bytes may remain separate pieces, so token count still needs measurement.
WordPiece chooses vocabulary differently
WordPiece appeared in Google's Japanese and Korean voice-search work and later became familiar through BERT's 30,000-token tokenizer.[5][6] It shares BPE's reusable pieces, but changes the selection question.
BPE asks which adjacent pair occurs most often; WordPiece asks which vocabulary addition best improves the language-model objective.
Exact training recipes aren't fully specified by the short original paper, and library trainers can differ. To see why raw frequency isn't enough, compare a very common pair with a rarer pair that almost always appears together. A useful classroom proxy is an association score:
The numerator rewards neighboring pieces that appear together. The denominator penalizes pieces that occur frequently in many other contexts, so a less frequent but tightly associated pair can look attractive. Treat this as intuition for WordPiece's likelihood motivation, not as the original implementation specification.
| Candidate pair | Pair count | Individual counts | Proxy score | Lesson |
|---|---|---|---|---|
code + base | 42 | 50 and 44 | 0.0191 | Often occurs together |
the + model | 90 | 900 and 300 | 0.0003 | Frequent pieces aren't necessarily exclusive |
The table makes the boundary visible: the + model has the larger pair count, yet code + base wins this proxy because its pieces co-occur more exclusively. This is the decision WordPiece adds beyond frequency.
At encoding time, BERT-style WordPiece uses continuation pieces such as ##ing and a greedy longest-match lookup. A piece beginning with ## continues the current word rather than beginning a new word. The encoder makes this choice at each cursor position, using the vocabulary it was given.
The lookup below shows the local decision. It always tries the longest valid piece from the current cursor position.
1def wordpiece_tokenize(word: str, vocabulary: set[str]) -> list[str]:
2 result: list[str] = []
3 start = 0
4 while start < len(word):
5 chosen = None
6 for end in range(len(word), start, -1):
7 candidate = word[start:end]
8 if start > 0:
9 candidate = "##" + candidate
10 if candidate in vocabulary:
11 chosen = candidate
12 start = end
13 break
14 if chosen is None:
15 return ["[UNK]"]
16 result.append(chosen)
17 return result
18
19vocabulary = {"code", "##base", "token", "##ized"}
20for term in ["code", "codebase", "tokenized"]:
21 print(term, "->", wordpiece_tokenize(term, vocabulary))1code -> ['code']
2codebase -> ['code', '##base']
3tokenized -> ['token', '##ized']Expose the unknown-token failure
Standard WordPiece can't necessarily spell a word from arbitrary bytes. If no valid segmentation reaches the end of a word, BERT-style tokenization emits [UNK] for that word.
The fallback preserves a valid model input, but loses distinctions between two different unseen strings.
The same algorithm with a missing ##bot continuation shows where the contract fails. A matched code prefix can't be kept when the rest of the word has no legal path. Don't silently map the new string to a known ID. Use the model's tokenizer contract, or change the vocabulary and corresponding model parameters as a training decision.
1def encode_word(word: str, vocabulary: set[str]) -> list[str]:
2 pieces: list[str] = []
3 cursor = 0
4 while cursor < len(word):
5 match = None
6 for end in range(len(word), cursor, -1):
7 candidate = word[cursor:end]
8 if cursor:
9 candidate = "##" + candidate
10 if candidate in vocabulary:
11 match = candidate
12 cursor = end
13 break
14 if match is None:
15 return ["[UNK]"]
16 pieces.append(match)
17 return pieces
18
19vocabulary = {"code", "##base", "token"}
20known = encode_word("codebase", vocabulary)
21missing = encode_word("codebot", vocabulary)
22
23print("known term:", known)
24print("missing continuation:", missing)
25assert known == ["code", "##base"]
26assert missing == ["[UNK]"]1known term: ['code', '##base']
2missing continuation: ['[UNK]']SentencePiece treats boundaries as part of the artifact
BPE and WordPiece in the original papers assume you already have word-like units. Ask who owns that boundary for Japanese, Chinese, or a code comment where spaces don't mark each word. SentencePiece is a tokenizer and detokenizer framework that trains directly from raw sentences instead of requiring pre-tokenized word sequences.[7]
SentencePiece makes spaces visible as ▁ (U+2581), so prompt cache failed becomes a stream like ▁prompt▁cache▁failed before final pieces are chosen. A leading ▁ dummy prefix lets a sentence-initial word use the same piece as that word after a space.
Decoding therefore targets the normalized input string, not necessarily the original raw byte sequence, because normalization can fold equivalent or compatibility forms.
The same artifact can reserve IDs for protocol symbols. SentencePiece documents <unk>, BOS <s>, EOS </s>, and padding as special meta symbols; BERT uses [CLS] and [SEP] for sequence roles.[7][6]
These IDs aren't ordinary substrings. A serving wrapper must decide whether text that resembles a marker is treated as control input or literal text, then pin that choice with the checkpoint.

fi, inserts visible boundary markers, then segments. Decoding restores spaces from ▁ and matches the normalized text, not the original ligature.SentencePiece can host BPE, and it also supports the Unigram language model algorithm proposed with subword regularization. Unigram starts with many candidate pieces, assigns probabilities, and removes pieces that contribute least to corpus likelihood.[8]
Candidate removal keeps the vocabulary small while retaining alternate paths that explain the corpus.
Training can sample multiple valid segmentations. Serving usually needs one reproducible answer, so it selects the highest-probability path. Viterbi dynamic programming finds that path by keeping, at each character offset, the best-scoring piece that ends there.
The classroom version uses Python's NFKC fold as a stand-in for SentencePiece's packaged NFKC-family default, then scores a tiny hand-set vocabulary. It isn't the official trainer. It isolates the serving path: normalize, mark spaces, pick one segmentation, decode.
1from math import inf
2import unicodedata
3
4logp = {
5 "▁token": -0.4,
6 "▁": -1.2,
7 "token": -2.0,
8 "f": -1.8,
9 "i": -1.8,
10 "l": -1.8,
11 "e": -1.8,
12 "de": -1.0,
13 "a": -1.8,
14 "y": -1.8,
15 "ed": -0.9,
16 "d": -2.2,
17}
18
19def viterbi_segment(text: str, scores: dict[str, float]) -> list[str]:
20 n = len(text)
21 best = [(0.0, -1, "")] + [(-inf, -1, "")] * n
22 max_len = max(map(len, scores))
23 for start in range(n):
24 if best[start][0] == -inf:
25 continue
26 for end in range(start + 1, min(n, start + max_len) + 1):
27 piece = text[start:end]
28 if piece not in scores:
29 continue
30 score = best[start][0] + scores[piece]
31 if score > best[end][0]:
32 best[end] = (score, start, piece)
33 if best[n][0] == -inf:
34 raise ValueError("no legal segmentation")
35 pieces: list[str] = []
36 cursor = n
37 while cursor > 0:
38 _, prev, piece = best[cursor]
39 pieces.append(piece)
40 cursor = prev
41 pieces.reverse()
42 return pieces
43
44raw_message = "token file delayed"
45normalized = unicodedata.normalize("NFKC", raw_message)
46marked = "▁" + normalized.replace(" ", "▁")
47pieces = viterbi_segment(marked, logp)
48decoded = "".join(pieces).replace("▁", " ").lstrip()
49
50print("raw:", raw_message)
51print("normalized with marker:", marked)
52print("pieces:", pieces)
53print("decoded:", decoded)
54assert raw_message != decoded
55assert marked == "▁token▁file▁delayed"
56assert pieces == ["▁token", "▁", "f", "i", "l", "e", "▁", "de", "l", "a", "y", "ed"]
57assert decoded == "token file delayed"1raw: token file delayed
2normalized with marker: ▁token▁file▁delayed
3pieces: ['▁token', '▁', 'f', 'i', 'l', 'e', '▁', 'de', 'l', 'a', 'y', 'ed']
4decoded: token file delayed▁token stays whole because that piece outscores ▁ plus token. file fragments because this vocabulary never made file a cheap path.
The decoded line matches NFKC text, so the original fi ligature is gone. Boundary handling and normalization shaped both the pieces and the round trip, which is why SentencePiece belongs in the artifact contract.
Is SentencePiece a fourth merge algorithm alongside BPE and WordPiece?
Answer
No. SentencePiece is a raw-text tokenizer and detokenizer framework. It can train a BPE model or a Unigram model, while also packaging its normalization and boundary-handling policy.
Vocabulary size spends parameters to save positions
Suppose a larger vocabulary saves three tokens on one prompt. Ask what those saved positions cost. Every added vocabulary entry can compress a recurring string into fewer tokens, but it also adds an embedding row.
If the output projection isn't tied to the input embedding matrix, it adds another row there too.
For a vocabulary of size and hidden dimension , an input embedding matrix contains parameters. With float16 weights, each parameter takes two bytes. A second untied output matrix doubles that vocabulary-dependent memory, so the cost grows linearly with .
Llama 2 shipped a 32k SentencePiece BPE vocabulary.[4] Llama 3 moved to 128k tokens: 100k pieces from the tiktoken family plus 28k extra tokens aimed at non-English text.[9]
On a sample of English, the Llama 3 report measured compression improving from 3.17 to 3.94 characters per token. That sample is evidence of a tradeoff, not a workload-wide guarantee. Extra rows are justified only when measured token savings and downstream quality pay for them.
The next snippet compares hypothetical vocabularies for a model with hidden dimension 4096. It calculates embedding memory, including an untied output matrix, rather than treating a larger vocabulary as free.
1def embedding_memory_mib(
2 vocabulary_size: int, hidden_size: int, bytes_per_weight: int = 2
3) -> float:
4 return vocabulary_size * hidden_size * bytes_per_weight / (1024**2)
5
6hidden_size = 4096
7for vocabulary_size in [8_000, 32_000, 128_000]:
8 input_mib = embedding_memory_mib(vocabulary_size, hidden_size)
9 untied_mib = 2 * input_mib
10 print(
11 f"{vocabulary_size:>6,} tokens:",
12 f"input={input_mib:>7.1f} MiB",
13 f"input+untied-output={untied_mib:>7.1f} MiB",
14 )18,000 tokens: input= 62.5 MiB input+untied-output= 125.0 MiB
232,000 tokens: input= 250.0 MiB input+untied-output= 500.0 MiB
3128,000 tokens: input= 1000.0 MiB input+untied-output= 2000.0 MiBThe output makes the parameter bill concrete. Sequence compression still needs its own measurement: a vocabulary can shorten common English prompts and fragment another script or a TypeScript identifier. Tokenizer design is an evaluation problem, not a race to the largest V.
Audit language and code token budgets
Fertility measures token length. At word level, calculate the average number of tokenizer pieces needed per word. For parallel-message audits, compare total token count or a locale-to-baseline ratio for equivalent text.
That ratio can change product economics. Petrov et al. measured translated text and found tokenizer-length disparities as large as 15 times: with the ChatGPT and GPT-4 tokenizer, Shan can take about 15 times as many tokens as English for the same content, affecting cost, latency, and available context.[10]
The classroom BPE trainer started from whole terms. Production BPE usually pre-tokenizes first: split on whitespace and punctuation so a repeated sentence can't collapse into one memorized piece. Merges then run inside those spans.
That boundary explains why tokenCount doesn't automatically become one token just because token is common.

token, encode, and lowercase prompt. CamelCase identifiers therefore fracture: tokenCount keeps token and spells the rest, and encodePrompt becomes encode + P + rompt.Now test the same artifact against different input forms. Train a small byte-level BPE on a handful of English and TypeScript spans, then encode parallel fixtures. Japanese has no learned merges here, so it stays a UTF-8 byte sequence.
The sample isn't a language-quality study; use it as a repeatable audit shape you can point at a real tokenizer later.
1from collections import Counter
2import re
3
4PRETOK = re.compile(r"[A-Za-z]+|\d+|\s+|[^\sA-Za-z\d]")
5
6def utf8_pieces(text: str) -> tuple[str, ...]:
7 return tuple(bytes([b]).decode("latin-1") for b in text.encode("utf-8"))
8
9def pretok(text: str) -> list[str]:
10 return PRETOK.findall(text)
11
12def count_pairs(words: dict[tuple[str, ...], int]) -> Counter[tuple[str, str]]:
13 pairs: Counter[tuple[str, str]] = Counter()
14 for pieces, count in words.items():
15 for pair in zip(pieces, pieces[1:]):
16 pairs[pair] += count
17 return pairs
18
19def merge_pair(pieces: tuple[str, ...], pair: tuple[str, str]) -> tuple[str, ...]:
20 merged: list[str] = []
21 i = 0
22 while i < len(pieces):
23 if i + 1 < len(pieces) and pieces[i : i + 2] == pair:
24 merged.append(pieces[i] + pieces[i + 1])
25 i += 2
26 else:
27 merged.append(pieces[i])
28 i += 1
29 return tuple(merged)
30
31def encode(text: str, merges: list[tuple[str, str]]) -> list[str]:
32 output: list[str] = []
33 for span in pretok(text):
34 pieces = list(utf8_pieces(span))
35 for pair in merges:
36 pieces = list(merge_pair(tuple(pieces), pair))
37 output.extend(pieces)
38 return output
39
40def visible(pieces: list[str]) -> list[str]:
41 shown: list[str] = []
42 for piece in pieces:
43 raw = piece.encode("latin-1")
44 try:
45 shown.append(raw.decode("ascii").replace(" ", "·"))
46 except UnicodeDecodeError:
47 shown.append("<byte>")
48 return shown
49
50corpus = {
51 "const tokenCount = encodePrompt(prompt).length;": 3,
52 "How do I tokenize this prompt?": 5,
53 "prompt cache failed unexpectedly": 4,
54 "token budget pending": 3,
55 "encode the prompt cache": 3,
56 "failed unexpectedly": 2,
57}
58state: dict[tuple[str, ...], int] = Counter()
59for text, count in corpus.items():
60 for span in pretok(text):
61 state[utf8_pieces(span)] += count
62
63merges: list[tuple[str, str]] = []
64for _ in range(25):
65 pair, _count = count_pairs(state).most_common(1)[0]
66 state = {merge_pair(pieces, pair): freq for pieces, freq in state.items()}
67 merges.append(pair)
68
69fixtures = {
70 "english": "How do I tokenize this prompt?",
71 "portuguese": "Como tokenizo este prompt?",
72 "japanese": "このプロンプトをトークン化するには?",
73 "typescript": "const tokenCount = encodePrompt(prompt).length;",
74}
75
76english_tokens = len(encode(fixtures["english"], merges))
77for name, text in fixtures.items():
78 ids = encode(text, merges)
79 print(f"{name:>10}: {len(ids):>2} tokens {len(ids) / english_tokens:>4.2f}x english")
80 assert "".join(ids).encode("latin-1").decode("utf-8") == text
81
82typescript = encode(fixtures["typescript"], merges)
83print("typescript pieces:", visible(typescript))
84assert [len(encode(text, merges)) for text in fixtures.values()] == [20, 17, 54, 25]
85assert visible(typescript) == [
86 "co", "n", "s", "t", "·", "token", "C", "o", "un", "t",
87 "·", "=", "·", "encode", "P", "rompt", "(", "prompt", ")",
88 ".", "l", "en", "g", "th", ";",
89]1english: 20 tokens 1.00x english
2portuguese: 17 tokens 0.85x english
3 japanese: 54 tokens 2.70x english
4typescript: 25 tokens 1.25x english
5typescript pieces: ['co', 'n', 's', 't', '·', 'token', 'C', 'o', 'un', 't', '·', '=', '·', 'encode', 'P', 'rompt', '(', 'prompt', ')', '.', 'l', 'en', 'g', 'th', ';']The output gives three different diagnoses. Portuguese reuses token inside tokenizo, so it can land shorter than English on this toy merge list. Japanese stays at 54 byte pieces because no Japanese pair was ever selected. encodePrompt becomes encode + P + rompt because prompt was learned in lowercase.
None of those single samples ranks a language or codebase. Build a locale-aware test set, record tokenizer version, compare distribution summaries, and then check downstream task quality.
The hand-written split of unexpectedly into un expect ed ly was optimistic. This corpus earned une and ed, not expect, so the same word still fragments. A candidate split describes an intention; a trained artifact determines the actual budget.
Make Unicode policy explicit
Two strings can look identical while holding different Unicode code points. For example, café may contain one composed é or the sequence e plus a combining accent. If clients normalize differently, cache keys, token counts, and filter behavior can disagree before the model sees the text.
Normalization Form C (NFC) composes canonically equivalent forms without folding broad compatibility distinctions. Normalization Form Compatibility Composition (NFKC) also folds compatibility characters, such as the fi ligature into fi.
SentencePiece's published default is NFKC-family normalization packaged inside the model file.[7] Python exposes both forms through unicodedata.normalize. Choosing between them is a product policy decision, not an automatic cleanup rule.
NFC can make equivalent spellings share IDs while preserving compatibility distinctions; NFKC may remove distinctions your product needs.

False → True canonical-equivalence result under NFC. The right lane makes the policy boundary explicit: NFC preserves file, while NFKC changes it to file. Version that choice with cache keys and tokenizer IDs.The checks below make both boundaries visible. For user-visible prompts, you might choose NFC first and add separate security checks for invisible or confusable characters. Another product may deliberately choose NFKC after deciding the information loss is acceptable.
1import unicodedata
2
3composed = "café"
4decomposed = "cafe\u0301"
5ligature = "file"
6
7print("raw cafe equal:", composed == decomposed)
8print("NFC cafe equal:", unicodedata.normalize("NFC", composed) == unicodedata.normalize("NFC", decomposed))
9print("NFC ligature:", unicodedata.normalize("NFC", ligature))
10print("NFKC ligature:", unicodedata.normalize("NFKC", ligature))
11
12assert composed != decomposed
13assert unicodedata.normalize("NFC", composed) == unicodedata.normalize("NFC", decomposed)
14assert unicodedata.normalize("NFC", ligature) != "file"
15assert unicodedata.normalize("NFKC", ligature) == "file"1raw cafe equal: False
2NFC cafe equal: True
3NFC ligature: file
4NFKC ligature: fileTokenizer behavior must be versioned with this policy. If one service normalizes with NFC and another silently folds with NFKC, they can send different IDs to the same model or generate different cache keys for text that looks unchanged. Test normalization before tokenization, not only after decoding.
Compare algorithms without mixing contracts
The common tokenizer families are design choices, not names to memorize. Return to unexpectedly from the running status line and ask one operational question: what exact serving rule turns it into IDs?

| Method | Training view | Serving view | Boundary/fallback detail |
|---|---|---|---|
| BPE | Add frequent adjacent merges | Replay ordered merges | Byte-level variants retain UTF-8 coverage; trainers usually pre-tokenize |
| WordPiece | Grow vocabulary for likelihood objective | Greedy longest valid piece | BERT-style continuation uses ##; missing segmentation can yield [UNK] |
| SentencePiece BPE | BPE trained directly on raw normalized text | Replay packaged model | Visible whitespace marker can be part of pieces |
| SentencePiece Unigram | Estimate and prune candidate-piece probabilities | Best path or sampled alternatives when requested | Segmentation sampling supports regularized training |
The table turns that question into a review path. Start with observed symptom, identify the contract boundary that could explain it, then run the smallest check that separates cause from coincidence.
Production review checklist
| Symptom | Likely cause | Check or fix |
|---|---|---|
| Model output collapses after swapping tokenizer file | IDs no longer match trained embeddings | Pin tokenizer artifact and model checkpoint together |
| Locale hits token limit sooner than English | Unequal fertility on translated requests | Measure parallel message sets by locale and task |
| CamelCase identifiers explode into many pieces | Merges were learned on lowercase fragments | Audit real repository files, not English prompts alone |
A WordPiece model emits [UNK] for new identifiers | No valid vocabulary segmentation | Evaluate vocabulary/model update rather than masking the failure |
| Cache misses differ across clients for same visible message | Unicode preprocessing isn't consistent | Version and test normalization plus tokenizer pipeline |
| Repository prompt holds less code than expected | Code fixtures fragment into many tokens | Measure actual files with intended deployed tokenizer |
Mastery check
Evaluation rubric
- Foundational: Given a five-word corpus, you can calculate one BPE winner by hand and apply it to updated pieces.
- Intermediate: You can implement BPE replay and WordPiece longest-match segmentation, then explain why their failure behavior differs.
- Intermediate: You can show the difference between SentencePiece BPE and SentencePiece Unigram without calling SentencePiece a merge algorithm.
- Advanced: You can present a tokenizer audit with locale fixtures, code fixtures, pre-tokenization, normalization policy, vocabulary memory cost, and artifact versioning.
Follow-up questions
Why must a deployed model keep the same tokenizer artifact used during training?
Answer
The model learned embeddings and output probabilities for specific token IDs. A different vocabulary, merge list, normalization policy, or ID mapping changes the integers representing the text, so the learned parameters no longer receive the intended inputs.
What does byte fallback solve, and what does it leave unsolved?
Answer
Byte fallback makes every UTF-8 input representable without an unknown character token. It doesn't guarantee good compression or equal token cost across languages; those properties require corpus coverage and measurement.
Why isn't a low token count on one English prompt enough to choose a tokenizer?
Answer
A production workload includes many message forms, locales, and possibly code or documents. Measure token distributions and downstream quality on representative fixtures, then account for vocabulary memory and normalization behavior.
Common pitfalls
- Retraining during inference: Pair frequencies are counted while building BPE rules, not while serving each prompt. Serving must replay fixed rules.
- Skipping a pre-tokenizer: BPE on raw repeated strings can memorize whole prompts. Production trainers split on whitespace and punctuation first, then merge inside those spans.
- Conflating byte-level BPE with byte fallback: GPT-2 starts from byte representations. Other tokenizers may invoke explicit byte fallback only when ordinary pieces can't encode an input. Both preserve coverage, but they aren't the same mechanism.
- Describing the WordPiece proxy as its spec: The association score clarifies the intuition. The original method is likelihood-driven, and implementations can vary.
- Calling SentencePiece a fourth algorithm: SentencePiece packages raw-text handling and can host BPE or Unigram models.
- Assuming byte fallback means fair multilingual cost: Coverage avoids unknown characters; it doesn't make segment lengths equal.
- Normalizing without a policy: NFC and NFKC solve different problems. Compatibility folding can discard distinctions your product intended to preserve.