Перейти к содержанию

Реализация BPE-токенизатора с нуля

~7 минут чтения

Предварительно: Токенизация LLM | Сравнение токенизаций

Зачем это нужно

Токенизация -- первый шаг любой LLM, и BPE (Byte Pair Encoding) -- самый распространенный алгоритм: его используют GPT-⅔/4, LLaMA, RoBERTa. Идея проста: начни с отдельных символов и итеративно склеивай самые частые пары. За 10K итераций алгоритм сам выучит, что "th"+"e" = "the", "ing" -- частый суффикс, а "##" в WordPiece отмечает продолжение слова. Реализация с нуля на Python занимает ~100 строк и дает глубокое понимание того, как текст превращается в числа для модели.

Обзор

Ключевой инсайт: BPE начинает с отдельных символов и итеративно сливает самые частые пары в новые токены, пока не достигнет целевого размера словаря. Это балансирует между размером словаря и длиной последовательности, обрабатывая редкие слова через subword-декомпозицию.

Токенизаторы 2026:

Токенизатор Модели Особенность
BPE GPT-⅔/4, RoBERTa Merge-based, greedy
Unigram T5, ALBERT Вероятностный, несколько сегментаций
WordPiece BERT ## префикс для subwords
SentencePiece mT5, XLNet Language-agnostic
Byte-level BPE GPT-2+, LLaMA Работает с сырыми байтами

Алгоритм BPE

Основной алгоритм

graph TD
    IN["Input: корпус текста,<br/>целевой размер словаря V"] --> S1
    S1["1. Инициализация<br/>vocab = все уникальные символы<br/>text = посимвольная разбивка"] --> S2
    S2["2. Подсчет пар<br/>pairs = {('t','h'): 50, ('h','e'): 100, ...}"] --> S3
    S3["3. Слияние лучшей пары<br/>best = ('t','h') → 'th'<br/>vocab.add('th')"] --> CHECK
    CHECK{"|vocab| = V?"} -->|Нет| S2
    CHECK -->|Да| OUT["Output: словарь + merge rules"]

    style IN fill:#e8eaf6,stroke:#3f51b5
    style S1 fill:#e8f5e9,stroke:#4caf50
    style S2 fill:#e8f5e9,stroke:#4caf50
    style S3 fill:#fff3e0,stroke:#ef6c00
    style CHECK fill:#f3e5f5,stroke:#9c27b0
    style OUT fill:#e8eaf6,stroke:#3f51b5

Пошаговый пример

Corpus: "the cat the dog"

Initial (character level):
  ["t", "h", "e", " ", "c", "a", "t", " ", "t", "h", "e", " ", "d", "o", "g"]

Iteration 1: ("t", "h") → "th"
  ["th", "e", " ", "c", "a", "t", " ", "th", "e", " ", "d", "o", "g"]

Iteration 2: ("th", "e") → "the"
  ["the", " ", "c", "a", "t", " ", "the", " ", "d", "o", "g"]

Iteration 3: ("t", "h") already merged...
  Continue with next most frequent pair

... until vocabulary size reached

Реализация на Python

Базовый BPE Trainer

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

class BPETrainer:
    """Train a BPE tokenizer from scratch."""

    def __init__(self, vocab_size: int = 1000):
        self.vocab_size = vocab_size
        self.vocab: Set[str] = set()
        self.merges: List[Tuple[str, str]] = []  # (1)!

    def train(self, corpus: str) -> None:
        """Train BPE on corpus."""
        # Step 1: Tokenize to characters
        words = re.findall(r'\w+|\S', corpus)  # (2)!
        word_freqs = defaultdict(int)
        for word in words:
            word_freqs[tuple(word)] += 1  # (3)!

        # Initialize vocabulary with characters
        self.vocab = set(char for word in word_freqs for char in word)
        self.merges = []

        # Iteratively merge pairs
        while len(self.vocab) < self.vocab_size:
            # Count pairs
            pairs = self._get_pairs(word_freqs)
            if not pairs:
                break

            # Find best pair
            best_pair = max(pairs, key=pairs.get)  # (4)!

            # Merge
            word_freqs = self._merge_pair(word_freqs, best_pair)
            new_token = best_pair[0] + best_pair[1]
            self.vocab.add(new_token)
            self.merges.append(best_pair)  # (5)!

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

    def _merge_pair(
        self,
        word_freqs: Dict,
        pair: Tuple[str, str]
    ) -> Dict:
        """Merge all occurrences of pair."""
        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
  1. Порядок merges критичен -- при encoding применяем их в том же порядке. Это единственное, что нужно сохранить вместе со словарем.
  2. Pre-tokenization: разбиваем на слова/символы ДО обучения BPE. GPT-2 использует более сложный regex для лучшего разбиения.
  3. Слова хранятся как tuple символов: "cat" -> ("c", "a", "t"). Tuple нужен как ключ dict (list нельзя хэшировать).
  4. Greedy: всегда сливаем самую частую пару. Не оптимально глобально, но работает хорошо на практике.
  5. Merge rules записываются в порядке обучения -- этот порядок используется при encode.

