llm_from_scratch / lessons/01 · tokenizationlesson 2 / 16

Part I — Text becomes numbers

Tokenization — from text to integer ids

Lesson 00 said an LLM is one function that eats a sequence of tokens and scores a fixed vocabulary. But we start with a raw string of characters, and a neural net is matrix multiplies that only consume numbers. This lesson builds the translator: a reversible map from text to a sequence of integer ids, backed by a fixed-size subword vocabulary that can never run out of room for a word it has never seen.

What the last step left broken
The function from lesson 00 assumed its input was already (token1, …, tokent) — a list of vocabulary indices — and produced a distribution over a vocabulary of size V. Neither end connects to reality yet. Real input is a string like "It's the last he painted."; real output must eventually become text again. Nothing in the model speaks characters. We need a codec that turns text into integers and back, and it has to answer two hard questions at once: what counts as one "token," and how big is the vocabulary V?
Linear position
Forced by: the one function eats a sequence of tokens and scores a fixed-size vocabulary, but text is a stream of characters and the network only consumes numbers.
New idea: tokenize — split text into a sequence of small reusable pieces and assign each a stable integer id, using byte-pair encoding (BPE) so the vocabulary is a fixed V = 50,257 and yet every possible string still encodes (unknown words fall back to sub-pieces, never to an "unknown" hole).
Forces next: an integer id is an arbitrary label — id 5 is no "closer" to id 6 than to id 5000, and the id carries no meaning a network can read off its magnitude. Lesson 02 must map each id to a learnable vector.
The plan
Four moves. (1) Argue from first principles why the unit must be integers, and why it must be subwords — words explode the vocabulary and still miss new words; characters make sequences painfully long. (2) Build a naive word tokenizer to feel the machinery — split, sort, map — and walk straight into the out-of-vocabulary wall that forces special tokens. (3) Derive byte-pair encoding: start from characters, greedily merge the most frequent adjacent pair, repeat to a target vocabulary — never out-of-vocabulary, always fixed size. (4) Read off the consequences: a compression ratio, and the pathologies (odd digits, whitespace tokens, "how many r's," glitch tokens). Then run the merge loop yourself.

1 · Why integers, and why subwords

The network is a stack of matrix multiplications. It cannot multiply the letter h. So the first non-negotiable is that text must become numbers — and specifically a sequence of integer ids, each pointing into a lookup table (the embedding table of lesson 02). The only real design freedom is the unit: what is one token? Three choices present themselves, and two of them fail for concrete reasons.

Option A · too big
One token = one word
Sequences stay short and human-readable, but the vocabulary is unbounded: English alone has hundreds of thousands of word forms, and you still break the first time a user types a name, a typo, or a word coined yesterday. A fixed V and an open-ended word list are incompatible.
Option B · too small
One token = one character
The vocabulary is tiny and can spell anything, so it never misses a word. But sequences explode — a paragraph becomes thousands of tokens — and since attention cost grows with the square of sequence length (lesson 06), you burn most of your compute spelling common words letter by letter.
Option C · just right
One token = one subword
Learn a fixed vocabulary of frequent character chunks: whole common words as single tokens, rare words split into a few sub-pieces. Short sequences and a bounded V and the ability to spell anything from its pieces. This is the Goldilocks unit, and BPE is how we build it.

Hold onto the tension in the table: it is a genuine trade-off between sequence length and vocabulary size. Bigger tokens mean a bigger vocabulary but fewer of them per document; smaller tokens mean a smaller vocabulary but more per document. Subwords do not escape the trade-off — they tune it, and the widget at the end of this lesson lets you slide along the curve one merge at a time.

2 · A naive word tokenizer, and the wall it hits

Before the clever scheme, build the obvious one — it makes the failure that motivates BPE concrete. A word-level tokenizer is three steps: split the text into pieces, collect the distinct pieces into a sorted vocabulary, and map each piece to its index in that vocabulary.

Split, then build the vocabulary

