История про токенизацию, научные статьи и production reality

Как мы потратили 2 месяца на адаптацию Qwen3-0.6B для русского языка. Написали систему с нуля на основе 8 научных статей из arXiv. Исправили 6 критических багов (от NaN в fp16 до архитектурных проблем). Получили +35% training speed и +60% inference speed. В этой статье - честный рассказ о том, что не работает из коробки, какие грабли ждут в production, и как мы их обошли.

Мы - это я и мой друг =)

Как всё началось

Август 2025. Мы работаем над MAWO - системой fine-tuning для русскоязычных LLM. У нас есть модель Qwen3-0.6B. Почему именно 0.6B, а не 8B или 70B?

RTX 4080 Super (16GB VRAM) - это всё, что у нас есть =)

  • Qwen3-8B в fp16: ~16GB только для модели + градиенты + optimizer → OOM (Out of Memory)

  • Qwen3-0.6B: влезает даже с batch_size=8 и остаётся место для экспериментов

Мы выбрали прагматизм. Лучше маленькая работающая модель, чем большая сломанная. Плюс всё, что мы делаем, масштабируется на большие модели (если купим больше видеокарт =)).

Но с моделью была одна проблема:

tokenizer.encode("Привет, как дела?")
# Output: 10 токенов ❌

Для сравнения, английская фраза такой же длины:

tokenizer.encode("Hello, how are you?")
# Output: 4 токена ✅

Что это значит:

  • Обучение в 2.5 раза медленнее (больше токенов = больше forward/backward passes)

  • Inference дороже (API берут деньги за токены)

  • Модель хуже понимает морфологию ("программирование" разбивается на 5 кусочков)

Западные модели (Qwen, Llama, Mistral) обучены преимущественно на английском корпусе (80-90%). Их tokenizer'ы оптимизированы для английского языка. А русский с его:

  • 6 падежами (программирование, программированием, программированию...)

  • Богатой морфологией (приставки, суффиксы, окончания)

  • Длинными словами (в среднем на 20% длиннее английских)

превращается в последовательность маленьких кусочков. В этом плане идеальны модели Яндекса и Сбера, но они очень большие и просто так не взять =)

Решение

Мы решили адаптировать модель для русского языка. Не обучать с нуля (это 3-6-12 месяцев и миллионы долларов, а у нас только 1 видеокарта =)), а взять существующую и "научить" её лучше работать с русским.

План был простой:

  1. Прочитать научные статьи про адаптацию токенизаторов

  2. Реализовать алгоритмы из arXiv

  3. Запустить и получить результат

На деле оказалось не все так просто.

Мы потратили 2 месяца, исправили 6 критических багов (от NaN в fp16 до архитектурных проблем), переписали 3 компонента и чуть не сдались раз пять.

Но в итоге получили работающую систему.

Что мы хотели получить

Согласно научным статьям (arXiv:2312.02598, 2406.11477), адаптация токенизатора даёт:

Метрика

Ожидание

Эффективность токенизации

2.0-2.5x

Скорость обучения

+30-40%

Скорость вывода

+50-70%

Понимание морфологии

+25-30 points F1

Шикарно! Но чтобы это получить, нужно было пройти через 5 компонентов:

┌─────────────────────────────────────────┐
│       Пайплайн адаптации                │
├─────────────────────────────────────────┤
│                                         │
│   1 VocabularyCalculator                │
│     Решаем: expansion или replacement?  │
│                                         │
│   2 TokAlign + MorphUnigram             │
│     Обучаем новый токенизатор           │
│                                         │
│   3 VocabularyExpander                  │
│     Добавляем 1K русских токенов        │
│                                         │
│   4 LEP Initialization                  │
│     Инициализируем embeddings умно      │
│                                         │
│   5 CPT (опционально)                   │
│     Дообучаем модель (3-5 дней)         │
│                                         │
└─────────────────────────────────────────┘

Две стратегии адаптации: быстро vs качественно

Прежде чем рассказать про баги, важно понять: существует две принципиально разные стратегии адаптации токенизатора для нового языка. От выбора стратегии зависит ВСЁ: время, качество, архитектура решения.