BPE Tokenizer (кодирование)

class BPETokenizer:
    """BPE tokenizer for encoding text."""

    def __init__(self, vocab: Set[str], merges: List[Tuple[str, str]]):
        self.vocab = vocab
        self.merges = merges
        self.merge_ranks = {merge: i for i, merge in enumerate(merges)}

    def encode(self, text: str) -> List[str]:
        """Encode text using learned BPE."""
        # Start with characters
        tokens = list(text)

        # Apply merges in order of priority
        while len(tokens) >= 2:
            # Find the best pair to merge (lowest rank)
            best_pair = None
            best_rank = float('inf')

            for i in range(len(tokens) - 1):
                pair = (tokens[i], tokens[i + 1])
                if pair in self.merge_ranks:
                    rank = self.merge_ranks[pair]
                    if rank < best_rank:
                        best_rank = rank
                        best_pair = pair

            if best_pair is None:
                break

            # Merge all occurrences
            tokens = self._merge(tokens, best_pair)

        return tokens

    def _merge(self, tokens: List[str], pair: Tuple[str, str]) -> List[str]:
        """Merge all occurrences of pair in token list."""
        new_tokens = []
        i = 0
        while i < len(tokens):
            if i < len(tokens) - 1 and tokens[i] == pair[0] and tokens[i + 1] == pair[1]:
                new_tokens.append(pair[0] + pair[1])
                i += 2
            else:
                new_tokens.append(tokens[i])
                i += 1
        return new_tokens

    def decode(self, tokens: List[str]) -> str:
        """Decode tokens back to string."""
        return ''.join(tokens)

Полный пример

# Train
corpus = "the cat sat on the mat the cat the dog"
trainer = BPETrainer(vocab_size=50)
trainer.train(corpus)

print(f"Vocabulary: {trainer.vocab}")
print(f"Merges: {trainer.merges}")

# Encode
tokenizer = BPETokenizer(trainer.vocab, trainer.merges)
tokens = tokenizer.encode("the cat")
print(f"Tokens: {tokens}")  # ['the', ' ', 'c', 'a', 't']

# Decode
text = tokenizer.decode(tokens)
print(f"Decoded: {text}")  # 'the cat'

Byte-Level BPE

Зачем byte-level?

Проблемы character-level BPE:

  • Unicode содержит 100K+ символов -- огромный начальный словарь
  • Невиданные символы становятся UNK
  • Мультиязычный текст взрывает размер словаря

Byte-level решение:

  • Начальный словарь фиксирован: 256 байтовых токенов -- и всё
  • Любой Unicode-текст представим через UTF-8 байты
  • Ноль UNK -- работает для всех языков, эмодзи, спецсимволов

Пример: "hello"[104, 101, 108, 108, 111] (байты) | "你好"[228, 189, 160, 229, 165, 189] (UTF-8)

Реализация Byte-Level BPE

class ByteLevelBPE:
    """Byte-level BPE as used in GPT-2/3/4."""

    def __init__(self, vocab_size: int = 50257):
        self.vocab_size = vocab_size
        # Base vocabulary: 256 bytes + special tokens
        self.byte_encoder = self._bytes_to_unicode()
        self.byte_decoder = {v: k for k, v in self.byte_encoder.items()}

    def _bytes_to_unicode(self) -> Dict[int, str]:
        """Map bytes to unicode characters for readability."""
        # Printable ASCII stays as-is
        bs = list(range(ord('!'), ord('~') + 1))
        bs += list(range(ord('¡'), ord('¬') + 1))
        bs += list(range(ord('®'), ord('ÿ') + 1))
        cs = bs[:]
        n = 0
        for b in range(256):
            if b not in bs:
                bs.append(b)
                cs.append(256 + n)
                n += 1
        return {b: chr(c) for b, c in zip(bs, cs)}

    def tokenize(self, text: str) -> List[str]:
        """Tokenize text using byte-level BPE."""
        # Convert to bytes, then to unicode representation
        tokens = []
        for byte in text.encode('utf-8'):
            tokens.append(self.byte_encoder[byte])
        return tokens

