Skip to content

Chapter 2 · Bonus material

Byte Pair Encoding from Scratch

The tokenizer behind GPT-2, GPT-4, and Llama 3 — told in three scroll-driven acts. Everything on this page runs live.

Adapted from the original notebook by Sebastian Raschka · the Python shown is the book's; the demos run a faithful JavaScript port in your browser

Act 1 · Text becomes numbers

T
h
i
s
i
s
s
o
m
e
t
e
x
t
01 / 04

An LLM never sees letters. Before anything else, text has to become numbers. Keep scrolling.

Act 2 · The merge dance

thecatinthehat

 

01 / 06

Training corpus: the cat in the hat— 18 characters, so 18 token IDs. BPE's loop: find the most frequent adjacent pair, merge it, repeat.

That's the entire algorithm — a 1994 compression trick. Now drive it yourself with any text:

BPE training lab

Watch the algorithm from §1.3 run: find the most frequent pair, merge it, assign a new ID, repeat.

Iteration 1 of 4 — sequence (18 tokens)

thecatinthehat

Most frequent pair: t + h appears 2× → becomes token 256

Learned vocabulary

0–255single bytes
no merges yet — press Play

Act 3 · Growing a real vocabulary

258vocabulary size
0merges learned
42tokens for the sentence
Jack·embraced·beauty·through·art·and·life.

trained on the-verdict.txt — the same 742 merges as the notebook

01 / 05

Now the real thing: train on an actual short story (The Verdict, ~20 KB). Before any merges, our sentence costs 42 tokens — one per character.

The lab

The tokenizer from Act 3 is loaded into this page, next to OpenAI's real GPT-2 vocabulary. Type, compare, break things:

Tokenizer playground

Type anything and watch it tokenize. Hover a token for its ID; · marks a leading space (Ġ).

J74ack361·e310m109b98ra420ce397d100·be300au428t116y121·through519·art699·and299·lif808e.534
characters: 42tokens: 17compression: 2.47× fewer IDs than bytes

How does a trained tokenizer handle a word it has never seen? Characters first, then replay the learned merges:

Merge stepper

How encode() rebuilds a word it has never seen: start from characters, apply learned merges until nothing more matches.

Single words only — try , or

chars
embraced
merge 1
embraced
merge 2
embraced
merge 3
embraced

Vocabulary explorer

Everything the tokenizer learned from the-verdict.txt beyond the 256 byte tokens (742 merges + specials).

256·257<|endoftext|>258·t259he260·a261in262·h263·s264·w265·o266·the267ou268re269it270·m271is272ed273at274nd275·b276ing277·p278as279en280·to281·d282·f283·he284er285·ha286·I287·l288·of289on290·c291·th292--293es294or295el296·was297·it298an299·and300·be301ar302ic303im304·on305ac306ow307·in308·his309ur310·e311·g312le313·n314ve315st316om317ut318·that319·had320gh321id322ll323ay324ain325·my326ld327·u328ly329tr330·me331·him332's333rs334·y335·wit336·with337ent338ot339ion340't341ir342se343ould344ght345ro346·"347·you348·M349ad350·S351·G352urn353al354·re355·at356ke357ver358ec359·wh360ure361ack362·have363·T364if365aint366·up367."368·paint369·ab370·not371and372ch373n't374burn375oo

Showing 120 entries (first 120). · marks a leading space.

The real code

The Python behind everything you just scrolled through — a single class mimicking tiktoken's interface, from the original notebook. The demos on this page run a TypeScript port of it, verified against the Python token-for-token.

tokenizer = BPETokenizerSimple()
tokenizer.train(text, vocab_size=1000, allowed_special={"<|endoftext|>"})
 
ids = tokenizer.encode("Jack embraced beauty through art and life.")
tokenizer.decode(ids)   # round-trips back to the input
 
# it can also load OpenAI's shipped GPT-2 vocabulary:
tokenizer_gpt2 = BPETokenizerSimple()
tokenizer_gpt2.load_vocab_and_merges_from_openai(
    vocab_path="encoder.json", bpe_merges_path="vocab.bpe")
