Skip to content

Tokenization Deep Dive

Tokenization is the bridge between human-readable text and the numerical representations that LLMs understand. This module explores the algorithms, trade-offs, and practical implications of different tokenization strategies.

Learning Objectives

  • Implement the BPE algorithm from scratch and understand its iterative merge process
  • Compare tokenization libraries (tiktoken, SentencePiece, Hugging Face) and their use cases
  • Analyze vocabulary trade-offs including size, coverage, efficiency, and multilingual support
  • Debug tokenization issues in production systems including unknown tokens and boundary effects
  • Calculate token counts for cost estimation and context window management

Why Tokenization Matters

Think of tokenization as choosing the alphabet for your language model. The choice affects:

  1. Model capacity - Vocabulary size determines embedding table size
  2. Sequence length - More tokens per text = more compute = shorter effective context
  3. Generation quality - Poor tokenization leads to spelling errors and rare word issues
  4. Multilingual fairness - Some languages get 2-3x more tokens for the same meaning

The Tokenization Spectrum

StrategyExample: "unhappiness"ProsCons
Character['u','n','h','a','p','p','i','n','e','s','s']Handles any textVery long sequences
Word['unhappiness']Semantic unitsOOV problems, huge vocab
Subword (BPE)['un', 'happiness'] or ['un', 'happ', 'iness']BalancedRequires training

BPE Algorithm (Byte-Pair Encoding)

BPE was originally a compression algorithm, repurposed for NLP by Sennrich et al. (2016). It iteratively merges the most frequent adjacent pairs of symbols.

The Algorithm

Implementation from Scratch

python
from collections import defaultdict
import re
from typing import Dict, List, Tuple

class BPETokenizer:
    """
    Byte-Pair Encoding tokenizer implementation.

    The algorithm:
    1. Initialize vocabulary with all characters
    2. Count frequency of all adjacent symbol pairs
    3. Merge most frequent pair into new symbol
    4. Repeat until desired vocabulary size
    """

    def __init__(self, vocab_size: int = 1000):
        self.vocab_size = vocab_size
        self.merges: List[Tuple[str, str]] = []
        self.vocab: Dict[str, int] = {}

    def _get_stats(self, word_freqs: Dict[Tuple[str, ...], int]) -> Dict[Tuple[str, str], int]:
        """Count frequency of adjacent pairs."""
        pairs = defaultdict(int)
        for word, freq in word_freqs.items():
            symbols = word
            for i in range(len(symbols) - 1):
                pairs[(symbols[i], symbols[i + 1])] += freq
        return pairs

    def _merge_pair(
        self,
        word_freqs: Dict[Tuple[str, ...], int],
        pair: Tuple[str, str]
    ) -> Dict[Tuple[str, ...], int]:
        """Merge all occurrences of a pair in the vocabulary."""
        new_word_freqs = {}
        bigram = pair
        replacement = pair[0] + pair[1]

        for word, freq in word_freqs.items():
            new_word = []
            i = 0
            while i < len(word):
                if i < len(word) - 1 and (word[i], word[i + 1]) == bigram:
                    new_word.append(replacement)
                    i += 2
                else:
                    new_word.append(word[i])
                    i += 1
            new_word_freqs[tuple(new_word)] = freq

        return new_word_freqs

    def train(self, corpus: List[str]) -> None:
        """Train BPE on a corpus of text."""
        # Step 1: Build initial word frequencies with character splits
        word_freqs = defaultdict(int)
        for text in corpus:
            words = text.lower().split()
            for word in words:
                # Add end-of-word marker
                word_tuple = tuple(word) + ('</w>',)
                word_freqs[word_tuple] += 1

        # Build initial character vocabulary
        self.vocab = {'</w>': 0}
        for word in word_freqs:
            for char in word:
                if char not in self.vocab:
                    self.vocab[char] = len(self.vocab)

        # Step 2: Iteratively merge most frequent pairs
        num_merges = self.vocab_size - len(self.vocab)

        for i in range(num_merges):
            pairs = self._get_stats(word_freqs)
            if not pairs:
                break

            # Find most frequent pair
            best_pair = max(pairs, key=pairs.get)
            self.merges.append(best_pair)

            # Add merged symbol to vocabulary
            new_symbol = best_pair[0] + best_pair[1]
            self.vocab[new_symbol] = len(self.vocab)

            # Update word frequencies
            word_freqs = self._merge_pair(word_freqs, best_pair)

            if (i + 1) % 100 == 0:
                print(f"Merge {i + 1}: {best_pair} -> {new_symbol}")

    def encode(self, text: str) -> List[int]:
        """Encode text to token IDs."""
        tokens = []
        words = text.lower().split()

        for word in words:
            word_tokens = list(word) + ['</w>']

            # Apply merges in order
            for merge in self.merges:
                i = 0
                while i < len(word_tokens) - 1:
                    if (word_tokens[i], word_tokens[i + 1]) == merge:
                        word_tokens = (
                            word_tokens[:i] +
                            [merge[0] + merge[1]] +
                            word_tokens[i + 2:]
                        )
                    else:
                        i += 1

            # Convert to IDs
            for token in word_tokens:
                if token in self.vocab:
                    tokens.append(self.vocab[token])
                else:
                    # Handle unknown (fall back to characters)
                    for char in token:
                        tokens.append(self.vocab.get(char, 0))

        return tokens

    def decode(self, token_ids: List[int]) -> str:
        """Decode token IDs back to text."""
        id_to_token = {v: k for k, v in self.vocab.items()}
        tokens = [id_to_token.get(id, '?') for id in token_ids]
        text = ''.join(tokens).replace('</w>', ' ').strip()
        return text


