Thomas Piacentini
← Workshop

Cheat sheet

Tokenization and BPE

Original text · English

Train, encode and decode a byte-level BPE tokenizer, then slice the id stream into training windows.

Tasks

TaskCode
Train byte-level BPEtrain(tokenizer, <corpus>, target_vocab_size=320)
Encode with special tokensencode(tokenizer, text, allow_special=True)
Decode ids to textdecode(tokenizer, ids)
Normalize Unicode firstunicodedata.normalize("NFKC", text)
Encode with tiktokenenc = tiktoken.get_encoding("cl100k_base") then enc.encode(text)
Train with HF tokenizerstokenizer.train(["corpus.txt"], trainer)
Train SentencePiecespm.SentencePieceTrainer.train(input="corpus.txt", vocab_size=8000)
Load a model tokenizerAutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B")
Sources

Tokenizer and data pipeline

slicebatchRaw TextNormalizeNFKCPre-Tokenizesplit into chunksBPE Mergemerge tableSpecial Tokensfixed IDsToken IDsflat list of idsPyTorch Datasetwindows of T+1 idsDataLoaderseeded shuffle, B x T
Nodes and connections
NodeRoleKind
Raw Text
NormalizeNFKC
Pre-Tokenizesplit into chunks
BPE Mergemerge table
Special Tokensfixed IDs
Token IDsflat list of ids
PyTorch Datasetwindows of T+1 ids
DataLoaderseeded shuffle, B x T
FromToConnection
Raw TextNormalize
NormalizePre-Tokenize
Pre-TokenizeBPE Merge
BPE MergeSpecial Tokens
Special TokensToken IDs
Token IDsPyTorch Datasetslice
PyTorch DatasetDataLoaderbatch
Sources

Train BPE merges

tokenizer.initialize(specials)256 bytes, then specials
units = _build_initial_units(corpus)byte tuple -> word count
while tokenizer.vocab_size < target_vocab_size:
pairs = _count_pairs(units)weighted by word count
max_count = max(pairs.values())
candidates = sorted(p for p, c in pairs.items() if c == max_count)
best = candidates[0]tie break on the pair
if pairs[best] < 2:
breakno pair repeats
new_id = tokenizer._add_token(
tokenizer.vocab[best[0]] + tokenizer.vocab[best[1]]
)next free id
tokenizer.merges[best] = new_idorder is the rank
units = _apply_merge_to_corpus(units, best, new_id)
Sources

Encode fresh text

  1. Specials with allow_special=True, literal special strings get reserved ids.
  2. Pre-tokenize _pretokenize splits text into whitespace and word runs.
  3. Bytes each chunk becomes its UTF-8 byte ids.
  4. Rank find the lowest-ranked merge that applies to the chunk.
  5. Merge replace that pair with its merged id.
  6. Repeat stop when no merge in the table applies.
  7. Join concatenate the ids of all chunks in order.
Sources

Sliding window dataset

usable = num_ids - (context_length + 1)count_windows
return 1 + usable // stride0 when usable < 0
start = index * self.stride__getitem__
end = start + self.context_length + 1T+1 ids
window = self.ids[start:end]
return window[:-1].clone(), window[1:].clone()input, target
generator = torch.Generator()
generator.manual_seed(base_seed + epoch)same order on rerun
inputs, targets = next(iter(loader))(B, T) and (B, T)
Sources

Algorithms and formulas