Сравнение токенизаторов

BPE vs Unigram vs WordPiece

BPE (GPT, RoBERTa): greedy merging частых пар, детерминированная сегментация, быстрый encoding, может пропустить оптимальное разбиение.

Unigram (T5, ALBERT): вероятностная модель, несколько возможных сегментаций, лучше для языков без пробелов (CJK), EM-алгоритм для обучения.

WordPiece (BERT): похож на BPE, но с ## префиксом для subwords. "playing"["play", "##ing"] -- четкие границы подслов.

Таблица сравнения

Свойство BPE Unigram WordPiece
Обучение Слияние пар EM-алгоритм Слияние пар
Сегментация Детерминированная Вероятностная Детерминированная
Маркер subword Нет Нет ##
Скорость Быстрая Средняя Быстрая
Мультиязычность Хорошая Лучше Хорошая
Используется GPT, RoBERTa T5, XLNet BERT

Специальные токены

Распространенные специальные токены

Токен Назначение Модели
<|endoftext|> End of text GPT-⅔/4
<|pad|> Padding Most models
<|unk|> Unknown token Legacy models
<s> Start of sequence LLaMA
</s> End of sequence LLaMA
[CLS] Classification BERT
[SEP] Separator BERT
[MASK] Masking for MLM BERT

Обработка специальных токенов

class BPETokenizerWithSpecial:
    """BPE tokenizer with special tokens."""

    SPECIAL_TOKENS = {
        '<|endoftext|>': 50256,
        '<|pad|>': 50257,
        '<|unk|>': 50258,
    }

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

        # Check for special tokens first
        i = 0
        while i < len(text):
            found_special = False
            for special, token_id in self.SPECIAL_TOKENS.items():
                if text[i:].startswith(special):
                    tokens.append(token_id)
                    i += len(special)
                    found_special = True
                    break

            if not found_special:
                # Regular BPE encoding
                # ... (implementation as before)
                i += 1

        return tokens

Продакшен-оптимизации

Оптимизация производительности

class OptimizedBPE:
    """Production-ready BPE with optimizations."""

    def __init__(self, vocab: Set[str], merges: List[Tuple[str, str]]):
        self.vocab = vocab
        self.merges = merges
        self.merge_ranks = {merge: i for i, merge in enumerate(merges)}

        # Pre-compile regex for word splitting
        self.pattern = re.compile(r"""'s|'t|'re|'ve|'m|'ll|'d| ?\w+| ?\d+| ?[^\s\w\d]+|\s+(?!\S)|\s+""")

    def encode_batch(self, texts: List[str]) -> List[List[int]]:
        """Encode multiple texts in parallel."""
        return [self.encode(text) for text in texts]

    def encode_fast(self, text: str) -> List[int]:
        """Fast encoding with word-level caching."""
        words = self.pattern.findall(text)
        tokens = []

        for word in words:
            # Could add word-level cache here
            word_tokens = self._encode_word(word)
            tokens.extend(word_tokens)

        return tokens

    def _encode_word(self, word: str) -> List[str]:
        """Encode a single word."""
        tokens = list(word)

        while len(tokens) >= 2:
            # Find best pair
            pairs = [(tokens[i], tokens[i + 1]) for i in range(len(tokens) - 1)]
            ranked = [(p, self.merge_ranks.get(p, float('inf'))) for p in pairs]

            best_pair, best_rank = min(ranked, key=lambda x: x[1])
            if best_rank == float('inf'):
                break

            tokens = self._merge(tokens, best_pair)

        return tokens

Компромиссы размера словаря

Размер словаря Длина последовательности Размер модели Покрытие
10K Длиннее Меньше Слабое
30K Баланс Средний Хорошее
50K Короче Больше Отличное
100K Самая короткая Самый большой Очень высокое

Числа для интервью

Размеры словарей

Модель Размер словаря Тип
GPT-2 50,257 Byte-level BPE
GPT-¾ ~100,000 Byte-level BPE
LLaMA 32,000 SentencePiece BPE
BERT 30,000 WordPiece
T5 32,000 SentencePiece Unigram

Коэффициенты сжатия

Язык Отношение символы:токены Сжатие
Английский 4:1 75%
Китайский 1.5:1 33%
Код 3:1 66%
Смешанный 3:1 66%

Потребление памяти

Размер словаря Память Embedding (d=4096)
32K 512 MB
50K 800 MB
100K 1.6 GB

Время обучения

Размер корпуса Размер словаря Время обучения
1 GB 30K 5 минут
10 GB 50K 30 минут
100 GB 100K 3 часа