"Split" means more than cutting on spaces: punctuation has to become its own token, or "painted." and "painted" would be two different vocabulary entries and the model would have to learn them separately. So we split on whitespace and peel off punctuation as standalone tokens. We also keep case as-is — The and the stay distinct — because capitalization is a real signal (proper nouns, sentence starts) that lowercasing would silently destroy.

# split on whitespace and punctuation, keeping punctuation as tokens
pieces = split_keeping_punctuation(text)      # "It's the last." -> ["It", "'", "s", "the", "last", "."]

# the vocabulary is the sorted set of distinct pieces
vocab_tokens = sorted(set(pieces))            # deterministic order
str_to_id = { tok: i for i, tok in enumerate(vocab_tokens) }
id_to_str = { i: tok for tok, i in str_to_id.items() }   # the inverse, for decoding

Sorting is not cosmetic: it makes the id assignment deterministic, so the same corpus always yields the same ids. With the two dictionaries in hand, encode maps each piece to its id, and decode looks each id back up and rejoins the pieces (fixing spacing around punctuation). The two are exact inverses — this reversibility is what lets us read the model's numeric output back out as text.

Grounding: Edith Wharton's "The Verdict"
Throughout this series we tokenize a real public-domain text — Edith Wharton's short story The Verdict, about 20,479 characters. Run the naive scheme above on it and you get roughly 4,690 tokens drawn from a vocabulary of about 1,130 distinct words and symbols. Keep those three numbers: ~20,479 characters, ~4,690 tokens, ~1,130 vocabulary. We will contrast them with BPE's fixed V = 50,257 shortly, and the compression ratio (~4.4 characters per token here) is exactly the quantity the widget measures.

The out-of-vocabulary wall

Now feed the tokenizer a word the story never used — say the encoder was built only from The Verdict and you hand it "Hello". The lookup str_to_id["Hello"] simply is not there. The encoder throws a key error. It cannot produce any id, so it cannot process the text at all. This is the out-of-vocabulary (OOV) wall, and it is fatal: a tokenizer that crashes on unseen words is useless on the open-ended text real users type.

The first patch is special tokens — reserved vocabulary entries that stand for something other than a literal word:

tokenmeaningwhy it exists
<|unk|>unknown wordAny piece missing from the vocabulary is replaced by this single id, so encoding never crashes. A blunt fix: every unseen word collapses to the same id, discarding what it actually said.
<|endoftext|>document boundaryInserted between concatenated documents so the model learns that one text has ended and an unrelated one begins; it also doubles as a padding token when batching. GPT models keep this one.

Adding these two entries bumps a 1,130-word vocabulary to 1,132. The <|endoftext|> token is genuinely useful and survives into the real GPT tokenizer. But <|unk|> is an admission of defeat — it says "there was a word here and I have no idea what." Every name, typo, and neologism becomes the same featureless id. That is the wall byte-pair encoding is built to demolish: a good tokenizer should never need an unknown token.

3 · Byte-pair encoding: never out-of-vocabulary, fixed size

Byte-pair encoding (BPE) gets the best of both extremes from §1 by building the subword vocabulary bottom-up from characters. The insight: don't pick words or characters as the unit — start at characters (which can spell anything) and then learn which chunks are worth promoting to single tokens, using nothing but their frequency in the training corpus.

The training algorithm

BPE trains a merge table in a greedy loop. Start with the vocabulary as the set of individual characters (or, in the real GPT-2 tokenizer, the 256 raw bytes — see the cross-reference). Then repeat: scan the whole corpus, count every adjacent pair of current tokens, take the single most frequent pair, and glue it into one new token everywhere it occurs — adding it as a new vocabulary entry. Each pass adds exactly one entry, so you stop the moment the vocabulary reaches the target size.

# train BPE: grow the vocabulary to a target size by greedy pair-merging
vocab  = set of all characters in the corpus       # seed: can spell anything
merges = []                                        # ordered list of learned merges