EXPANSION (Расширение словаря)

Идея: Экономим время, добавляя токены сверху

Представьте, что у вас есть англо-русский словарь на 150,000 слов. Вместо того чтобы переписывать его с нуля, вы просто добавляете 1,000 самых употребительных русских слов в конец.

Что делаем:

  • Сохраняем весь оригинальный vocabulary (151,669 токенов Qwen3)

  • Добавляем сверху 500-1,000 самых частых русских токенов

  • Итого: 152,669 токенов (151K старых + 1K новых)

Плюсы:

  • Быстро: 6-8 часов (нет CPT!)

  • ✅ Не теряем multilingual способности (английский, китайский остаются)

  • ✅ Работает сразу после LEP (Learned Embedding Propagation)

  • ✅ Для отладки: запустил, проверил, работает

Минусы:

  • ⚠️ Большой vocabulary (152K токенов вместо 25K)

  • ⚠️ Медленнее inference (больше токенов = больше softmax)

  • ⚠️ Качество ниже: 92-94% от оригинальной модели (arXiv:2406.11477)

Когда использовать:

  • Мало данных (<3GB корпуса)

  • Нужно сохранить multilingual

  • Ограничено время (нет 3-5 дней на CPT)

  • Для экономии времени: мы используем expansion для отладки pipeline

Пример:

# Было
"Программирование" → [151643, 45892, 101234, ...]  # 5 токенов

# Стало (expansion добавил русские токены 151669+)
"Программирование" → [151670, 151901]  # 2 токена (новые ID!)

REPLACEMENT (Замена словаря)

Идея: Максимальное качество через полную переделку

Вместо добавления слов, мы выбрасываем весь англо-русский словарь и пишем новый - чисто русский. Меньше, компактнее, оптимизированнее.

Что делаем:

  • Удаляем весь оригинальный vocabulary (151,669 токенов)

  • Обучаем новый vocabulary на русском корпусе

  • Размер: 25,000-40,000 токенов (AUTO-CALCULATED по размеру корпуса)

Плюсы:

  • Максимальное качество: 96-98% (после CPT!)

  • ✅ Маленький vocabulary (30K vs 152K) → экономия памяти

  • ✅ Быстрый inference (+60% скорость, меньше токенов!)

  • ✅ Лучшая морфология (+35% training speed)

  • ✅ Токены заточены под русский: "программирование" = 1 токен

Минусы:

  • ОБЯЗАТЕЛЕН CPT (Continual Pre-training, 3-5 дней!)

  • ❌ Без CPT модель генерирует мусор (проверено на практике!)

  • ❌ Теряем multilingual способности (только русский)

  • ❌ Долго: 2-3 часа (TokAlign) + 3-5 дней (CPT)

Когда использовать:

  • Большой корпус (≥3GB)

  • Есть 3-5 дней на CPT

  • Не нужен multilingual (только русский)

  • Нужно максимальное качество для production

Пример:

# Было
"Программирование" → [151643, 45892, 101234, ...]  # 5 токенов

# Стало (replacement создал новые ID с нуля)
"Программирование" → [3421]  # 1 токен! Совершенно другой ID!

Автоматический выбор

Мы создали VocabularyCalculator - автоматический калькулятор стратегии на основе научных исследований. Он анализирует:

  1. Размер корпуса (главный фактор!) - сколько у нас данных для обучения токенизатора

  2. Доступное время - сколько часов мы можем потратить

  3. Multilingual требования - нужно ли сохранить другие языки

И выдаёт рекомендацию:

# Наш случай: 5.5GB корпуса, 10 часов времени
recommendation = auto_select_strategy(
    corpus_path="data/russian_corpus.txt",  # 5.5GB
    available_time_hours=10.0
)

# Output:
# Strategy: REPLACEMENT
# Target vocab: 30,000 tokens (auto-calculated)
# Expected quality: 96-98% (after CPT)
# Requires CPT: YES ⚠️
#
# ⚠️  КРИТИЧЕСКОЕ: Replacement требует 72ч+ CPT!
# БЕЗ CPT модель генерирует мусор!

