Привет, Хабр! В продолжение моей предыдущей статьи о локальном переводчике на кабардинском языке хочу поделиться практическим опытом обучения моделей машинного перевода для низкоресурсных языков. Расскажу о том, с какими проблемами я столкнулся, как их решал, и покажу конкретный код, который помог улучшить качество перевода с 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).
Реализация
Я написал скрипт, который:
Прогоняет весь корпус через обученную модель и вычисляет loss для каждого примера
Сортирует примеры по loss от большего к меньшему
Отбирает топ-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 позволили создать отличный инструмент для перевода с кабардинского на любой из этих языков с правильным произношением.
Слушайте, изучайте - мир прекрасен своим языковым разнообразием!
Ресурсы
PyPI: kabardian-translator
Модели на Hugging Face:
Благодарности
Огромная благодарность: - Команде Silero за потрясающую TTS-систему и поддержку - Анзору Кунашеву (adiga.ai) за качественный корпус и модели - Адаму Паногову за вклад в корпусы и исследования - Всему сообществу, работающему над сохранением кабардинского языка - Helsinki-NLP за отличные OPUS-MT модели
P.S. Если вы носитель чеченского, ингушского, даргинского, лезгинского, аварского или осетинского языка и готовы помочь с записью голоса на вашем родном языке - пишите компании Silero. Они делают эту работу по гранту и с удовольствием поработают с вами.
Также приглашаю энтузиастов кабардинского языка присоединиться к проекту записи кабардинского языка Common Voice на адыгских языках.
Статья написана с любовью к родному языку и уважением ко всем, кто работает над его сохранением.