while len(vocab) < TARGET_V:                        # e.g. TARGET_V = 50257
    pairs = count_adjacent_pairs(corpus_tokens)     # {("l","o"): 42, ("e","r"): 37, ...}
    best  = pair_with_highest_count(pairs)          # greedy: take the most frequent
    corpus_tokens = merge_everywhere(corpus_tokens, best)   # "l","o" -> "lo"
    vocab.add(join(best))                            # new token enters the vocabulary
    merges.append(best)                             # remember the order — it defines encoding

The ordered merges list is the whole trained tokenizer. To encode new text, split it into characters and replay the merges in the same order they were learned; whatever tokens remain after the last applicable merge are the ids. Because the loop terminates at a chosen count, V is fixed by construction — for GPT-2 it is exactly 50,257 (256 byte tokens + 50,000 learned merges + one <|endoftext|>).

Why BPE is never out-of-vocabulary

Here is the payoff that kills the <|unk|> token. Suppose the model meets a word it never saw in training — a surname, someunknownPlace, a fresh piece of slang. The merges that do apply fire, and the parts that never earned a merge simply stay as smaller sub-pieces or, in the worst case, individual characters. A brand-new word does not crash the encoder; it comes out as a short sequence of familiar sub-tokens. Since the seed vocabulary can spell every character, there is no string BPE cannot encode. Frequent words end up as a single token (efficient); rare words decompose into a handful of pieces (still exact). The vocabulary is fixed and complete — the exact combination the naive scheme could not achieve.

The two properties, together
Fixed size: the training loop stops at a chosen count, so V = 50,257 is guaranteed. Never OOV: the vocabulary is seeded with all characters (bytes), so any word decomposes into pieces it already contains. Words and characters each gave you one of these properties; BPE is the construction that gives you both at once — which is why GPT-2, GPT-3, and the model behind the original ChatGPT all use it.

4 · Consequences: compression and pathologies

Choosing the subword unit has downstream effects that show up constantly when you actually use an LLM. Two are worth internalizing now.

Compression ratio

Tokenization is a compression step. The useful number is the compression ratio = characters ÷ tokens: how many characters, on average, one token buys you. For English with a GPT-2-style vocabulary it is roughly 4 characters per token. That ratio is why the same context window measured in tokens holds far more English than the token count suggests — and why prices, context limits, and throughput are all quoted per token, not per character or per word. The widget's KPI is exactly this ratio, and you will watch it climb as merges make the sequence shorter.

Pathologies you inherit

Because the model only ever sees tokens, quirks of the tokenizer become quirks of the model's apparent "reasoning":

Where tokenization leaks

  • Digits split oddly. Numbers are tokenized by frequency, not by place value — 2024 might be one token while 2025 is two. Arithmetic looks unreliable partly because the model never sees clean digit units.
  • Whitespace is part of the token. BPE usually attaches a leading space to a word, so " the" and "the" are different tokens. This is why a stray space can change a completion, and why counting is finicky.
  • "How many r's in strawberry?" The word is a couple of tokens, not seven letters, so the model cannot simply count characters it never received as separate units. The failure is in the input representation, not the intelligence.
  • Glitch tokens. Rare strings that got a token during training but almost never appeared in text (e.g. scraped usernames) have near-random embeddings and can make the model behave erratically when they show up.

Design choices that bite

  • Lowercasing is lossy. Folding case shrinks the vocabulary but throws away proper-noun and sentence-boundary signal — a one-way door. We keep case.
  • Vocabulary size is a trade-off, not a maximum. A bigger V shortens sequences but adds embedding parameters (V·d) and thins out training signal per token; 50k is an empirical sweet spot, not a law.
  • Train ≠ inference mismatch. If you tokenize training text one way and inference text another (different normalization, different special tokens), the ids drift and quality drops. The codec must be identical on both sides.
  • Non-English pays more. Vocabularies trained mostly on English spend more tokens per character on other scripts — a direct cost and context-length penalty.

5 · Watch a vocabulary grow, one merge at a time