# Demonstration
corpus = [
    "the cat sat on the mat",
    "the cat ate the rat",
    "the rat sat on the cat",
] * 100  # Repeat for frequency

tokenizer = BPETokenizer(vocab_size=50)
tokenizer.train(corpus)

test = "the cat sat"
encoded = tokenizer.encode(test)
decoded = tokenizer.decode(encoded)
print(f"Original: {test}")
print(f"Encoded: {encoded}")
print(f"Decoded: {decoded}")

Complexity Analysis

Training time: O(V * N * M) where V is vocab size, N is corpus size, M is average sequence length Encoding time: O(M * V) per word in worst case, but typically much faster with optimizations Space: O(V) for vocabulary + O(merges) for merge rules

SentencePiece

SentencePiece is a language-agnostic tokenizer that treats the input as a raw stream of characters, including whitespace. This makes it ideal for multilingual models.

python
import sentencepiece as spm

# Training a SentencePiece model
spm.SentencePieceTrainer.train(
    input='corpus.txt',
    model_prefix='my_tokenizer',
    vocab_size=32000,
    model_type='bpe',  # or 'unigram'
    character_coverage=0.9995,
    pad_id=0,
    unk_id=1,
    bos_id=2,
    eos_id=3,
)

# Loading and using
sp = spm.SentencePieceProcessor(model_file='my_tokenizer.model')

text = "Hello, how are you?"
tokens = sp.encode(text, out_type=str)
ids = sp.encode(text, out_type=int)

print(f"Tokens: {tokens}")  # ['▁Hello', ',', '▁how', '▁are', '▁you', '?']
print(f"IDs: {ids}")        # [123, 45, 67, 89, 101, 112]

# Decode back
decoded = sp.decode(ids)
print(f"Decoded: {decoded}")  # "Hello, how are you?"

Key Features of SentencePiece

FeatureDescription
Whitespace handlingUses (U+2581) to mark word boundaries
Language agnosticWorks on raw Unicode, no pre-tokenization
Model typesSupports BPE and Unigram
Subword regularizationCan sample different segmentations during training

tiktoken (OpenAI)

tiktoken is OpenAI's fast BPE tokenizer, used by GPT-3.5, GPT-4, and the OpenAI API.

python
import tiktoken

