Как мы адаптировали LLM для русского языка
История про токенизацию, научные статьи и 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 =).
Источник: habr.com