The widget below runs the BPE training loop from §3 on a fixed toy corpus with lots of repeated structure: low low lower lowest newer newest wider widest. It starts with every word split into individual characters (a small vocabulary that can spell anything, but a long sequence). Each Merge finds the single most frequent adjacent pair — highlighted before it fires — glues it into one token everywhere, and adds it to the vocabulary. Watch the two numbers move in opposite directions: the token count falls while the vocabulary size rises. That is the §1 trade-off happening in front of you, and the compression ratio (characters per token) is the score. Merges never cross the word gaps, exactly as real BPE trains within word boundaries.

BPE merge stepper
Proves the core BPE bargain: each greedy merge trades one new vocabulary entry for a shorter token sequence. The most-frequent adjacent pair is boxed in red before it merges; press Merge and it fuses everywhere. Tokens ↓, vocabulary ↑, compression ↑ — the trade-off from §1, made mechanical.
tokens
vocabulary size
merges done
0
compression (chars/token)

Failure modes & checklist

Failure modes

  • Word-level vocabulary. Unbounded size and guaranteed OOV on the first unseen name or typo — a fixed V is impossible. Signal: crashes or <|unk|> everywhere on real input.
  • Character-level everything. No OOV, but sequences blow up and quadratic attention wastes compute spelling common words. Signal: tiny context measured in words.
  • Encode ≠ decode. Break reversibility (drop case, mishandle punctuation spacing) and you cannot read the model's output back as the original text.
  • Train/inference tokenizer drift. Different normalization or special-token handling at inference shifts every id and silently degrades quality.

Checklist

  • The unit is a subword: common words whole, rare words in pieces.
  • Vocabulary size V is fixed by construction (stop the merge loop at the target).
  • The seed is all characters/bytes, so encoding is never OOV — no <|unk|> needed.
  • Encode and decode are exact inverses; case and punctuation are preserved.
  • <|endoftext|> marks document boundaries and pads batches.

Checkpoint

Try it
Press Reset, note the starting token count and compression ratio (it begins near 1.0 char/token — pure characters). Now press Merge a few times and watch which pair gets boxed first: the most frequent adjacent pair in low low lower lowest… is the one that pays off most, so BPE grabs it first. Keep merging and watch the compression ratio climb well above 1 while the vocabulary grows by exactly one entry per merge. Ask yourself: if you kept merging until every whole word was a single token, what would happen to the vocabulary size the moment a brand-new word arrived — and why does seeding from characters mean that never breaks encoding?

Where this points next

You can now turn any string into a sequence of integer ids and back — reversibly, with a vocabulary that is fixed at 50,257 and yet never stumped by a new word. But look hard at what those ids are: arbitrary labels. The token " the" might be id 262 and " a" id 257, but 262 is not "5 more" than 257 in any meaningful sense, and id 5 is no nearer id 6 than id 5000. If we fed these integers to the network directly, it would read their magnitudes as meaning and invent a fake ordering that isn't there. The ids are pointers, not quantities. Lesson 02, Embeddings & positions, replaces each id with a learnable vector — so that "meaning" is something gradient descent can shape, and so that similar tokens can drift close together in a space where distance actually means something.

Takeaway
Tokenization is the reversible codec between text and the integer ids the network consumes. The unit must be a subword: words explode the vocabulary and still hit OOV; characters make sequences too long. Byte-pair encoding resolves the dilemma by seeding the vocabulary with all characters and greedily merging the most frequent adjacent pair to a target size — giving a fixed V = 50,257 that is never out-of-vocabulary, because any unseen word decomposes into pieces already present (so GPT needs <|endoftext|> but not <|unk|>). The cost is a bundle of representational quirks — ~4 chars/token compression, odd digit and whitespace splits, "count the r's" failures, glitch tokens — that are properties of the tokenizer, not the model. And the ids it emits are arbitrary labels, which is precisely the wall lesson 02 must break.

Interview prompts

Companion reads: cs336 · 01 Tokenization goes deeper on byte-level BPE, the 256-byte seed, and glitch tokens; the compression trade-off there is framed against a fixed compute budget.