Neural networks eat numbers; text arrives as a string. The tokenizer carves the string into the units x_t a sequence model reads, and maps each to an integer id.
It fixes the model’s output-layer size and every document’s effective length.
It is learned from data and ships with every modern LM: the tokenizer is part of the model.
This section: characters/words → bytes → byte pair encoding from scratch → verified token-for-token against tiktoken.
Read the dataset
class TimeMachine(d2l.DataModule):"""The Time Machine dataset."""def _download(self): fname = d2l.download(d2l.DATA_URL +'timemachine.txt', self.root,'090b5e7e70c295757f55df93cb0a180b9691891a')withopen(fname) as f:return f.read()data = TimeMachine()raw_text = data._download()raw_text[:60]
'The Time Machine, by H. G. Wells [1898]\n\n\n\n\nI\n\n\nThe Time Tra'
Classical pipelines normalized aggressively (drop punctuation, lowercase). We keep the step for the char/word experiments, but byte-level BPE will need none of it:
'the time machine by h g wells i the time traveller for so it'
Characters vs. words
words = text.split()print(f'characters: {len(set(tokens)):>6} distinct, 'f'{len(tokens):>7} tokens in the corpus')print(f'words: {len(set(words)):>6} distinct, 'f'{len(words):>7} tokens in the corpus')
characters: 27 distinct, 173428 tokens in the corpus
words: 4579 distinct, 32775 tokens in the corpus
Characters: ~30 symbols, no OOV, but 170k time steps.
Words: 5× shorter, but thousands of types, unbounded growth, and unseen words are out of vocabulary.
One trade-off curve
A larger vocabulary encodes the same text in fewer tokens; BPE interpolates.
Text as bytes
Every string is UTF-8 bytes: a universal base alphabet of 256 symbols. Nothing is ever out of vocabulary.
s ='Hello, naïve café: 你好 🚀'print(len(s), 'characters,', len(s.encode('utf-8')), 'bytes')for ch in'a é 你 🚀'.split():print(repr(ch), '->', list(ch.encode('utf-8')))
@d2l.add_to_class(BPETokenizer)def _encode_chunk(self, text_bytes): ids = [self.byte_ids[b] for b in text_bytes]whilelen(ids) >1:# The lowest-rank merge applicable anywhere in this chunk pair =min(zip(ids, ids[1:]), key=lambda p: self.merges.get(p, float('inf')))if pair notinself.merges:break ids =self._merge(ids, pair, self.merges[pair])return ids@d2l.add_to_class(BPETokenizer)def encode(self, text, allow_special=False):if allow_special andself.specials: pat ='('+'|'.join(regex.escape(s) for s inself.specials) +')' ids = []for part in regex.split(pat, text):if part inself.specials: ids.append(self.specials[part])elif part: ids.extend(self.encode(part))return idsreturn [i for chunk inself._chunks(text)for i inself._encode_chunk(chunk.encode('utf-8'))]@d2l.add_to_class(BPETokenizer)def decode(self, ids): specials = {i: s.encode('utf-8') for s, i inself.specials.items()} data =b''.join(self.vocab[i] if i inself.vocab else specials[i]for i in ids)return data.decode('utf-8', errors='replace')
Round trip
print([toy_tok.vocab[i] for i in toy_tok.encode('hugs')])print([toy_tok.vocab[i] for i in toy_tok.encode('bun')])toy_tok.decode(toy_tok.encode('hug pug hugs'))
[b'hug', b's']
[b'b', b'u', b'n']
'hug pug hugs'
Encoding consults only the merge table: train and inference agree by construction. The merge table is the tokenizer.
Trained on The Time Machine
tok = BPETokenizer(vocab_size=1024)tok.train(raw_text)merged_ids =list(tok.merges.values())print('first:', [tok.vocab[i] for i in merged_ids[:8]])print('last: ', [tok.vocab[i] for i in merged_ids[-8:]])ids = tok.encode('The Time Traveller smiled. Are you sure?')print(len(ids), 'tokens:', [tok.vocab[i] for i in ids])
A smaller BPE vocab is a prefix of a larger one, so one training run sweeps them all:
Unconstrained merges cross word boundaries
crossers = [v.decode('utf-8', 'replace') for i, v in tok.vocab.items()if i >=256andb' 'in v.strip(b' ')]print(len(crossers), 'tokens cross a word boundary, e.g.', crossers[:8])
78 tokens cross a word boundary, e.g. [', and ', 'of the ', '. I ', '. Th', '. A', '. I', 'in the ', 'ed to ']
Tokens like of the compress the training corpus but are brittle composites that generalize poorly.
Pre-tokenization
Split text into chunks with a regex; merges never cross chunks (GPT-2’s actual pattern):
bpe = BPETokenizer(vocab_size=1024, pattern=BPETokenizer.GPT2_PATTERN)bpe.train(raw_text)crossers = [v for i, v in bpe.vocab.items()if i >=256andb' 'in v.strip(b' ')]print('boundary-crossing tokens now:', len(crossers))gained =sorted(set(bpe.vocab.values()) -set(tok.vocab.values()), key=lambda v: (-len(v), v))print('longest tokens gained instead:', [v.decode('utf-8', 'replace') for v in gained[:6]])
<pad>, <bos>, <eos> live above the BPE id range: they must never be producible from ordinary text (prompt-injection guard; cf. tiktoken’s allowed_special):
print('as plain text: ', bpe.encode('<bos>the time machine'))print('as control code:', bpe.encode('<bos>the time machine', allow_special=True))print('pad/bos/eos ids:', bpe.pad, bpe.bos, bpe.eos, '| len:', len(bpe))
as plain text: [60, 98, 111, 115, 62, 341, 513, 680]
as control code: [1025, 341, 513, 680]
pad/bos/eos ids: 1024 1025 1026 | len: 1027
What a production tokenizer stores
One table: bytes → rank, plus the pre-tokenization regex.
GPT-2’s first merges (” t”, ” a”) match what we learned from one novella: the head of English is that stable.
The verification moment
Load GPT-2’s published ranks into our encoder and reproduce tiktoken token for token:
ours = BPETokenizer.from_tiktoken('gpt2')para = ('The Time Traveller (for so it will be convenient to speak of ''him) was expounding a recondite matter to us; his pale grey ''eyes shone and twinkled. Prices rose 3.5% in 1895, and naïve ''café patrons paid £12,345 more! 🚀')assert ours.encode(para) == enc.encode(para)print(len(ours.encode(para)), 'tokens, identical to tiktoken')print([ours.vocab[i].decode('utf-8', 'replace')for i in ours.encode(para)[:8]])
Token boundaries misalign with place value → brittle arithmetic.
Glitch tokens (” SolidGoldMagikarp”): frequent in the tokenizer’s corpus, absent from the model’s, leaving an untrained embedding reachable from any prompt.
Recap
Chars ↔︎ words is a vocabulary/length trade-off; bytes make the base alphabet universal.
BPE: iterated most-frequent-pair merges; encoding replays merges by rank; the merge table is the tokenizer.
Pre-tokenization keeps merges inside words; specials live outside the BPE range.
Production tokenizers are exactly this at scale, and we reproduced GPT-2’s tokenization token for token.