Build a BPE Tokenizer From Scratch in Python (and See Why Some Languages Cost More)
Hands-on lab · IDE in your browser

Build a BPE Tokenizer From Scratch in Python (and See Why Some Languages Cost More)

Write the byte-level BPE tokenizer behind GPT, Llama and Claude: UTF-8 bytes, pair counting, merges, training with chunking, encoding and decoding.

Time
45 min
Checked steps
5
Level
Beginner
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit45 min · 5 stepsSession running
1 / 5 steps passingOne merge · step 2 of 5
bpe.py▶ Run✓ Check
# ---------- Step 2: one merge ----------def merge(ids, pair, new_id):    """A new list where every occurrence of `pair`, scanning left to right without overlaps, is replaced by    new_id: merge([7, 7, 7], (7, 7), 256) == [256, 7]."""        
TerminalOutput

The job

Revontuli runs customer chat for Finnish shops on a model that bills per token, and its invoices are more than twice what English traffic costs. You build a tokenizer from scratch to find out why, and measure it against the ones the big models use.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Text is bytes

    Revontuli runs customer chat for Finnish shops on a hosted model that charges per token.

    You writeto_bytes()
  2. 2

    One merge

    A merge replaces every occurrence of a pair with one new id.

    You writemerge()
  3. 3

    Training

    Training is just merging, repeated: count every pair, merge the most frequent, and do it again until the vocabulary reaches its size.

    You writetrain()
  4. 4

    Encode and decode

    Encoding new text replays the training merges in the order they were learned.

    You writeencode()
  5. 5

    What a token costs

    Tokens per word is the number that turns into money and context space.

    You writetokens_per_word()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Text is bytes

Revontuli runs customer chat for Finnish shops on a hosted model that charges per token. Its Finnish invoices are more than twice what the same traffic costs an English shop. To see why, you build the machinery that turns text into tokens: byte-level BPE, the method behind GPT, Llama and Claude.

It starts from bytes. UTF-8 stores a in one byte, ä in two and an emoji in four, so any text in any language is a list of numbers from 0 to 255. BPE then repeatedly finds the most frequent pair of neighbours and makes it a new token.

Do this

1. Write to_bytes() and pair_counts() in bpe.py.

2. Run it. Count the bytes in each Finnish word, and see which pair tops the Finnish text.

bpe.py, the file you edit70 lines
"""A byte-level BPE tokenizer, the kind behind GPT and Claude, written from scratch for Revontuli's chat."""
import collections
import re

# Text is first cut into chunks: words with their leading space, numbers, punctuation runs and whitespace. Merges
# never cross a chunk boundary, so "dog." and "dog!" share the token for " dog".
PATTERN = re.compile(r"'(?:s|t|re|ve|m|ll|d)| ?[^\W\d_]+| ?\d{1,3}| ?[^\s\w]+|\s+(?!\S)|\s+")


def chunks(text):
    return PATTERN.findall(text)


# ---------- Step 1: text is bytes ----------
def to_bytes(text):
    """The UTF-8 bytes of text, as a list of ints 0-255."""
    # TODO (Step 1): text.encode("utf-8") gives bytes; list() of it gives the ints.
    raise NotImplementedError("Step 1: write to_bytes()")


def pair_counts(ids, counts=None, weight=1):
    """Count every adjacent pair in ids (overlapping: [1, 1, 1] has the pair (1, 1) twice), adding `weight` per
    occurrence into `counts` (a new collections.Counter when None). Returns the counter."""
    # TODO (Step 1): zip(ids, ids[1:]) walks the adjacent pairs.
    raise NotImplementedError("Step 1: write pair_counts()")


# ---------- Step 2: one merge ----------
def merge(ids, pair, new_id):
    """A new list where every occurrence of `pair`, scanning left to right without overlaps, is replaced by
    new_id: merge([7, 7, 7], (7, 7), 256) == [256, 7]."""
    raise NotImplementedError("merge() arrives in Step 2")


# ---------- Step 3: training ----------
def train(text, vocab_size):
    """Learn vocab_size - 256 merges from text. Returns {pair: new_id} in the order learned (ids 256, 257, ...).
    Work on distinct chunks with their counts: each round, count pairs over all chunks (weighted by how often the
    chunk occurs), take the most frequent pair (ties: the smallest pair, e.g. (32, 116) before (32, 119)), give it
    the next id and merge it in every chunk. Stop early if no pair is left."""
    raise NotImplementedError("train() arrives in Step 3")


# ---------- Step 4: encode and decode ----------
def encode(text, merges):
    """Token ids for text. Per chunk: start from its bytes, then repeatedly apply the learned merge with the
    lowest id among the pairs present (the order training learned them), until none applies."""
    raise NotImplementedError("encode() arrives in Step 4")


def vocab(merges):
    """{id: bytes}: 0-255 are single bytes; every merged id is the bytes of its two parts joined."""
    raise NotImplementedError("vocab() arrives in Step 4")


def decode(ids, merges):
    """The text for token ids. A slice of ids can end in the middle of a character, so undecodable bytes become
    U+FFFD (errors="replace") instead of raising."""
    raise NotImplementedError("decode() arrives in Step 4")


# ---------- Step 5: what a token costs ----------
def tokens_per_word(encode_fn, text):
    """len(encode_fn(text)) divided by the number of words (text.split())."""
    raise NotImplementedError("tokens_per_word() arrives in Step 5")


def compare(encoders, texts):
    """{encoder name: {text name: tokens_per_word}} for every encoder and text."""
    raise NotImplementedError("compare() arrives in Step 5")
Provided for you:english.txtenglish_holdout.txtfinnish.txtfinnish_holdout.txttry_it.py

Frequently asked questions

How does byte-pair encoding work?

It starts from the bytes of the text and repeatedly replaces the most frequent adjacent pair with a new token, recording each merge. Encoding new text replays those merges in the order they were learned.

Why do some languages use more tokens?

A tokenizer's merges come from its training text. Languages that were rare in that text are split into more, shorter pieces. With cl100k, Finnish needs about 2.4 times as many tokens per word as English, so it costs more and fills the context window faster.

Why does a tokenizer split text into chunks before merging?

So that merges stay inside words, numbers or punctuation runs. The leading space stays with its word, and tokens do not weld words together across boundaries.

How tokenizers turn text into tokens

Every language model reads tokens, not words, and byte-pair encoding decides what a token is. It explains why prices, context limits and even spelling skills differ between languages. In this lab you write byte-level BPE from scratch: bytes, pair counts, merges, training on chunked text, encoding and decoding. Then you compare your tokenizers with tiktoken's cl100k and o200k on held-out English and Finnish text. The corpora are public-domain books: The Adventures of Sherlock Holmes and Aleksis Kivi's Seitsemän veljestä.