Приоритет ДАННЫХ > времени:

Даже если у вас только 10 часов, но 5.5GB корпуса - калькулятор всё равно выберет Replacement! Почему? Потому что большой корпус позволяет обучить качественный токенизатор (25K-40K токенов), а expansion на таком корпусе неоптимален.

Как вычисляется vocab size?

Не фиксированный (не всегда 40K как в Vikhr (мы же ходим адаптивность)!), а по формуле из arXiv:2312.02598:

# 33GB corpus → 23K tokens (статья)
# 5.5GB corpus → ?

if corpus_size >= 30:
    vocab_size = 40000  # Very large corpus
elif corpus_size >= 10:
    vocab_size = 30000  # Large corpus
else:
    vocab_size = 25000  # Medium corpus

? Сравнение стратегий

Характеристика

Expansion

Replacement + CPT

Время

6-8 часов

3-5 дней

Corpus min

0.01GB

3GB

Vocab size

152K (151K + 1K)

25K-40K (new)

Качество

92-94%

96-98%

CPT нужен?

❌ Нет

Обязательно!

Multilingual

✅ Полный

⚠️ Ограниченный

Inference speed

+30-40%

+60%

Training speed

+20-25%

+35%

Что мы выбрали?

Replacement + CPT (5.5GB корпуса, 3-5 дней CPT). Для отладки использовали expansion (6-8 часов).

Теперь, когда вы понимаете стратегии, давайте посмотрим на проблемы, с которыми мы столкнулись...

Когда fp16 убивает обучение за одну секунду

Запускаю обучение LEP (Learned Embedding Propagation) в стопятисотый раз - нейросеть из 3 слоёв, которая улучшает инициализацию embeddings.

Смотрю на логи:

Шаг 0: loss=0.2341 ✅
Шаг 1: loss=nan ❌

Обучение умерло на первом шаге. Наверное, слишком большой learning_rate. Пробуем уменьшить 1e-41e-51e-6. Не помогает.

Добавляем логирование градиентов:

Шаг 0:
  layer.0.weight: 12.34 ✅
  layer.3.weight: 45.67 ✅

Шаг 1:
  layer.0.weight: 1203.45 ⚠️
  layer.3.weight: inf ❌

На второй итерации Градиенты улетали в бесконечность.
Модель была в fp16. Почему это проблема?

Fp16 может хранить числа только ±65,504. Это много, но:

  1. У нас 3 слоя (Linear + LayerNorm + ReLU)

  2. Градиенты накапливаются при backward pass

  3. LayerNorm может усиливать градиенты (делит на std)

  4. Если gradient > 65,504 → overflowNaN

Решение:

# 1. Принудительно fp32 (было: .half())
model = LEPPropagationNetwork(...).float()

# 2. Gradient clipping (на всякий случай)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=0.3)

Результат:

Шаг 0: loss=0.2341 ✅
Шаг 100: loss=0.1876 ✅
Шаг 5000: loss=0.0121 ✅

Fp16 - отличная штука для экономии памяти, но не для обучения маленьких сетей с LayerNorm.

Вывод: Всегда используйте fp32 для обучения embedding initialization networks. Fp16 оставьте для inference.

При в е т

Тестируем адаптированный токенизатор после 6 часов обучения:

tokenizer = AutoTokenizer.from_pretrained("adapted-model")
text = "Привет, как дела?"
decoded = tokenizer.decode(tokenizer.encode(text))
print(decoded)

Output:

При в е т ,   к а к   д е л а ?

Проверяем конфигурацию:

cat tokenizer.json | jq '.decoder'
null  # ← Вот проблема!

Когда мы обучали Unigram tokenizer, мы забыли установить decoder. В результате токены склеивались с пробелами:

# Токены после encode:
["▁Прив", "ет", ",", "▁как", "▁дела", "?"]

# БЕЗ decoder:
" ".join(tokens) = "▁Прив ет , ▁как ▁дела ?"  # Пробелы везде!

