Привет, Хабр! В продолжение моей предыдущей статьи о локальном переводчике на кабардинском языке хочу поделиться практическим опытом обучения моделей машинного перевода для низкоресурсных языков. Расскажу о том, с какими проблемами я столкнулся, как их решал, и покажу конкретный код, который помог улучшить качество перевода с BLEU 8 до 28 пунктов.

Введение: три кита обучения переводчиков

Обучение моделей перевода - нетривиальная задача, которая опирается на три ключевых элемента:

1. Корпус параллельных текстов

Это фундамент всего. В моем случае я стартовал с корпусом из 200 тысяч предложений на русском и кабардинском языках. Здесь важны два аспекта:

  • Объем данных - чем больше, тем лучше, но качество важнее количества

  • Покрытие грамматики - корпус должен содержать основные грамматические конструкции: времена, падежи, порядок слов. Если данных недостаточно, модель будет допускать грамматические ошибки

2. Выбор архитектуры модели

Здесь нужно учитывать несколько факторов:

  • Ресурсоемкость - большие модели требуют мощного железа для обучения

  • Наличие предобученных моделей - желательно найти модель, которая уже знает хотя бы один из ваших языков

  • Размер итоговой модели - для практического испол��зования важно, чтобы модель могла работать на обычных компьютерах

Я выбрал семейство моделей OPUS-MT от Helsinki-NLP. Это один из самых качественных переводчиков при минимальном объеме всего около 300 мегабайт, что позволяет создавать качественные однонаправленные модели перевода.

3. Подготовка данных и скрипты обучения

Третий, не менее важный элемент - создание кода для:

- Обработки и нормализации корпуса данных

- Обучения с различными параметрами для подбора оптимального способа

- Оптимизации как для подготовительных операций (можно на обычном компьютере), так и для обучения на профессиональных GPU типа T4 в Google Colab

Выбор базовой модели: метод проб и ошибок

Для обучения кабардино-русской модели я использовал opus-mt-en-ru (английский → русский), а для русско-кабардинской - opus-mt-ru-uk (русский → украинский).

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

Почему работает transfer learning?

Позволю себе метафору. Представьте, что вы развешиваете белье для сушки на двух разных веревках, и при этом вам надо систематизировать все виды белья — чтобы рубашки висели в одинаковых местах, носки рядом с носками и так далее.

Когда вы организуете этот процесс с нуля, вам приходится изобретать всю систему развешивания. Но если у вас на одной стороне уже висят систематизированные вещи, на второй веревке намного проще воспроизвести эту же систему.

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

Проблема №1: кириллическая палочка Ӏ

Наибольшее количество головной боли мне причинила буква Ӏ (кириллическая палочка-корень). В кабардинском языке она используется для формирования большого количества диграфов: пӀ, кӀ, тӀ, цӀ и так далее.

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

После недели мучений я пришел к простому решению: замена кириллической палочки Ӏ на английскую букву I. После этого токенизация стала работать отлично.

def preprocess_kbd_text(text):
    """Замена Ӏ → I для обучения"""
    if isinstance(text, str):
        if 'Ӏ' in text:
            return text.replace('Ӏ', 'I')
    return text

def postprocess_kbd_prediction(text):
    """Восстановление Ӏ после перевода"""
    return text.replace('I', 'Ӏ') if isinstance(text, str) else text

Нам повезло, что русско-английская и русско-украинская модели содержали букву I в словаре - и это стало одним из важных условий, почему мы остановились на них.

Первые результаты: успех и провал

Процесс обучения был достаточно простой: с агрессивным learning rate мы обучили модели за 6-7 эпох.

Каб��рдино-русская модель практически сразу дала высокий уровень, преодолев планку в 22 BLEU. Отличный результат!

Но русско-кабардинская модель застряла на уровне 8-13 BLEU и никак не хотела расти дальше. 

Было очевидно, что подход в целом работал, но наши примеры и корпус были недостаточны или зашумлены для качественного изучения грамматики.

Очистка корпуса: детективная работа

