BPE: Byte Pair Encoding - The Most Common Tokenization Method¶
Overview¶
Byte Pair Encoding (BPE) iteratively merges the most frequent pair of bytes/characters to build vocabulary. Simple yet powerful, used by GPT models and many others.
- Paper: "Neural Machine Translation of Rare Words with Subword Units" (Sennrich et al., 2016)
- Adoption: GPT-2, GPT-3, GPT-4, and most modern models
- Principle: Build vocabulary bottom-up from bytes/characters
- Result: Efficient subword tokenization without rare words
How BPE Works¶
Algorithm (Step-by-Step)¶
Start: Every character is a token (character-level)
Goal: Merge frequent pairs to build subwords
Example: "hello world" (repeated 3 times in the corpus)
Initial state (character-level):
- Unique chars: h, e, l, o, (space), w, r, d
- Frequency: h:3, e:3, l:9, o:6, (space):3, w:3, r:3, d:3
- Text (one occurrence): "h e l l o (space) w o r l d"
Step 1: Count adjacent pairs across the whole corpus
- In this TINY corpus every pair appears exactly 3 times:
"h e", "e l", "l l", "l o", "o (space)", "(space) w",
"w o", "o r", "r l", "l d" β all tied at 3
- Tie-break (use the order shown): pick "l l" β merge to "ll"
- Text becomes: "h e ll o (space) w o r ll d"
Step 2: Recount pairs, merge the next most frequent
- Ties again (all pairs appear 3 times):
"h e", "e ll", "ll o", "o (space)", "(space) w",
"w o", "o r", "r ll", "ll d" β all 3
- Pick "o (space)" β merge to "o_"
- Text becomes: "h e ll o_ w o r ll d"
- (Note: "o_" marks the space inside a token β real tokenizers
encode spaces as Δ or β β see the Concrete Example below)
Step 3: Repeat until desired vocab size
- Keep merging most frequent pairs
- Each merge creates a new token
- Stop when vocab size = target (e.g., 50K)
π‘ Tiny corpora tie everywhere β a REAL corpus (millions of words)
gives distinct pair frequencies, so the merge order is deterministic.
Final vocabulary:
- Base: all single characters (the alphabet)
- Plus: every merged unit (one new token per merge step)
- Total size = alphabet size + number of merges
Concrete Example: "hello world"¶
Concrete Example: "hello world"¶
Corpus: "hello world" repeated 3 times (every merge below is justified by frequency 3; ties broken in the order shown).
Initial: h e l l o Δ w o r l d
(Δ represents the space in tokenization)
Iteration 1: Merge l + l β ll
Result: h e ll o Δ w o r ll d
Iteration 2: Merge h + e β he
Result: he ll o Δ w o r ll d
Iteration 3: Merge he + ll β hell
Result: hell o Δ w o r ll d
Iteration 4: Merge hell + o β hello
Result: hello Δ w o r ll d
Iteration 5: Merge Δ + w β Δ w
Result: hello Δ w o r ll d
Iteration 6: Merge Δ w + o β Δ wo
Result: hello Δ wo r ll d
Iteration 7: Merge Δ wo + r β Δ wor
Result: hello Δ wor ll d
Iteration 8: Merge ll + d β lld
Result: hello Δ wor lld
Iteration 9: Merge Δ wor + lld β Δ world
Result: hello Δ world
Final tokens: ["hello", "Δ world"]
Vocabulary: 8 base chars + 9 merges = 17 tokens
π‘ Where did the space go? Iteration 5 glued Δ to the FOLLOWING word (Δ world), which is why GPT-2-style output shows space prefixes. If iteration 5 had merged hello Δ instead, you'd get ["helloΔ ", "world"]. Real tokenizers decide this by design:
GPT-2 byte-level BPE: add_prefix_space=True β "Δ world"
SentencePiece: β prefix β "βworld"
Both avoid a standalone "space" token and keep the boundary
information attached to a word.
BPE Algorithm Implementation¶
Python Implementation¶
def bpe(text, vocab_size=50000, num_merges=None):
"""
Implement BPE from scratch
Args:
text: Raw text
vocab_size: Target vocabulary size
num_merges: Number of merge operations (alt to vocab_size)
"""
from collections import defaultdict, Counter
# Step 1: Split into words and count frequencies
words = text.split()
word_freqs = Counter(words)
# Convert each word into "space-separated chars + </w>"
# e.g. "hello" β "h e l l o </w>" (</w> marks the word end)
vocab = {}
for word, freq in word_freqs.items():
vocab[' '.join(word) + ' </w>'] = freq
# Step 2: Get initial alphabet (all characters)
alphabet = set()
for word in vocab.keys():
for char in word:
if char != ' ':
alphabet.add(char)
# Step 3: Iteratively merge most frequent pair
num_merges_to_do = num_merges or vocab_size - len(alphabet)
for i in range(num_merges_to_do):
# Count all adjacent pairs
pairs = defaultdict(int)
for word, freq in vocab.items():
symbols = word.split()
for j in range(len(symbols) - 1):
pair = (symbols[j], symbols[j+1])
pairs[pair] += freq
# Find most frequent pair
if not pairs:
break
best_pair = max(pairs, key=pairs.get)
# Merge best pair in vocabulary
new_word = ''.join(best_pair)
new_vocab = {}
bigram = ' '.join(best_pair)
replacement = new_word
for word in vocab:
new_word_str = word.replace(bigram, replacement)
new_vocab[new_word_str] = vocab[word]
vocab = new_vocab
alphabet.add(new_word)
if (i + 1) % 100 == 0:
print(f"Merge {i+1}: {best_pair} β {new_word} (vocab size: {len(alphabet)})")
return vocab, alphabet
# Usage:
text = "hello world hello" * 3
vocab, alphabet = bpe(text, num_merges=10)
print(f"Vocabulary size: {len(alphabet)}")
print(f"Sample tokens: {sorted(list(alphabet))[:20]}")
Using Hugging Face Tokenizers¶
from tokenizers import Tokenizer, models, pre_tokenizers, trainers
# Create BPE tokenizer
tokenizer = Tokenizer(models.BPE())
# Set pre-tokenizer (split on spaces/punctuation)
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=True)
# Create trainer
trainer = trainers.BpeTrainer(
vocab_size=50000,
special_tokens=["<|endoftext|>", "<|padding|>"]
)
# Train on files
tokenizer.train(
files=["text_file.txt"],
trainer=trainer
)
# Use tokenizer
encoded = tokenizer.encode("hello world")
print(encoded.tokens) # ['hello', ' world']
print(encoded.ids) # [1234, 5678]
BPE Variants¶
Byte-Level BPE¶
Standard BPE: Character-level (128 ASCII chars)
Byte-Level BPE: Start from bytes (256 possible bytes)
Advantage:
- Can handle any Unicode character
- Doesn't need special handling for languages
- Single representation for all text
- Example: GPT-2, GPT-3 use byte-level BPE
Implementation difference:
```python
# Standard BPE: Characters
text = "hello"
initial_tokens = ['h', 'e', 'l', 'l', 'o']
# Byte-Level BPE: UTF-8 bytes
text = "hello"
utf8_bytes = text.encode('utf-8') # b'hello'
initial_tokens = list(utf8_bytes) # [104, 101, 108, 108, 111]
# Then treat numbers as tokens!
Benefits: - Works for all Unicode - No need for separate character encodings - Same algorithm for all languages - Slightly less efficient (starts with 256 instead of 128)
### Unigram LM
Approach: - Start with vocab_size tokens - Model probability of each token - Remove tokens that don't help (lowest probability) - Iterate until desired vocab size
Vs BPE: - BPE: Greedy merging (bottom-up) - Unigram: Probabilistic (top-down) - Unigram often slightly better quality - But BPE is simpler and widely adopted
Used by: - SentencePiece (can use Unigram or BPE)
> π **WordPiece** (BERT) is the third major family β it merges pairs by
> *likelihood gain* rather than raw frequency, and marks continuations
> with `##`. See [06 Wordpiece](/02-llm-modeling/00-fundamentals/00-tokenization/06-wordpiece/).
---
## BPE Encoding/Decoding
### Encoding (Text β Tokens)
```python
# GPT-2 tokenizer (uses BPE)
import tiktoken
enc = tiktoken.encoding_for_model("gpt-3.5-turbo")
text = "Hello, world! δ½ ε₯½"
tokens = enc.encode(text)
print(tokens)
# Output: [15339, 11, 1917, 0, 242, 25180, 234]
# (approximate; exact IDs depend on the encoding version)
# "Hello"=15339 ","=11 " world"=1917 "!"=0 " δ½ ε₯½"=242,25180,234
# Note: Chinese text takes 3+ tokens in cl100k (see 05-Multilingual)
# Decode back
decoded = enc.decode(tokens)
print(decoded) # "Hello, world! δ½ ε₯½"
Token Composition¶
GPT-3 encoding example:
Text: "hello world"
β
Tokens: ["hello", " world"]
β
Token IDs: [15339, 1917]
Text: "The quick brown fox"
β
Tokens: ["The", " quick", " brown", " fox"]
β
Token IDs: [464, 2068, 6218, 21831]
Text: "extraordinary"
β
Tokens: ["extra", "ordinary"] (might be split if "extraordinary" not in vocab)
β
Token IDs: [12345, 6789]
Text: "π"
β
Tokens: bytes representing emoji
β
Token IDs: multiple IDs (emoji is multi-token)
BPE Limitations & Gotchas¶
Limitation 1: Inefficient for Some Languages¶
English:
Text: "hello world"
Tokens: ["hello", " world"] - 2 tokens
Efficiency: Good
Chinese:
Text: "δ½ ε₯½δΈη"
Tokens: ["δ½ ", "ε₯½", "δΈ", "η"] - 4 tokens
Efficiency: Poor (no spaces!)
Arabic (right-to-left):
Text: "Ω
Ψ±ΨΨ¨Ψ§ Ψ¨Ψ§ΩΨΉΨ§ΩΩ
"
Tokens: Multiple bytes
Efficiency: Medium
Lesson: BPE optimal for English, less so for other languages
Limitation 2: Long Subword Sequences¶
Problem: Rare long words split into many tokens
Word: "internationalization"
BPE encoding: ["inter", "nation", "al", "ization"] - 4 tokens
Problem: Lost structure, harder to learn
Word: "pneumonoultramicroscopicsilicovolcanoconiosis"
BPE encoding: 15+ tokens!
Problem: Very inefficient, sequence grows
Solution: Larger vocabulary can help
But: Vocab size is limited (computational trade-off)
Limitation 3: Suboptimal for Code¶
Python code:
def count_elements():
return len(items)
BPE tokenization:
["def", " count", "_", "elements", "():", "\n", " ", "return", " len", "(", "items", ")", "\n"]
Problems:
- "_" is separate token (should be part of name)
- "()" might be merged or separate
- Indentation awkward
- Not great for code!
Better: Code-aware tokenizers
- Understand Python syntax
- Better splitting at meaningful boundaries
- More efficient for code
BPE Performance¶
Token Efficiency Comparison¶
Model Vocab Size Avg Tokens/Word Language
ββββββββββββββββββββββββββββββββββββββββββββββββββ
GPT-2 (BPE) 50K 1.15 English
GPT-3 (BPE) 50K 1.15 English
BERT 30K 1.25 English
LLaMA 32K 1.20 English
Mistral 32K 1.20 English
Token inflation (tokens per word > 1):
Text Tokens/Word
βββββββββββββββββββββββββββββββββ
Common English 1.10
Technical English 1.20
Code 1.30
Mixed languages 1.50
All Unicode 2.00
Implication:
- 1M word document = 1.2M tokens (typical)
- Training cost β tokens
- Better tokenizer = 10-20% cost savings
Practical BPE Tips¶
Training a Custom BPE Tokenizer¶
from tokenizers import Tokenizer, models, pre_tokenizers, trainers, processors
# Create BPE tokenizer with custom config
tokenizer = Tokenizer(models.BPE())
# Configure
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=True)
tokenizer.post_processor = processors.ByteLevel(trim_offsets=True)
# Train on your data
trainer = trainers.BpeTrainer(
vocab_size=32000,
min_frequency=2, # Minimum frequency to include
special_tokens=[
"<|begin_of_text|>",
"<|end_of_text|>",
"<|padding|>",
]
)
files = [
"data/train.txt",
"data/validation.txt",
]
tokenizer.train(files=files, trainer=trainer)
# Save for later use
tokenizer.save("my_tokenizer.json")
# Load and use
loaded_tokenizer = Tokenizer.from_file("my_tokenizer.json")
encoded = loaded_tokenizer.encode("hello world")
Key Takeaways¶
π BPE: Iteratively merge most frequent character pairs
π Simple yet effective: Character-level β subwords β vocabulary
π Widely adopted: GPT-2, GPT-3, GPT-4 use BPE
πΎ Efficient: ~32-50K vocab handles most text
β οΈ Trade-offs: Inefficient for some languages, not optimal for code
Related Notes in Tokenization Subdirectory¶
- 00 Tokenization Fundamentals - Overview
- 02 Sentencepiece - Modern alternative to BPE
- 06 Wordpiece - BERT-style variant (## continuations)
- 03 Token Efficiency & Compression - Improving token count
- 04 Tokenization Best Practices - Tips and tricks