# С decoder:
"".join(tokens).replace("▁", " ") = "Привет, как дела?"  # ✅

Решение - одна строка кода:

from tokenizers import decoders
tokenizer.decoder = decoders.ByteLevel(
    add_prefix_space=False,
    trim_offsets=False
)

Вывод: Всегда тестируйте полный цикл encode → decode и проверяйте, что получили исходный текст!

Токены добавлены, но не используются

Мы реализовали expansion strategy: добавили 1,000 самых частых русских токенов к оригинальным 151,669.

Проверяем через 19 минут обучения co-occurrence matrix:

text = "Программирование на Python"
tokens = tokenizer.tokenize(text)
new_token_usage = count_new_tokens(tokens)

print(f"Новых токенов использовано: {new_token_usage}%")
# Output: 0% ❌

Мы добавили 1,000 токенов, модель их видит, но не использует!

Проверяем vocabulary:

cat vocab.json | jq '. | length'
152669  # 151669 + 1000 = правильно ✅

cat tokenizer.json | jq '.model.vocab | length'
151669  # только старые токены! ❌

Оказалось, vocab.json содержит 152K токенов, но tokenizer.json - только 151K.

cat tokenizer.json | jq '.model.type'
"BPE"  # должен  быть "Unigram"! ❌

Мы делали expansion так:

1. TokAlign обучает Unigram tokenizer (40K tokens) ✅
2. VocabularyExpander:
   - Находит 1K новых токенов ✅
   - Объединяет с BPE vocab (151K + 1K = 152K) ✅
   - Сохраняет... СТАРЫЙ BPE tokenizer! ❌
3. Результат:
   - vocab.json: 152K (mixed BPE + Unigram) ✅
   - tokenizer.json: BPE 151K ❌

Fast tokenizers в HuggingFace используют tokenizer.json. Они игнорируют vocab.json!

Нужно передавать обученный Unigram tokenizer через весь pipeline:
После исправления:

"Программирование" → [151670, 151901]  # Использует новые токены! ✅

Вывод: Fast tokenizers - это два файла (vocab.json + tokenizer.json). Обновляйте ОБА!

Дооооолгое ожидание

Запускаем CW2V (Convex hull Within Word Vectors) - инициализацию embeddings для 1,000 новых токенов:

python adapt.py --strategy expansion

[INFO] Initializing 1,000 new embeddings...
[INFO] Building co-occurrence matrix...

Ждем. Ждем. Ждем. Ждем час.

Статья arXiv:2407.05841 описывает CW2V просто:

Для каждого нового токена:
  1. Найти k ближайших токенов (по co-occurrence)
  2. Взять их embeddings
  3. Сделать convex combination

Мы реализовали буквально как написано:

# Наивная реализация
for new_token in new_tokens:  # 1000 итераций
    # Читаем корпус для КАЖДОГО токена!
    neighbors = find_neighbors(new_token, corpus_path)

1,000 токенов × 10,000 строк корпуса = 10,000,000 операций чтения!

Сделали так - читаем корпус ОДИН РАЗ, строим co-occurrence для ВСЕХ токенов одновременно.

Версия

Операций

Время

N-pass (из arXiv)

10,000,000

45 минут ❌

Single-pass (наш)

10,000

2-3 минуты ✅

Speedup

1000x I/O

20-30x total

Ещё одно улучшение - frequency-based weights вместо uniform:

# arXiv: uniform weights
weights = [1/k, 1/k, ..., 1/k]  # Все соседи одинаково важны ❌

# Наш: frequency-based
neighbor_freqs = [1000, 800, 600, ..., 5]  # Co-occurrence counts
weights = softmax(neighbor_freqs)  # Частые соседи важнее! ✅

Для токена "нейросеть":

  • Сосед "обучение" (1000 co-occurrences) → вес 0.35 ✅

  • Сосед "стол" (5 co-occurrences, шум!) → вес 0.001 ✅

92GB не влезают в 16GB GPU