BPE
merge the most frequent adjacent pair until the target vocabulary size.
Byte-level BPE
base vocabulary of exactly 256 bytes. No unknown token.
WordPiece
merges by count(AB) / (count(A) * count(B)). Continuation pieces get ##.
Unigram
start large, prune tokens whose removal least increases corpus log-likelihood.
SentencePiece
trains BPE or Unigram on raw Unicode. Space is ▁.
tiktoken
fast encoder for pre-built vocabularies. It does not train.
Naive encode cost
O(n \cdot \lvert \text{merges} \rvert) for one word.
Window count
max(0, 1 + (N - (T + 1)) // S) for N ids, context T, stride S.
Sources

Defaults and numbers

SettingDefaultNote
Byte alphabet256ids for bytes 0x00 to 0xFF
GPT-2 vocabulary50,257256 bytes, 50,000 merges, 1 special
BERT WordPiece vocabulary30,522## continuation prefix
Llama 3 vocabulary128,256byte-level BPE
GPT-4o vocabulary200,019o200k_base encoding
SentencePiece character_coverage0.99950.995 for Japanese
SlidingWindowDataset stridecontext_lengthused when stride is None
make_dataloader num_workers0slices an in-memory tensor
Sources

Troubleshooting

SymptomCauseFix
Model outputs garbage after deploytrained on vocab A, deployed with vocab Bcheck the tokenizer file hash in CI
"hello" and " hello" get different idswhitespace ambiguityset add_special_tokens and add_prefix_space explicitly
Chat model gives garbagechat template differs from trainingmatch the training format exactly
Non-Latin text costs 5-10x more tokensEnglish-heavy vocabularytrain a larger multilingual vocabulary
\p{L} pattern failsstdlib re has no Unicode property escapesinstall regex
Special token encodes as bytesallow_special is offpass allow_special=True
KeyError unknown token idid is not in the vocabularydecode only ids this tokenizer made
Last window missingtail shorter than T+1 idsexpected. The slicer drops it
Sources

Terms

BPE
Byte Pair Encoding. Iterative merge of the most frequent pair.
NFKC
Unicode normalization. The "fi" ligature becomes "fi".
Merge table
ordered list of pair merges. It is the tokenizer.
Pre-tokenization
splitting rules before BPE, so merges stay inside words.
Fertility
average tokens per word. 1.0 is perfect.
Compression ratio
tokens produced divided by input bytes. Lower is better.
Chat template
model-specific format from messages to a flat token sequence.
Special tokens
reserved ids that bypass BPE, such as [BOS], [EOS], [PAD].
Sources

Questions

Why a byte alphabet?
Every UTF-8 input fits the vocabulary before any merge. No unknown token.
Why pre-tokenize?
Without it, merges cross word boundaries and fill the vocabulary with phrases.
Why apply merges in rank order?
Encoding is then deterministic and matches training.
Large or small vocabulary?
Large gives shorter sequences but a larger embedding matrix.
Why seed the shuffle?
Two runs that differ in one hyperparameter then see the same data order.
Sources

Tips

  • Test the round trip on unseen text, an emoji and a literal <|endoftext|>.
  • Measure the compression ratio on held-out text, not on the training corpus.
  • Encode the corpus once at build time, then slice the id stream.
  • Insert <|endoftext|> ids between documents.
  • Use a stride equal to the context length for a large corpus.
  • Size the vocabulary to the model: 32k for under 1B params, 50-100k for 1-10B.
Sources
Original source
---
id: tokenization
title: Tokenization and BPE
code: TB
subtitle: Train, encode and decode a byte-level BPE tokenizer, then slice the id stream into training windows.
hue: 45
sources: phases/05-nlp-foundations-to-advanced/19-subword-tokenization, phases/10-llms-from-scratch/01-tokenizers, phases/10-llms-from-scratch/02-building-a-tokenizer, phases/19-capstone-projects/30-bpe-tokenizer-from-scratch, phases/19-capstone-projects/31-tokenized-dataset-sliding-window
source_hash: 61eb8b567821c32e
---

## Tasks
type: task-table
from: phases/19-capstone-projects/30-bpe-tokenizer-from-scratch/code/main.py, phases/10-llms-from-scratch/01-tokenizers/docs/en.md, phases/10-llms-from-scratch/02-building-a-tokenizer/docs/en.md, phases/05-nlp-foundations-to-advanced/19-subword-tokenization/docs/en.md

| Task | Code |
|---|---|
| Train byte-level BPE | `train(tokenizer, <corpus>, target_vocab_size=320)` |
| Encode with special tokens | `encode(tokenizer, text, allow_special=True)` |
| Decode ids to text | `decode(tokenizer, ids)` |
| Normalize Unicode first | `unicodedata.normalize("NFKC", text)` |
| Encode with tiktoken | `enc = tiktoken.get_encoding("cl100k_base")` then `enc.encode(text)` |
| Train with HF tokenizers | `tokenizer.train(["corpus.txt"], trainer)` |
| Train SentencePiece | `spm.SentencePieceTrainer.train(input="corpus.txt", vocab_size=8000)` |
| Load a model tokenizer | `AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B")` |

## Tokenizer and data pipeline
type: architecture
from: phases/10-llms-from-scratch/02-building-a-tokenizer/docs/en.md, phases/19-capstone-projects/31-tokenized-dataset-sliding-window/docs/en.md

```arch
node | raw | 1,1 | Raw Text |
node | norm | 2,1 | Normalize | NFKC
node | pre | 3,1 | Pre-Tokenize | split into chunks
node | bpe | 4,1 | BPE Merge | merge table
node | spec | 4,2 | Special Tokens | fixed IDs
node | ids | 3,2 | Token IDs | flat list of ids
node | ds | 2,2 | PyTorch Dataset | windows of T+1 ids
node | dl | 1,2 | DataLoader | seeded shuffle, B x T
edge | raw | norm |
edge | norm | pre |
edge | pre | bpe |
edge | bpe | spec |
edge | spec | ids |
edge | ids | ds | slice
edge | ds | dl | batch
```

## Train BPE merges
type: annotated-code
lang: python
from: phases/19-capstone-projects/30-bpe-tokenizer-from-scratch/code/main.py

```python
tokenizer.initialize(specials)                 # 256 bytes, then specials
units = _build_initial_units(corpus)           # byte tuple -> word count
while tokenizer.vocab_size < target_vocab_size:
    pairs = _count_pairs(units)                # weighted by word count
    max_count = max(pairs.values())
    candidates = sorted(p for p, c in pairs.items() if c == max_count)
    best = candidates[0]                       # tie break on the pair
    if pairs[best] < 2:
        break                                  # no pair repeats
    new_id = tokenizer._add_token(
        tokenizer.vocab[best[0]] + tokenizer.vocab[best[1]]
    )                                          # next free id
    tokenizer.merges[best] = new_id            # order is the rank
    units = _apply_merge_to_corpus(units, best, new_id)
```

## Encode fresh text
type: flow
from: phases/19-capstone-projects/30-bpe-tokenizer-from-scratch/docs/en.md, phases/19-capstone-projects/30-bpe-tokenizer-from-scratch/code/main.py

1. **Specials**: with `allow_special=True`, literal special strings get reserved ids.
2. **Pre-tokenize**: `_pretokenize` splits text into whitespace and word runs.
3. **Bytes**: each chunk becomes its UTF-8 byte ids.
4. **Rank**: find the lowest-ranked merge that applies to the chunk.
5. **Merge**: replace that pair with its merged id.
6. **Repeat**: stop when no merge in the table applies.
7. **Join**: concatenate the ids of all chunks in order.

## Sliding window dataset
type: annotated-code
lang: python
from: phases/19-capstone-projects/31-tokenized-dataset-sliding-window/code/main.py

```python
usable = num_ids - (context_length + 1)        # count_windows
return 1 + usable // stride                    # 0 when usable < 0
start = index * self.stride                    # __getitem__
end = start + self.context_length + 1          # T+1 ids
window = self.ids[start:end]
return window[:-1].clone(), window[1:].clone()  # input, target
generator = torch.Generator()
generator.manual_seed(base_seed + epoch)       # same order on rerun
inputs, targets = next(iter(loader))           # (B, T) and (B, T)
```

## Algorithms and formulas
type: concepts
from: phases/10-llms-from-scratch/01-tokenizers/docs/en.md, phases/05-nlp-foundations-to-advanced/19-subword-tokenization/docs/en.md, phases/19-capstone-projects/31-tokenized-dataset-sliding-window/docs/en.md

- **BPE**: merge the most frequent adjacent pair until the target vocabulary size.
- **Byte-level BPE**: base vocabulary of exactly 256 bytes. No unknown token.
- **WordPiece**: merges by `count(AB) / (count(A) * count(B))`. Continuation pieces get `##`.
- **Unigram**: start large, prune tokens whose removal least increases corpus log-likelihood.
- **SentencePiece**: trains BPE or Unigram on raw Unicode. Space is `▁`.
- **tiktoken**: fast encoder for pre-built vocabularies. It does not train.
- **Naive encode cost**: $O(n \cdot \lvert \text{merges} \rvert)$ for one word.
- **Window count**: `max(0, 1 + (N - (T + 1)) // S)` for N ids, context T, stride S.

## Defaults and numbers
type: defaults
from: phases/19-capstone-projects/30-bpe-tokenizer-from-scratch/docs/en.md, phases/05-nlp-foundations-to-advanced/19-subword-tokenization/docs/en.md, phases/10-llms-from-scratch/01-tokenizers/docs/en.md, phases/19-capstone-projects/31-tokenized-dataset-sliding-window/code/main.py

| Setting | Default | Note |
|---|---|---|
| Byte alphabet | 256 | ids for bytes 0x00 to 0xFF |
| GPT-2 vocabulary | 50,257 | 256 bytes, 50,000 merges, 1 special |
| BERT WordPiece vocabulary | 30,522 | `##` continuation prefix |
| Llama 3 vocabulary | 128,256 | byte-level BPE |
| GPT-4o vocabulary | 200,019 | `o200k_base` encoding |
| SentencePiece character_coverage | 0.9995 | 0.995 for Japanese |
| SlidingWindowDataset stride | context_length | used when stride is None |
| make_dataloader num_workers | 0 | slices an in-memory tensor |

## Troubleshooting
type: troubleshooting
from: phases/05-nlp-foundations-to-advanced/19-subword-tokenization/docs/en.md, phases/10-llms-from-scratch/02-building-a-tokenizer/docs/en.md, phases/19-capstone-projects/30-bpe-tokenizer-from-scratch/docs/en.md, phases/19-capstone-projects/30-bpe-tokenizer-from-scratch/code/main.py, phases/19-capstone-projects/31-tokenized-dataset-sliding-window/docs/en.md

| Symptom | Cause | Fix |
|---|---|---|
| Model outputs garbage after deploy | trained on vocab A, deployed with vocab B | check the tokenizer file hash in CI |
| "hello" and " hello" get different ids | whitespace ambiguity | set `add_special_tokens` and `add_prefix_space` explicitly |
| Chat model gives garbage | chat template differs from training | match the training format exactly |
| Non-Latin text costs 5-10x more tokens | English-heavy vocabulary | train a larger multilingual vocabulary |
| `\p{L}` pattern fails | stdlib `re` has no Unicode property escapes | install `regex` |
| Special token encodes as bytes | `allow_special` is off | pass `allow_special=True` |
| `KeyError` unknown token id | id is not in the vocabulary | decode only ids this tokenizer made |
| Last window missing | tail shorter than T+1 ids | expected. The slicer drops it |

## Terms
type: glossary
from: phases/10-llms-from-scratch/01-tokenizers/docs/en.md, phases/10-llms-from-scratch/02-building-a-tokenizer/docs/en.md, phases/05-nlp-foundations-to-advanced/19-subword-tokenization/docs/en.md

- **BPE**: Byte Pair Encoding. Iterative merge of the most frequent pair.
- **NFKC**: Unicode normalization. The "fi" ligature becomes "fi".
- **Merge table**: ordered list of pair merges. It is the tokenizer.
- **Pre-tokenization**: splitting rules before BPE, so merges stay inside words.
- **Fertility**: average tokens per word. 1.0 is perfect.
- **Compression ratio**: tokens produced divided by input bytes. Lower is better.
- **Chat template**: model-specific format from messages to a flat token sequence.
- **Special tokens**: reserved ids that bypass BPE, such as [BOS], [EOS], [PAD].

## Questions
type: qa
from: phases/19-capstone-projects/30-bpe-tokenizer-from-scratch/docs/en.md, phases/10-llms-from-scratch/01-tokenizers/docs/en.md, phases/19-capstone-projects/31-tokenized-dataset-sliding-window/docs/en.md

- **Why a byte alphabet?** Every UTF-8 input fits the vocabulary before any merge. No unknown token.
- **Why pre-tokenize?** Without it, merges cross word boundaries and fill the vocabulary with phrases.
- **Why apply merges in rank order?** Encoding is then deterministic and matches training.
- **Large or small vocabulary?** Large gives shorter sequences but a larger embedding matrix.
- **Why seed the shuffle?** Two runs that differ in one hyperparameter then see the same data order.

## Tips
type: tips
from: phases/19-capstone-projects/30-bpe-tokenizer-from-scratch/docs/en.md, phases/10-llms-from-scratch/01-tokenizers/docs/en.md, phases/19-capstone-projects/31-tokenized-dataset-sliding-window/docs/en.md, phases/05-nlp-foundations-to-advanced/19-subword-tokenization/docs/en.md

- Test the round trip on unseen text, an emoji and a literal <|endoftext|>.
- Measure the compression ratio on held-out text, not on the training corpus.
- Encode the corpus once at build time, then slice the id stream.
- Insert <|endoftext|> ids between documents.
- Use a stride equal to the context length for a large corpus.
- Size the vocabulary to the model: 32k for under 1B params, 50-100k for 1-10B.
Code and artifacts 1
What you take away

Saved in your notebook on this device.

Sources and limits

Reading checkpoints are self-reported. Test results are recorded separately and apply to the executed cases.

AI Engineering from Scratch · 3f9715c · MIT

Download this file ↓