Я вышел из положения двумя способами:

1. Расширение корпуса

Добавил дополнительные источники данных и довел корпус до нужного объема. Спасибо Адаму Панагову - в его проекте нашел данные, которые смог частично использовать.

2. Значительная чистка

При чистке я использовал метод поиска семантически близких пар и смог обнаружить:

  • ~5 тысяч предложений, где в обоих языках использовались преимущественно русские слова - их сразу удалил

  • Все слишком длинные предложения (более 200 символов)

  • Все слишком короткие предложения (менее 15 символов)

Вот скрипт для фильтрации корпуса по длине:

# Фильтрация по длине символов
MIN_CHAR_LENGTH = 15
MAX_CHAR_LENGTH = 200

ru_lens = df['ru'].astype(str).str.len()
kbd_lens = df['kbd'].astype(str).str.len()

df_filtered = df[
    (ru_lens >= MIN_CHAR_LENGTH) & (ru_lens <= MAX_CHAR_LENGTH) &
    (kbd_lens >= MIN_CHAR_LENGTH) & (kbd_lens <= MAX_CHAR_LENGTH)
].copy()

print(f"Удалено: {len(df) - len(df_filtered):,} пар")
print(f"Осталось: {len(df_filtered):,} пар")

Это в сумме дало прыжок на 12-14 пунктов BLEU - уже неплохо! Но дальнейший рост оказался невозможным.

Открытие: асимметрия языков

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

Это полисинтетический язык с богатой аффиксацией - там одно слово может выражать целое предложение на русском.

Решение: подбор оптимального length penalty

Я начал экспериментировать с параметром length_penalty. Сначала увеличил с 1.0 до 2.0 - и модель стала сочинять лишние слова, пытаясь искусственно удлинить перевод!

Пришлось искать баланс. Оптимальный результат получился на уровне 1.5 — золотая середина между точностью и правильной длиной.

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

Чудо! Параметр length_penalty=1.5 позволил русско-кабардинской модели за 3 эпохи прыгнуть на уровень 18-19 BLEU на том же корпусе.

При этом кабардино-русская модель на этом же корпусе с length_penalty=1.0 выросладо уровня 28 BLEU - разница в 6 пунктов получена исключительно качеством данных, так как параметры обучения практически не менялись.

Продвинутая техника: отбор сложных примеров

После базового обучения я применил продвинутую технику - отбор самых сложных примеров для дообучения.

Идея

Не все примеры в корпусе одинаково полезны. Некоторые модель уже выучила хорошо (низкий loss), а другие до сих пор переводит плохо (высокий loss).

Реализация

Я написал скрипт, который:

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

  2. Сортирует примеры по loss от большего к меньшему

  3. Отбирает топ-30% самых сложных примеров

Вот ключевой фрагмент кода:

def compute_loss_per_example(model, tokenizer, batch, device):
    """Вычисление loss для каждого примера в батче"""
    inputs = tokenizer(
        batch['ru'].tolist(),
        padding=True,
        truncation=True,
        max_length=128,
        return_tensors="pt"
    )
    
    labels = tokenizer(
        batch['kbd'].tolist(),
        padding=True,
        truncation=True,
        max_length=128,
        return_tensors="pt"
    ).input_ids
    
    # Переносим на GPU
    inputs = {k: v.to(device) for k, v in inputs.items()}
    labels = labels.to(device)
    
    # Заменяем pad_token на -100
    labels[labels == tokenizer.pad_token_id] = -100
    
    # Forward pass
    with torch.no_grad():
        outputs = model(**inputs, labels=labels)
        logits = outputs.logits
        
        # Вычисляем loss для каждого токена
        loss_fn = CrossEntropyLoss(reduction='none')
        shift_logits = logits[:, :-1, :].contiguous()
        shift_labels = labels[:, 1:].contiguous()
        
        token_losses = loss_fn(
            shift_logits.view(-1, shift_logits.size(-1)),
            shift_labels.view(-1)
        )
        
        # Средний loss для каждого примера
        token_losses = token_losses.view(shift_labels.size(0), -1)
        mask = (shift_labels != -100).float()
        example_losses = (token_losses * mask).sum(dim=1) / mask.sum(dim=1)
    
    return example_losses.cpu().numpy()

