Skip to content

02 · Tokenizers Deep Dive

Every module up to now treated "tokens" as an abstract cost/length unit. This module builds a byte-pair encoding (BPE) tokenizer from scratch to show exactly how text becomes the integer ids the transformer in module 1 actually consumes — and why token counts differ between models, and even between different strings you'd expect to cost the same.

Why not just split on characters or words?

Character-level tokenization keeps vocabulary tiny but makes sequences very long (every character is a step of expensive attention computation). Word-level tokenization keeps sequences short but the vocabulary explodes (every inflection, typo, and rare word needs its own slot, and unseen words have no representation at all). BPE sits between the two: common words stay single tokens, rare words decompose into meaningful sub-word pieces, and literally any string — including gibberish or new words — can be represented, because worst-case it falls back to individual bytes.

Building a BPE tokenizer

BPE starts from individual bytes/characters and iteratively merges the most frequent adjacent pair into a new token, building up a vocabulary of whatever substrings actually recur in the training corpus:

from collections import Counter

def get_pair_counts(corpus: list[list[str]]) -> Counter:
    counts = Counter()
    for word in corpus:
        for a, b in zip(word, word[1:]):
            counts[(a, b)] += 1
    return counts

def merge_pair(corpus: list[list[str]], pair: tuple) -> list[list[str]]:
    merged = "".join(pair)
    new_corpus = []
    for word in corpus:
        new_word, i = [], 0
        while i < len(word):
            if i < len(word) - 1 and (word[i], word[i + 1]) == pair:
                new_word.append(merged)
                i += 2
            else:
                new_word.append(word[i])
                i += 1
        new_corpus.append(new_word)
    return new_corpus

def train_bpe(words: list[str], num_merges: int) -> list[tuple]:
    corpus = [list(w) + ["</w>"] for w in words]     # start at character level
    merges = []
    for _ in range(num_merges):
        pair_counts = get_pair_counts(corpus)
        if not pair_counts:
            break
        best_pair = max(pair_counts, key=pair_counts.get)
        corpus = merge_pair(corpus, best_pair)
        merges.append(best_pair)
    return merges

training_words = ["lower", "lowest", "newer", "wider", "newest"] * 20
merges = train_bpe(training_words, num_merges=10)
print(merges[:5])
# e.g. [('e', 'r'), ('er', '</w>'), ('n', 'e'), ('ne', 'w'), ('new', 'er</w>')]

The learned merges are the tokenizer's vocabulary, in the order they were discovered — ("e", "r") merging first means "er" was the single most common adjacent character pair across the training corpus, and it becomes one token going forward.

Encoding with a trained tokenizer

Applying the merges in the order they were learned to a new string is how encoding works — the same merge rules that built the vocabulary now segment new text into that vocabulary's pieces:

def encode(word: str, merges: list[tuple]) -> list[str]:
    tokens = list(word) + ["</w>"]
    for pair in merges:
        i = 0
        new_tokens = []
        while i < len(tokens):
            if i < len(tokens) - 1 and (tokens[i], tokens[i + 1]) == pair:
                new_tokens.append("".join(pair))
                i += 2
            else:
                new_tokens.append(tokens[i])
                i += 1
        tokens = new_tokens
    return tokens

print(encode("newer", merges))    # likely ['new', 'er</w>'] if trained as above
print(encode("newestly", merges)) # unseen word — falls back to smaller known pieces

An unseen word never fails outright; it just decomposes into whatever pieces the trained merges cover, down to individual characters/bytes in the worst case — this is why a tokenizer never returns an "unknown token" error the way a fixed word-level vocabulary would.

Why token counts differ across models and strings

Real tokenizers (used by production LLMs) are trained on huge, differently composed corpora, so the same string produces a different token count on different models — a string efficient in one model's vocabulary (because similar text was common in its training data) may split into many more pieces in another's:

  • Non-English text often tokenizes less efficiently than English on vocabularies trained mostly on English corpora — expect noticeably more tokens per character.
  • Code and rare identifiers (snake_case_variable_123) frequently split into several sub-word tokens, since exact identifier strings are far less likely to recur verbatim in training data than common English words.
  • Whitespace and casing changes the tokenization"Hello", " Hello", and "hello" are commonly three distinct tokens or token sequences, not variants of the same one, because BPE merges operate on the literal byte sequence, whitespace included.
# Illustrative: don't assume word count ≈ token count
for s in ["hello world", "  hello world", "HELLO WORLD", "hello_world_var"]:
    print(s, "->", encode(s.replace(" ", "_"), merges))  # toy tokenizer, illustrative only

For real cost estimation, always use the provider's actual tokenizer or a count_tokens API rather than a word-count heuristic — the module 8 cost tracking from Level 1 should call the real counting endpoint, not len(text.split()).

Tokenization gotchas that bite in practice

  • Numbers split unpredictably. "12345" might be one token, two, or five depending on the vocabulary — arithmetic reliability partly depends on how consistently a model's tokenizer represents digit sequences, which is one reason Level 1's tool-calling module told you to offload math to a calculator tool rather than trust generated digits.
  • Token boundaries don't align with word boundaries. A regex or string-length-based prompt truncation strategy can cut a token in half from the model's perspective (though not from yours, since you're slicing the string) — truncate by token count, not character count, when you're near a context-window limit.
  • A single "character" can be several tokens. Emoji and many non-Latin scripts are represented as multiple bytes, which can become multiple tokens — never assume len(string) ≈ len(tokens).

How It Actually Works

Tokenization is not part of the transformer's learned computation from module 1 — it's a fixed, separately-trained preprocessing step that runs before the embedding lookup, converting raw text into the sequence of integer ids the model was trained against. Once a tokenizer's vocabulary is fixed (typically frozen before pretraining begins), it never changes for that model — this is precisely why token counts for identical text are consistent within one model but vary between models with different vocabularies: each vocabulary encodes a different set of frequent substrings, learned from a different training corpus via the same frequency-driven merge process demonstrated above at a much larger scale (commonly ten-thousand to several-hundred-thousand merges, not ten).

This also explains the numeric and multilingual gotchas directly: BPE merges are chosen purely by byte/character-pair frequency in the training corpus, with no built-in concept of "this is a number" or "this is a word boundary" — a digit sequence's tokenization depends entirely on how often that exact digit substring appeared during training, and a low-resource language's tokenization is worse simply because its substrings were rarer in the corpus the merges were learned from, so fewer of its common patterns earned a place in the vocabulary as a single token.

Cheat sheet

Concept Key fact
BPE Iteratively merges the most frequent adjacent pair into a new token
Vocabulary The ordered list of learned merges, fixed after training
Unseen words Decompose into smaller known pieces — never a hard failure
Token ≠ word ≠ character None of these counts are interchangeable
Cross-model variance Different training corpora → different vocabularies → different counts
Cost estimation Always use the real tokenizer/count API, never a word-count heuristic

Exercise

Train the toy BPE tokenizer above on a corpus of at least 50 short English sentences for 60 merges, then encode 5 test strings: a common English sentence, a snake_case code identifier, a string of digits, a sentence with unusual capitalization, and a word absent from training. Print the token count for each and explain in a comment, for each case, why it tokenized the way it did based on which merges fired.