Как мы адаптировали LLM для русского языка. deeplearning.. deeplearning. llm.. deeplearning. llm. machinelearning.. deeplearning. llm. machinelearning. mawo.. deeplearning. llm. machinelearning. mawo. nlp.. deeplearning. llm. machinelearning. mawo. nlp. ProductionML.. deeplearning. llm. machinelearning. mawo. nlp. ProductionML. PyTorch.. deeplearning. llm. machinelearning. mawo. nlp. ProductionML. PyTorch. RussianNLP.. deeplearning. llm. machinelearning. mawo. nlp. ProductionML. PyTorch. RussianNLP. tokenization.. deeplearning. llm. machinelearning. mawo. nlp. ProductionML. PyTorch. RussianNLP. tokenization. искусственный интеллект.. deeplearning. llm. machinelearning. mawo. nlp. ProductionML. PyTorch. RussianNLP. tokenization. искусственный интеллект. Машинное обучение.

История про токенизацию, научные статьи и 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 =).

Автор: Mawo

Источник

Rambler's Top100