Ожидания

После дообучения на сложных примерах модель должна показать дополнительный прирост +2-4 пункта BLEU. Обучение пока в процессе, как закончим вернусь с результатами. Я также думаю о циклическом тренинге, так как одна модель работает лучше другой она может быть использована, как учитель более слабой.

Оптимизация данных: динамический padding

Еще одна важная вещь найденная в процессе оптимизации - динамический padding вместо статического.

Проблема

Изначально я использовал padding='max_length' с max_length=128. Это означало, что ВСЕ примеры паддились до 128 токенов, даже если предложение состояло из 10 токенов.

Результат: - Файлы данных весили ~3 GB - Тратилось много времени на загрузку - Много вычислений впустую на padding токенах

Решение

Я перешел на padding='longest' в датасете и использовал DataCollatorForSeq2Seq для динамического padding во время обучения:

from transformers import DataCollatorForSeq2Seq

# При подготовке данных - НЕ паддим
def tokenize_function(examples):
    model_inputs = tokenizer(
        examples['source'],
        max_length=128,
        truncation=True,
        padding=False,  # ← Ключевое изменение!
        return_tensors=None
    )
    
    labels = tokenizer(
        text_target=examples['target'],
        max_length=128,
        truncation=True,
        padding=False,  # ← Ключевое изменение!
        return_tensors=None
    )
    
    model_inputs['labels'] = labels['input_ids']
    return model_inputs

# При обучении - используем DataCollator
data_collator = DataCollatorForSeq2Seq(
    tokenizer=tokenizer,
    model=model,
    padding=True  # ← Динамический padding по батчу
)

trainer = Seq2SeqTrainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset,
    data_collator=data_collator,  # ← Используем коллатор
    # ...
)

Результат

  • Размер данных уменьшился с 3 GB до 800 MB (~75% экономии)

  • Обучение стало быстрее на 20-30%

  • Меньше нагрузка на память GPU

Итоговые результаты

После всех оптимизаций вот что я получил:

Направление

Базовая модель

BLEU после обучения

Прирост

Кабардинский → Русский

opus-mt-en-ru

28.13

+28 пунктов

Русский → Кабардинский

opus-mt-ru-uk

18.65

+18 пунктов

Производительность: - KBD→RU: 27.1 примеров/сек (37 мс на пример) - RU→KBD: 6.9 примеров/сек (144 мс на пример)

Модели опубликованы на Hugging Face: - kubataba/kbd-ru-opus  (кабардинский → русский) - kubataba/ru-kbd-opus (русский → кабардинский)

Интеграция в переводчик версии 2.0

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

Ключевые улучшения v2.0:

1. Замена моделей перевода 

- Было: три модели M2M100 (100 языков, ~6.9 GB)

- Стало: одна модель NLLB-200 (200 языков, ~2.3 GB) и две модели kbd-ru-opus (300mb) и ru-kbd-opus (300mb) в сумме (200 языков, ~2.9 GB) в три раза меньше!

Результат: значительно улучшилось качество переводов для низкоресурсных языков.

2. Расширение языковой поддержки - Добавлены: грузинский, казахский (которые были, но качество было плохим) - Добавлены: башкирский и киргизский (которых вообще не было) - Появилась возможность не только переводить, но и синтезировать речь на этих языках

3. Интеграция Silero Stress - Автоматическая расстановка ударений для славянских языков - Точность: 98% для русского, 95% для украинского и белорусского - Теперь вы слышите не только слова, но и правильные ударения

4. Улучшенная транслитерация - Для языков с неславянскими алфавитами (грузинский, армянский, турецкий) - Качество озвучки: 70-85% в зависимости от языка - Расширенная фонетическая поддержка и словари ударений.

Архитектура системы

Русский ↔ Кабардинский: Кастомные MarianMT модели (прямой перевод)
         ↓