Gotchas

Порядок merge правил критичен

При encoding BPE должен применять merges в том порядке, в котором они были выучены при training. Merge ('t','h')->'th' перед ('th','e')->'the'. Если нарушить порядок -- получишь другую токенизацию и модель будет видеть незнакомые токены. Это самая частая ошибка при реализации с нуля.

Byte-level BPE -- не то же что Character-level BPE

Character-level BPE начинает с Unicode символов (~100K+ начальный словарь). Byte-level BPE начинает с 256 байтов. Byte-level = 0 UNK для любого языка, фиксированный начальный словарь. Но CJK символы разбиваются на 3 байта (UTF-8) -- отсюда хуже compression ratio для китайского/японского (1.5:1 vs 4:1 для английского).

vocab_size напрямую влияет на размер модели

Embedding matrix = vocab_size x d_model. При d_model=4096: 32K vocab = 512 MB, 100K vocab = 1.6 GB. Это не "просто словарь" -- это trainable параметры. Увеличение vocab в 3x увеличивает embedding layer в 3x. А vocab определяется до обучения и не меняется -- ошибка = переобучение модели с нуля.


Interview Q&A

Q: Объясните алгоритм BPE пошагово.

❌ Red flag: "BPE разбивает слова на символы" (это только инициализация, не алгоритм)

✅ Strong answer: "BPE Training: (1) Разбить корпус на символы. (2) Подсчитать частоту всех пар соседних токенов. (3) Слить самую частую пару в новый токен, добавить в словарь. (4) Повторять 2-3 пока |vocab| не достигнет целевого размера. BPE Encoding: применить выученные merges к тексту в порядке приоритета (порядок обучения). Пример: 'the' -> ['t','h','e'] -> ['th','e'] -> ['the']. Training greedy (всегда лучшая пара), encoding детерминированный (фиксированный порядок merges)."

Q: Почему GPT-2+ используют byte-level BPE?

❌ Red flag: "Потому что байты меньше символов"

✅ Strong answer: "Три причины: (1) Фиксированный начальный словарь 256 токенов вместо 100K+ Unicode символов -- предсказуемый и компактный. (2) Ноль UNK -- любой текст на любом языке представим через UTF-8 байты. (3) Мультиязычность без специальной обработки -- китайский, код, эмодзи -- всё работает. Trade-off: CJK получают худший compression ratio (1 символ = 3 байта), отсюда 'token tax' для не-латинских языков."

Q: Как vocab_size влияет на качество модели?

✅ Strong answer: "Маленький vocab (10K): длинные последовательности, медленный inference, маленькая embedding matrix. Большой vocab (100K): короткие последовательности, быстрый inference, но 1.6 GB только на embedding при d=4096. Sweet spot: 30-50K для большинства задач. Для мультиязычных моделей больше (100K+), для доменных -- можно меньше. LLaMA выбрал 32K (баланс), GPT-4 ~100K (покрытие всех языков)."


Самопроверка

  1. Ручной BPE: Дан корпус "aaabdaaabac". Проведите 3 итерации BPE вручную. Какой словарь получится? Какие merge rules? Проверьте: закодируйте "aaba" полученными правилами.

  2. Compression ratio: Текст на русском (UTF-8) обрабатывается byte-level BPE. Символ "Д" = 2 байта (0xD0 0x94). Слово "Данные" = 12 байтов. Если BPE выучил merge "Данные" -> один токен, каков compression ratio в токенах vs байтах? Почему русский текст дороже английского в API с оплатой за токены?

  3. Vocab size trade-off: У вас модель с d_model=4096. Текущий vocab=32K. Клиент просит добавить 10 языков, и вы рассматриваете увеличение vocab до 100K. Посчитайте: (a) на сколько MB вырастет embedding matrix, (b) как это повлияет на inference latency, © предложите альтернативу увеличению vocab.


See Also


Источники

  1. arXiv — "Neural Machine Translation of Rare Words with Subword Units" (Sennrich et al., 2016)
  2. Sebastian Raschka — "Implementing BPE from Scratch" (Jan 2025)
  3. Hugging Face — Tokenizers library documentation
  4. OpenAI — GPT-2 paper and code
  5. Andrej Karpathy — "Let's build GPT" (tokenization section)
  6. Google — SentencePiece documentation
  7. BPE Tokenizer tutorials (fancyerii, Medium)
  8. Byte-level BPE explanation (GPT-2 paper)
  9. Hugging Face LLM Course — Tokenization chapter
  10. MarkTechPost — BPE algorithm explanation