tokenizer_gpt2.encode("This is some text")   # [1212, 318, 617, 2420]
Show the full BPETokenizerSimple class
bpe-from-scratch.ipynb
from collections import Counter, deque
from functools import lru_cache
import json
 
 
class BPETokenizerSimple:
    def __init__(self):
        # Maps token_id to token_str (e.g., {11246: "some"})
        self.vocab = {}
        # Maps token_str to token_id (e.g., {"some": 11246})
        self.inverse_vocab = {}
        # Dictionary of BPE merges: {(token_id1, token_id2): merged_token_id}
        self.bpe_merges = {}
 
    def train(self, text, vocab_size, allowed_special={"<|endoftext|>"}):
        """Train the BPE tokenizer from scratch."""
        # Pre-tokenize: split into words, marking leading spaces with 'Ġ'
        tokens = self.pretokenize_text(text)
 
        # Initialize vocab with the 256 byte values + extra chars + 'Ġ'
        unique_chars = [chr(i) for i in range(256)]
        unique_chars.extend(
            char for char in sorted({char for token in tokens for char in token})
            if char not in unique_chars
        )
        if "Ġ" not in unique_chars:
            unique_chars.append("Ġ")
 
        self.vocab = {i: char for i, char in enumerate(unique_chars)}
        self.inverse_vocab = {char: i for i, char in self.vocab.items()}
 
        # Add allowed special tokens (e.g. <|endoftext|>)
        if allowed_special:
            for token in allowed_special:
                if token not in self.inverse_vocab:
                    new_id = len(self.vocab)
                    self.vocab[new_id] = token
                    self.inverse_vocab[token] = new_id
 
        # Each pre-token becomes a list of character IDs
        token_id_sequences = [
            [self.inverse_vocab[char] for char in token]
            for token in tokens
        ]
 
        # BPE steps 1-3: repeatedly find and replace frequent pairs
        for new_id in range(len(self.vocab), vocab_size):
            pair_id = self.find_freq_pair(token_id_sequences, mode="most")
            if pair_id is None:
                break
            token_id_sequences = self.replace_pair(
                token_id_sequences, pair_id, new_id)
            self.bpe_merges[pair_id] = new_id
 
        # Build the vocabulary entries for the merged tokens
        for (p0, p1), new_id in self.bpe_merges.items():
            merged_token = self.vocab[p0] + self.vocab[p1]
            self.vocab[new_id] = merged_token
            self.inverse_vocab[merged_token] = new_id
 
    def encode(self, text, allowed_special=None):
        """Encode text into a list of token IDs."""
        token_ids = []
        # (special-token passthrough handling omitted here for brevity —
        #  see the original notebook for the tiktoken-style guard rails)
        tokens = self.pretokenize_text(text)
 
        for tok in tokens:
            if tok in self.inverse_vocab:
                token_ids.append(self.inverse_vocab[tok])
            else:
                token_ids.extend(self.tokenize_with_bpe(tok))
        return token_ids
 
    def tokenize_with_bpe(self, token):
        """Tokenize a single word using the learned BPE merges."""
        # Start from individual characters
        token_ids = [self.inverse_vocab.get(char, None) for char in token]
        if None in token_ids:
            missing_chars = [char for char, tid in zip(token, token_ids)
                             if tid is None]
            raise ValueError(f"Characters not found in vocab: {missing_chars}")
 
        # Repeatedly merge adjacent pairs that exist in bpe_merges
        can_merge = True
        while can_merge and len(token_ids) > 1:
            can_merge = False
            new_tokens = []
            i = 0
            while i < len(token_ids) - 1:
                pair = (token_ids[i], token_ids[i + 1])
                if pair in self.bpe_merges:
                    merged_token_id = self.bpe_merges[pair]
                    new_tokens.append(merged_token_id)
                    i += 2  # skip the next token; it was merged
                    can_merge = True
                else:
                    new_tokens.append(token_ids[i])
                    i += 1
            if i < len(token_ids):
                new_tokens.append(token_ids[i])
            token_ids = new_tokens
        return token_ids
 
    def decode(self, token_ids):
        """Decode a list of token IDs back into a string."""
        out = []
        for tid in token_ids:
            if tid not in self.vocab:
                raise ValueError(f"Token ID {tid} not found in vocab.")
            tok = self.vocab[tid]
            if tid == 198 or tok == "\n":
                out.append("\n")
            elif tok.startswith("Ġ"):
                out.append(" " + tok[1:])
            else:
                out.append(tok)
        return "".join(out)
 
    @staticmethod
    def find_freq_pair(token_id_sequences, mode="most"):
        pairs = Counter(
            pair
            for token_ids in token_id_sequences
            for pair in zip(token_ids, token_ids[1:])
        )
        if not pairs:
            return None
        if mode == "most":
            return max(pairs.items(), key=lambda x: x[1])[0]
        elif mode == "least":
            return min(pairs.items(), key=lambda x: x[1])[0]
        else:
            raise ValueError("Invalid mode. Choose 'most' or 'least'.")
 
    @staticmethod
    def replace_pair(token_id_sequences, pair_id, new_id):
        replaced_sequences = []
        for token_ids in token_id_sequences:
            dq = deque(token_ids)
            replaced = []
            while dq:
                current = dq.popleft()
                if dq and (current, dq[0]) == pair_id:
                    replaced.append(new_id)
                    dq.popleft()  # remove the 2nd token of the pair
                else:
                    replaced.append(current)
            replaced_sequences.append(replaced)
        return replaced_sequences

Three things to remember

  1. Nothing is ever out-of-vocabulary — worst case, text falls back to byte tokens.
  2. The vocabulary is learned compression — frequent text costs fewer tokens.
  3. Training discovers merges; encoding replays them — two different loops over the same table.

Adapted from Build a Large Language Model (From Scratch) by Sebastian Raschka — original notebook (Apache 2.0). The demos on this page run a TypeScript port of its Python, verified against it token-for-token.