# Get encoder for a specific model
enc = tiktoken.encoding_for_model("gpt-4")

# Or by encoding name
enc = tiktoken.get_encoding("cl100k_base")

text = "Hello, how are you today?"

# Encode
tokens = enc.encode(text)
print(f"Tokens: {tokens}")
print(f"Token count: {len(tokens)}")

# Decode
decoded = enc.decode(tokens)
print(f"Decoded: {decoded}")

# Inspect individual tokens
for token_id in tokens:
    token_bytes = enc.decode_single_token_bytes(token_id)
    print(f"{token_id} -> {token_bytes}")

# Special tokens
enc_with_special = tiktoken.get_encoding("cl100k_base")
special_tokens = enc_with_special.encode(
    "<|endoftext|>",
    allowed_special={"<|endoftext|>"}
)

tiktoken Encodings

python
# Common tiktoken encodings
ENCODINGS = {
    "cl100k_base": {
        "models": ["gpt-4", "gpt-3.5-turbo", "text-embedding-ada-002"],
        "vocab_size": 100277,
        "special_tokens": ["<|endoftext|>", "<|fim_prefix|>", "<|fim_middle|>", "<|fim_suffix|>"]
    },
    "p50k_base": {
        "models": ["text-davinci-003", "code-davinci-002"],
        "vocab_size": 50281,
    },
    "r50k_base": {
        "models": ["davinci", "curie", "babbage", "ada"],
        "vocab_size": 50257,
    }
}

def estimate_tokens(text: str, model: str = "gpt-4") -> int:
    """Estimate token count for cost calculation."""
    enc = tiktoken.encoding_for_model(model)
    return len(enc.encode(text))

def truncate_to_tokens(text: str, max_tokens: int, model: str = "gpt-4") -> str:
    """Truncate text to fit within token limit."""
    enc = tiktoken.encoding_for_model(model)
    tokens = enc.encode(text)
    if len(tokens) <= max_tokens:
        return text
    return enc.decode(tokens[:max_tokens])

Vocabulary Trade-offs

Vocabulary Size Impact

python
import tiktoken

def analyze_tokenization_efficiency(text: str):
    """Compare tokenization across different encodings."""
    encodings = ["cl100k_base", "p50k_base", "r50k_base"]

    print(f"Text: {text[:50]}..." if len(text) > 50 else f"Text: {text}")
    print("-" * 60)

    for enc_name in encodings:
        enc = tiktoken.get_encoding(enc_name)
        tokens = enc.encode(text)

        # Calculate compression ratio
        chars_per_token = len(text) / len(tokens)

        print(f"{enc_name}:")
        print(f"  Tokens: {len(tokens)}")
        print(f"  Chars/token: {chars_per_token:.2f}")
        print()

# Test with different text types
english = "The quick brown fox jumps over the lazy dog."
code = "def fibonacci(n): return n if n < 2 else fibonacci(n-1) + fibonacci(n-2)"
multilingual = "Hello world! Bonjour le monde! Hola mundo!"

analyze_tokenization_efficiency(english)
analyze_tokenization_efficiency(code)
analyze_tokenization_efficiency(multilingual)

Multilingual Considerations

Different languages have vastly different tokenization efficiency:

python
def compare_languages():
    """Show tokenization disparity across languages."""
    enc = tiktoken.get_encoding("cl100k_base")

    # Same meaning, different languages
    texts = {
        "English": "Hello, how are you?",
        "Spanish": "Hola, como estas?",
        "Chinese": "你好,你好吗?",
        "Arabic": "مرحبا، كيف حالك؟",
        "Hindi": "नमस्ते, आप कैसे हैं?",
    }

    for lang, text in texts.items():
        tokens = enc.encode(text)
        print(f"{lang}: '{text}'")
        print(f"  Tokens: {len(tokens)}, Chars: {len(text)}")
        print(f"  Ratio: {len(tokens)/len(text):.2f} tokens/char")
        print()

# This reveals that non-English languages often require
# 2-5x more tokens for equivalent meaning