Запускаем TokAlign с vocabulary = 30,000:

RuntimeError: CUDA out of memory. Tried to allocate 18.2 GB
# Co-occurrence matrix
30,000 × 30,000 × 4 bytes (fp32) = 3.6 GB

# Alignment matrix (source × target vocab)
151,669 × 30,000 × 4 bytes = 18.2 GB

# Total matrices: 21.8 GB
# Model + gradients: ~5 GB
# TOTAL: 27 GB ❌

# Наша GPU: 16 GB ❌

Проверяем sparsity (сколько элементов = 0):

# Возможных пар:
30,000 × 30,000 = 900,000,000

# Реально встретились вместе:
non_zero_pairs = 500,000

# Sparsity:
1 - (500,000 / 900,000,000) = 99.94% sparse!

99.94% элементов - это нули! Слова "программирование" и "банан" вряд ли встретятся в одном окне из 5 токенов.

Решение: Используем PyTorch sparse tensors (COO format):

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

Компонент

Dense

Sparse

Экономия

Co-occurrence

3,600 MB

18 MB

200x

Alignment

18,200 MB

91 MB

200x

Всего

21,800 MB

109 MB

200x

Теперь влезает в 16GB GPU! ?

NLP матрицы почти всегда sparse (99%+). Используйте sparse tensors по умолчанию!

Auto-detection выбрал неправильную стратегию

После адаптации, тестируем модель:

prompt = "Напиши функцию сортировки на Python"
output = model.generate(...)
print(tokenizer.decode(output))

Output:

влакпщук вмилло пвакщуе фывапролд жэбячсмить...

Мусор. Проверяем метаданные:

strategy: replacement  # Не expansion!
vocab_size: 25000  # Не 152K!
requires_cpt: true
cpt_completed: false  # ← ПРОБЛЕМА!

Auto-detection выбрал replacement вместо expansion. И мы запустили БЕЗ CPT!

Auto-detection логика была неправильной:

# БЫЛО:
if available_time < 24h:
    strategy = "expansion"  # ← Решение только по времени!

# НО внутри:
if corpus_size >= 3GB:
    strategy = "replacement"  # ← Переопределение БЕЗ предупреждения!

Результат:

  1. Пользователь: time = 10h → думает expansion

  2. Скрипт видит: corpus = 5.5GB → replacement (без предупреждения!)

  3. Replacement без CPT → модель сломана

Решение: Приоритет ДАННЫХ > времени:

Мы реализовали систему на основе 8 научных статей (2024-2025). Почему ни один алгоритм не заработал "из коробки"?

1. Scaling Law не применим для адаптации

arXiv:2407.13623 даёт формулу:

optimal_vocab = 5.4e-4 × model_params^0.83

Для Qwen3-0.6B это даёт 50K tokens.

НО это для training from scratch! Для адаптации:

  • Expansion: сохраняем multilingual → 152K tokens

  • Replacement: язык-specific → 25K-40K tokens

Scaling Law оптимизирует compute для новой модели, не для адаптации существующей.

2. Sparse tensors не упоминаются

arXiv:2506.03523 тестировался на vocabulary 10K-20K:

20K × 20K × 4 = 1.6 GB  # OK для GPU

Production:

151K × 30K × 4 = 18.2 GB  # OOM на 16GB GPU!

NLP co-occurrence matrices 99.9% sparse. Sparse tensors обязательны для больших vocabulary!

3. Precision (fp16 vs fp32) не обсуждается

arXiv:2412.21140 не упоминает precision. Вероятно, использовали fp32 по умолчанию (A100 с 80GB).

Мы попробовали fp16 (для экономии памяти на 16GB GPU) → NaN на первой итерации.

4. N-pass алгоритмы для малых датасетов

Статьи тестируются на малых корпусах (для скорости экспериментов).

Production: 5.5GB corpus, 1000 tokens → N-pass = 45 минут!

5. Corpus size thresholds работают (но качество другое!)

Эмпирически подтвердили пороги из arXiv:2312.02598, arXiv:2406.11477:

Corpus size

