История про токенизацию, научные статьи и 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 видеокарта =)), а взять существующую и "научить" её лучше работать с русским.
План был простой:
Прочитать научные статьи про адаптацию токенизаторов
Реализовать алгоритмы из arXiv
Запустить и получить результат
На деле оказалось не все так просто.
Мы потратили 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 - автоматический калькулятор стратегии на основе научных исследований. Он анализирует:
Размер корпуса (главный фактор!) - сколько у нас данных для обучения токенизатора
Доступное время - сколько часов мы можем потратить
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-4 → 1e-5 → 1e-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. Это много, но:
У нас 3 слоя (Linear + LayerNorm + ReLU)
Градиенты накапливаются при backward pass
LayerNorm может усиливать градиенты (делит на std)
Если gradient > 65,504 → overflow →
NaN
Решение:
# 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" # ← Переопределение БЕЗ предупреждения!
Результат:
Пользователь:
time = 10h→ думает expansionСкрипт видит:
corpus = 5.5GB→ replacement (без предупреждения!)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,000 токенов к оригинальным 151,669
Новые токены: 1K / 152K = 0.65% от vocabulary
Остальные 99.35% - старые (неоптимальные для русского)
Нет CPT - модель не "переобучилась" на новые токены
LEP инициализация даёт 95-97%, оставшиеся 3-5% теряются
Почему replacement даёт 96-98%, а не 100%?
CPT не может полностью восстановить знания на других языках
Vocabulary оптимизирован для русского, хуже для code/специальных терминов
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 месяца:
-
✅ Research papers ≠ production code
arXiv опускает детали (precision, memory, sparsity)
Тестируют на малых датасетах
Не учитывают ограничения consumer GPU
-
✅ Debugging в ML - 80% времени
6 багов, 25+ часов debugging
Каждый баг учит чему-то новому
Git history бесценна
-
✅ Production требует optimization
Single-pass вместо N-pass
Sparse tensors для NLP
Frequency weights вместо uniform
-
✅ Data > Time для ML decisions
Большой corpus → replacement optimal
Маленький corpus → expansion optimal
Threshold 3GB работает эмпирически
-
✅ Explicit warnings критичны
Replacement без CPT = мусор
Пользователь должен понимать последствия
Manual override обязателен
Самый важный урок: Не бойтесь делать с нуля. Готовые библиотеки удобны, но не всегда решают вашу задачу. Мы получили:
Полный контроль над процессом
Глубокое понимание алгоритмов
Оптимизации под наши нужды
Систему, которая работает в production
+35% training speed и +60% inference speed (зависит от исходной модели и качества адаптации)
Ресурсы
Научные статьи (использованы в проекте):
arXiv:2508.08424 - MorphUnigram tokenization
arXiv:2312.02598 - Russian tokenization (+35% training)
arXiv:2412.21140 - LEP: Learned Embedding Propagation
arXiv:2407.05841 - CW2V: Convex hull initialization
arXiv:2506.03523 - TokAlign
arXiv:2406.11477 - Vocabulary expansion
arXiv:2407.13623 - Scaling Laws with Vocabulary
arXiv:2504.21018 - HYPEROFA
Все баги, решения и метрики из этой статьи - production reality. Репозиторий откроем когда исправим ошибки в STAGE 2 =).
ZeroMatrix
Дипсик?