Interview Insight

Multilingual tokenization disparity is a fairness issue. Users of non-Latin script languages:

  • Pay more per API call (charged by token)
  • Get shorter effective context windows
  • May experience worse model performance

This is why models like LLaMA 2 and Gemini invested heavily in multilingual tokenization.

Handling Special Cases

Unknown Tokens (UNK)

python
def handle_unk_tokens(text: str, tokenizer) -> List[int]:
    """
    Strategy for handling out-of-vocabulary tokens.

    Modern BPE tokenizers rarely produce UNK because they
    can fall back to byte-level encoding.
    """
    tokens = []
    for token in tokenizer.encode(text):
        if token == tokenizer.unk_id:
            # Option 1: Skip
            continue
            # Option 2: Replace with special token
            # tokens.append(tokenizer.pad_id)
            # Option 3: Byte-level fallback (if supported)
        else:
            tokens.append(token)
    return tokens

Token Boundaries and Text Generation

python
def demonstrate_boundary_issues():
    """
    Token boundaries can cause unexpected generation behavior.
    """
    import tiktoken
    enc = tiktoken.get_encoding("cl100k_base")

    # These look similar but tokenize differently
    examples = [
        "ChatGPT",      # Single token or multiple?
        "Chat GPT",     # With space
        "chatgpt",      # Lowercase
        "CHATGPT",      # Uppercase
    ]

    for ex in examples:
        tokens = enc.encode(ex)
        token_strs = [enc.decode([t]) for t in tokens]
        print(f"'{ex}' -> {token_strs}")

# Output shows that capitalization and spacing
# significantly affect tokenization

Practical Token Counting

python
from typing import List, Dict
import tiktoken

class TokenCounter:
    """Production-ready token counting utilities."""

    def __init__(self, model: str = "gpt-4"):
        self.encoding = tiktoken.encoding_for_model(model)

    def count_tokens(self, text: str) -> int:
        """Count tokens in a string."""
        return len(self.encoding.encode(text))

    def count_messages(self, messages: List[Dict[str, str]]) -> int:
        """
        Count tokens in a chat conversation.

        Based on OpenAI's token counting guidelines.
        """
        tokens_per_message = 3  # <|im_start|>role\n...content<|im_end|>
        tokens_per_name = 1

        num_tokens = 0
        for message in messages:
            num_tokens += tokens_per_message
            for key, value in message.items():
                num_tokens += self.count_tokens(value)
                if key == "name":
                    num_tokens += tokens_per_name

        num_tokens += 3  # <|im_start|>assistant
        return num_tokens

    def estimate_cost(
        self,
        input_tokens: int,
        output_tokens: int,
        model: str = "gpt-4"
    ) -> float:
        """Estimate API cost based on token counts."""
        # Prices as of 2024 (update as needed)
        prices = {
            "gpt-4": {"input": 0.03, "output": 0.06},
            "gpt-4-turbo": {"input": 0.01, "output": 0.03},
            "gpt-3.5-turbo": {"input": 0.0005, "output": 0.0015},
        }

        price = prices.get(model, prices["gpt-4"])
        cost = (input_tokens * price["input"] + output_tokens * price["output"]) / 1000
        return cost


# Usage
counter = TokenCounter()

messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "What is the capital of France?"},
]

total_tokens = counter.count_messages(messages)
print(f"Total tokens: {total_tokens}")
print(f"Estimated cost: ${counter.estimate_cost(total_tokens, 50):.4f}")

Interview Q&A

Q1: Why do modern LLMs use subword tokenization instead of word or character level?

Strong Answer:

Subword tokenization (BPE, WordPiece, Unigram) is the sweet spot between word-level and character-level approaches:

vs. Word-level:

  • Word tokenizers require a fixed vocabulary and can't handle new words (OOV problem)
  • Vocabulary would need to be enormous (millions of words) to cover all cases
  • Morphologically rich languages become impossible (German compound words, agglutinative languages)
  • Subword can represent ANY word by combining subunits: "unhappiness" -> "un" + "happiness"