Другие языки ↔ Кабардинский: Каскад через русский (NLLB-200 + MarianMT)
         ↓
Синтез речи: Silero TTS + автоматические ударения + транслитерация

Практические советы

Если вы хотите обучить свой переводчик для низкоресурсного языка, вот мои рекомендации:

1. Выбор базовой модели

  • Ищите модель, которая знает хотя бы один из ваших языков

  • Предпочитайте специализированные модели (одно направление) вместо универсальных

  • Проверяйте размер словаря - больше не всегда лучше

  • Тестируйте несколько кандидатов - не все модели одинаково обучаемы

2. Подготовка корпуса

  • Качество > количество - лучше 100K чистых пар, чем 200K зашумленных

  • Удалите дубликаты и почти-дубликаты

  • Отфильтруйте по длине (слишком короткие и слишком длинные примеры)

  • Проверьте баланс языков - нет ли в "целевом" языке слов из "исходного"

3. Гиперпараметры обучения

# Для базового обучения
NUM_TRAIN_EPOCHS = 6-7
LEARNING_RATE = 2e-4
BATCH_SIZE = 32-64
GRADIENT_ACCUMULATION = 2

# Для дообучения на сложных примерах
NUM_TRAIN_EPOCHS = 2-3
LEARNING_RATE = 3e-5  # Меньше!

4. Generation config

generation_config = GenerationConfig(
    max_length=128,
    num_beams=4,
    length_penalty=1.0-1.5,  # Подбирайте под свою пару языков!
    early_stopping=True,
)

5. Оптимизация обучения

  • Используйте динамический padding через DataCollatorForSeq2Seq

  • Включите FP16 для экономии памяти и ускорения

  • Для Apple Silicon используйте MPS вместо CPU

  • Сохраняйте только лучшие чекпоинты (save_total_limit=3)

6. Мониторинг качества

  • Следите не только за BLEU, но и за chrF и TER

  • Проверяйте примеры переводов после каждой эпохи

  • Обращайте внимание на empty predictions (пустые переводы)

  • Измеряйте length ratio (соотношение длин предсказаний и референсов)

Дальнейшие планы

Мы находимся на уровне 18-19 BLEU для русско-кабардинского направления, в то время как кабардино-русская модель дошла до 28 BLEU

Что я планирую попробовать:

1. Отбор худших 30% примеров - Прогнать все пары через обученную модель - Отобрать примеры с наихудшими результатами перевода - Попробовать тонкие настройки на этом подмножестве, попробовать циклическое обучение слабой модели более продвинутой.

2. Поиск дополнительного корпуса - Если предыдущее не поможет, нужен более полноценный корпус - Цель: снизить уровень loss и выйти на production-ready модель.

3. Расширение северокавказских языков - Планирую добавить: чеченский, осетинский, даргинский, аварский, лезгинский - нужна помощь со сбором корпусов и составлением словарей ударений для этих языков.

Заключение

В целом я доволен проделанной работой. Синтез языков бывших стран СНГ, созданный Silero, вместе с нашими переводчиками кабардинского и каскадными переводами через NLLB-200 позволили создать отличный инструмент для перевода с кабардинского на любой из этих языков с правильным произношением.

Слушайте, изучайте - мир прекрасен своим языковым разнообразием!

Ресурсы

Благодарности

Огромная благодарность: - Команде Silero за потрясающую TTS-систему и поддержку - Анзору Кунашеву (adiga.ai) за качественный корпус и модели - Адаму Паногову за вклад в корпусы и исследованияВсему сообществу, работающему над сохранением кабардинского языка - Helsinki-NLP за отличные OPUS-MT модели


P.S. Если вы носитель чеченского, ингушского, даргинского, лезгинского, аварского или осетинского языка и готовы помочь с записью голоса на вашем родном языке - пишите компании Silero. Они делают эту работу по гранту и с удовольствием поработают с вами.

Также приглашаю энтузиастов кабардинского языка присоединиться к проекту записи кабардинского языка Common Voice на адыгских языках.

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