Auto-selected strategy

Качество

Комментарий

<0.1GB

Expansion (500 tokens)

94-96%

LOW-RESOURCE

0.1-3GB

Expansion (1000 tokens)

92-94%

MEDIUM-RESOURCE

≥3GB

Replacement + CPT (25K-40K)

96-98%

HIGH-RESOURCE

Важно: Качество - это % от оригинальной модели (NOT absolute accuracy!)

Почему expansion даёт 92-94%, а не 100%?

  1. Добавляем только 1,000 токенов к оригинальным 151,669

    • Новые токены: 1K / 152K = 0.65% от vocabulary

    • Остальные 99.35% - старые (неоптимальные для русского)

  2. Нет CPT - модель не "переобучилась" на новые токены

  3. LEP инициализация даёт 95-97%, оставшиеся 3-5% теряются

Почему replacement даёт 96-98%, а не 100%?

  1. CPT не может полностью восстановить знания на других языках

  2. Vocabulary оптимизирован для русского, хуже для code/специальных терминов

  3. Empirical results (arXiv:2312.02598): "comparable quality" = 96-98%

Что мы получили в итоге

Timeline

Этап

Время

Результат

Обязательно?

TokAlign + MorphUnigram

1.5-2ч

Unigram tokenizer ✅

ДА

LEP

1-2ч

Initialized embeddings ✅

ДА

HYPEROFA

3-4ч

Enhanced embeddings (+2-3%)

Опционально

CPT

3-5д

Adapted model ✅

Только для replacement

HYPEROFA (arXiv:2504.21018) - опциональный компонент для улучшения embeddings (+2-3% качества). Мы пробовали с ним и без него, на маленькой модели разницы не заметили.

Что дальше?

Адаптация - это только STAGE 1 нашего pipeline. Дальше идёт:

STAGE 2: PEFT Fine-tuning

  • три метода обучения

  • бенчмарки

  • нервы

  • квантизация

Расскажу в следующей статье =)

Выводы

Что мы поняли за 2 месяца:

  1. Research papers ≠ production code

    • arXiv опускает детали (precision, memory, sparsity)

    • Тестируют на малых датасетах

    • Не учитывают ограничения consumer GPU

  2. Debugging в ML - 80% времени

    • 6 багов, 25+ часов debugging

    • Каждый баг учит чему-то новому

    • Git history бесценна

  3. Production требует optimization

    • Single-pass вместо N-pass

    • Sparse tensors для NLP

    • Frequency weights вместо uniform

  4. Data > Time для ML decisions

    • Большой corpus → replacement optimal

    • Маленький corpus → expansion optimal

    • Threshold 3GB работает эмпирически

  5. Explicit warnings критичны

    • Replacement без CPT = мусор

    • Пользователь должен понимать последствия

    • Manual override обязателен

Самый важный урок: Не бойтесь делать с нуля. Готовые библиотеки удобны, но не всегда решают вашу задачу. Мы получили:

  • Полный контроль над процессом

  • Глубокое понимание алгоритмов

  • Оптимизации под наши нужды

  • Систему, которая работает в production

  • +35% training speed и +60% inference speed (зависит от исходной модели и качества адаптации)

Ресурсы

Научные статьи (использованы в проекте):

  1. arXiv:2508.08424 - MorphUnigram tokenization

  2. arXiv:2312.02598 - Russian tokenization (+35% training)

  3. arXiv:2412.21140 - LEP: Learned Embedding Propagation

  4. arXiv:2407.05841 - CW2V: Convex hull initialization

  5. arXiv:2506.03523 - TokAlign

  6. arXiv:2406.11477 - Vocabulary expansion

  7. arXiv:2407.13623 - Scaling Laws with Vocabulary

  8. arXiv:2504.21018 - HYPEROFA

Все баги, решения и метрики из этой статьи - production reality. Репозиторий откроем когда исправим ошибки в STAGE 2 =).

Комментарии (1)


  1. ZeroMatrix
    09.11.2025 17:06

    Мы - это я и мой друг =)

    Дипсик?