vs. Character-level:

  • Characters create very long sequences (5-7x longer)
  • Attention is O(n^2), so longer sequences are computationally expensive
  • Models struggle to learn long-range dependencies
  • Semantic information is diluted across many positions

Subword benefits:

  1. Open vocabulary: Can represent any text, including neologisms and typos
  2. Compact sequences: Typically 1-4 characters per token
  3. Semantic coherence: Common words often remain whole tokens
  4. Efficient training: Reasonable vocabulary sizes (32K-100K)

Q2: How does the choice of vocabulary size affect model performance and efficiency?

Strong Answer:

Vocabulary size creates a three-way trade-off:

Smaller vocabulary (8K-16K):

  • Pros: Smaller embedding table, better generalization on rare words
  • Cons: Longer sequences, more compute per text, shorter effective context
  • Use case: Resource-constrained, single-language models

Larger vocabulary (100K+):

  • Pros: Shorter sequences, better for multilingual, common words stay whole
  • Cons: More parameters in embedding layer, sparse updates for rare tokens
  • Use case: Multilingual models, API cost optimization

The math:

  • Embedding memory = vocab_size * embed_dim * bytes_per_param
  • For vocab=100K, embed=4096, fp16: 100K * 4096 * 2 = 800MB just for embeddings
  • Sequence length roughly inversely proportional to vocab size

Practical impact:

  • GPT-2 (50K vocab): ~4 chars/token for English
  • GPT-4 (100K vocab): ~4-5 chars/token for English, better multilingual
  • The 2x vocabulary increase from GPT-2 to GPT-4 reduced token counts by ~15-20%

Q3: What are the challenges with tokenizing code, and how do modern tokenizers address them?

Strong Answer:

Code tokenization has unique challenges:

  1. Whitespace significance: Python indentation is semantic, but many tokenizers normalize whitespace

    • Solution: Special tokens for indentation levels or preserving exact whitespace
  2. Identifier fragmentation: Variable names like getUserById get split unpredictably

    • This can cause: get, User, By, Id or getUser, ById
    • Impacts code completion quality and understanding
  3. Syntax tokens: Brackets, operators need to be single tokens for proper parsing

    • != should be one token, not ! + =
  4. Language diversity: Python, JavaScript, Rust have different patterns

    • Tokenizers trained on general text may be suboptimal

Modern solutions:

  • Code-specific training: Codex/GitHub Copilot trained on code corpora
  • Fill-in-the-middle tokens: <|fim_prefix|>, <|fim_suffix|> for code completion
  • Byte-level fallback: Can always represent any byte sequence
  • Larger vocabularies: More code patterns become single tokens
python
# Example: Codex tokenization preserves code structure better
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")

code = "def fibonacci(n):\n    if n < 2:\n        return n"
tokens = enc.encode(code)
# Ideally: ['def', ' fibonacci', '(n', '):', '\n    ', 'if', ' n', ' <', ' 2', ':']
# Each meaningful unit stays together

Summary Table

TokenizerAlgorithmVocab SizeBest For
tiktoken (cl100k_base)BPE100KGPT-4, OpenAI API
SentencePiece (BPE)BPEConfigurableMultilingual, LLaMA
SentencePiece (Unigram)Unigram LMConfigurablemT5, multilingual
WordPieceGreedy30KBERT, DistilBERT
Byte-level BPEBPE on bytes50KGPT-2, RoBERTa

Sources

  • Sennrich et al. "Neural Machine Translation of Rare Words with Subword Units" (2016) - Original BPE for NLP
  • Kudo & Richardson "SentencePiece: A simple and language independent subword tokenizer" (2018)
  • OpenAI tiktoken documentation and source code
  • Hugging Face Tokenizers library documentation
  • Radford et al. "Language Models are Unsupervised Multitask Learners" (GPT-2, 2019) - Byte-level BPE
  • Wu et al. "Google's Neural Machine Translation System" (2016) - WordPiece