Немного контекста

Последние месяцы мы в команде развиваем проект Manuscript OCR - открытую библиотеку, которая учит нейросети читать рукописные документы XIX века. Это сложный материал: дореформенная орфография, нестабильный почерк, архивные артефакты.
Кому интересны технические детали - отдельная статья про Manuscript OCR уже есть на Хабре.

По ходу работы мне захотелось глубже разобраться в современных подходах к дообучению языковых моделей: SFT, LoRA, стилизация диалогов, подготовка синтетических датасетов. И как небольшой побочный эксперимент я решил попробовать обучить tiny-модель говорить в манере человека примерно 1850-х годов. Это не продакшн-задача, а скорее образовательный проект “для себя”, чтобы лучше понять инструменты в реальной практике.

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

Господин Говорунъ распознает рукописи
Господин Говорунъ распознает рукописи

Помимо основной работы над Manuscript OCR, мне стало интересно попробовать дообучить маленькую модель под дореформенную стилизацию - скорее как образовательный эксперимент. Такой «голос эпохи» можно использовать в демонстрациях и экспериментальных интерфейсах, но основная цель здесь - именно проверить, как tiny-LLM ведёт себя при локальном дообучении на стиле.

И при этом хотелось решение, которое можно:
- попробовать обучить всё локально и не зависеть от внешних API хотя бы для экспериментов,
- запускать на обычном GPU,

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

Это и есть герой этой статьи - условный «господин говорунъ», мини-LLM, дообученная под вполне специфическую, но интересную задачу.

Где взять диалоги XIX века?

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

готовых датасетов “вопрос-ответ в дореформенном стиле” попросту нет.

Исторические тексты - есть.
Архивные письма - есть.
Рукописи - тоже.
Но всё это - монологи, а не диалоги. Да и стилизовать ответы вручную на десятки тысяч примеров - задача из разряда «можно, но не нужно».

Поэтому я пошел другим путём.

Базовый корпус: alpaca-cleaned-ru

В качестве основы я взял открытый датасет d0rj/alpaca-cleaned-ru (~50 000 примеров)

Это обычный набор пар «instruction - response» на современном русском - короткие вопросы и простые ответы. Например:

Какие три основных цвета?
Три основных цвета - красный, синий и жёлтый…

Какая столица Франции?
Столица Франции - Париж.

Сгенерируйте три глагола, которые означают то же самое, что и “извиняться”.

  1. Выразить сожаление

  2. Искупить

  3. Загладить вину

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

Не хватало только одного: перевести всё это в XIX век.

Синтетика вместо архивов

Чтобы модель понимала задачу и отвечала в нужном стиле, я сделал следующее: взял крупную LLM и попросил её:

  1. переформулировать вопрос так, чтобы он звучал естественно для человека середины XIX века;

  2. сгенерировать ответ, соблюдая дореформенную орфографию и манеру речи.

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

Поэтому я взял большую LLM только для генерации обучающей выборки, а сам эксперимент сосредоточил на tiny-модели.

То есть на вход подаётся современная пара, а на выходе получается новая - уже стилизованная.

Результат - примерно 50 000 пар, которые выглядят так:

Вопрос: Пошагово объясните процесс приготовления сырной кесадильи.
Ответ: Что ж, кесадилья… дивно звучитъ. Не слышалъ о сем блюдѣ, но, полагаю, суть въ том, дабы сыръ въ лепешку завернуть да нагреть…

Вопрос: Назовите двух деятелей - современников Галилея.
Ответ: Иоаннъ Кеплеръ и Исаакъ Бекманъ - люди учёныя и серьёзныя.

Вопрос: Разработайте алгоритм сортировки списка.
Ответ: Коли списокъ малъ, разложите числа от меньшаго к большему. А коли великъ - сравнивайте попарно да меняйте мѣстами…

Почему маленькая модель?

Для эксперимента я специально выбрал маленькую модель Qwen2.5-0.5B. Не потому что это единственно правильное решение, а потому что хотелось проверить, насколько далеко можно зайти именно с tiny-LLM в условиях локального обучения. В реальной задаче, конечно, имеет смысл рассматривать модели покрупнее или LoRA-адаптеры на мощных LLM - но здесь цель была учебная и исследовательская.

Это действительно очень маленькая LLM по современным меркам, но зато:

  • обучается быстро,

  • не требует огромных ресурсов,

  • хорошо поддаётся дообучению,

  • показывает достойные результаты на задачах «стилевого» файнтюна.

Среда и общий процесс

Для эксперимента я использовал обычный Google Colab.
Чтобы было проще повторить - весь код доступен в ноутбуке:

В статье ниже - ключевые фрагменты с комментариями.

Загрузка модели и токенизатора

from transformers import AutoTokenizer, AutoModelForCausalLM
from trl import SFTTrainer, SFTConfig

MODEL_NAME = "Qwen/Qwen2.5-0.5B"

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForCausalLM.from_pretrained(MODEL_NAME, torch_dtype="auto")

В этот момент можно приятно удивиться: модель весит мало, загружается быстро, память не забивает.

Подготовка датасета

from datasets import Dataset
import json, re

DATA_PATH = "training_data_19century.json"

def remove_system_prompt(text):
    pattern = r'<\|system\|>.*?<\|user\|>'
    return re.sub(pattern, '<|user|>', text, flags=re.DOTALL)

data_list = []
with open(DATA_PATH, 'r', encoding='utf-8') as f:
    for line in f:
        obj = json.loads(line)
        cleaned_text = remove_system_prompt(obj["text"])
        data_list.append({"text": cleaned_text})

data = Dataset.from_dict({"text": [x["text"] for x in data_list]})

На выходе - примерно 50k примеров диалогов в дореформенном стиле.

Конфигурация обучения

config = SFTConfig(
    output_dir="model_1",
    per_device_train_batch_size=1,
    logging_steps=1000,
    max_length=1024,
    num_train_epochs=1,
    lr_scheduler_type="cosine",
    learning_rate=7e-5,
    run_name='exp1',
    save_steps=1000,
    save_total_limit=1,
)

Обучение

trainer = SFTTrainer(
    model=model,
    args=config,
    train_dataset=data,
    processing_class=tokenizer,
)

trainer.train()

Вся тренировка заняла ~4 часа на стандартном Colab GPU.

Step

Training Loss

1000

2.560000

2000

2.220300

3000

2.084600

...

...

41000

1.362100

42000

1.347800

43000

1.327500

Лосс постепенно снижался, что показывает, что обучение идёт в нужную сторону. Это не глубокий анализ качества, но для учебного эксперимента этого достаточно.

Сохранение модели

model.save_pretrained("model_19century_final")
tokenizer.save_pretrained("model_19century_final")

На этом шаге получаем полностью готовые веса модели, которые можно загрузить локально или залить в HuggingFace Hub.

Результаты: что получилось на практике

После завершения обучения я начал тестировать модель на простых бытовых вопросах - примерно таких же, какие были в исходном корпусе. Ожидания были умеренными: tiny-модель, один проход по данным, стильовая задача. Но всё равно хотелось посмотреть, как она поведёт себя “вживую”.

Ниже - реальные ответы, без правок и украшений.

Вопрос:
Сколько планет в нашей солнечной системе?
Ответ модели:
О, вопросъ любезный! Земля наша, какъ намъ кажется, богатъ планетами. Земля наша, конечно, есть малая, но планетъ въ ней, полагаю, въ сотни разъ больше, нежели и въ нашей нашей Солнечной системѣ. Считается, что свѣтъ изъ нихъ въ небесахъ видимъ въ разы больше, нежели въ землѣ нашей.

Здесь модель полностью уходит в образ, но теряет связь с реальностью. Архаичный тон выдержан, орфография тоже, но смысл - размыт, астрономии - ноль. Типичная реакция маленькой LLM, обученной на стилевых данных.

Вопрос:
Как быстро выучить новый язык?
Ответ модели:
Почитай, господинъ! Истинно, не всякій въ словеси пойметъ, а разумъ не всегда бываетъ. Но коли хочешь, да и душа у тебя не задержится. Слышалъ я, что въ Россіи, въ Петербургѣ, въ гимназіяхъ учатъ, да и въ университетахъ, если позволятъ. А коли ужъ не такъ, то, пожалуй, почитайъ и почитайъ.

Стиль - есть. Дореформа - есть. Логика - частично: модель понимает, что речь о “учёбе”, но дальше начинает блуждать. Ответ звучит “по духу эпохи”, но не несёт чёткого совета.

Вопрос:
Когда собирать крыжовник?
Ответ модели:
Крыжовникъ? Да что вы говорите! Истинно, что на ней въ небесахъ видимъ. Да и въ сущности, что за вещь эта, крыжовникъ? И что есть "книга"? Не слышалъ я о семъ.

Очень показательная ошибка: модель не знает предмет → начинает блуждать по семантическому полю, теряет объект (“крыжовник”), переходит к чему-то созвучному (“книга”), и уходит в сторону.

Что можно сказать по итогам тестирования

Модель отражает ровно то, чем её кормили:

Что получилось хорошо

  • стиль эпохи - уверенный, атмосферный;

  • дореформенная орфография - стабильная и последовательная;

  • тон - действительно напоминает речь «образованного человека XIX века»;

  • работает стабильно, без генерации современного жаргона или англицизмов;

  • отлично подходит как демонстрационный “голос эпохи”.

Что ожидаемо слабее

  • понимание смысла: tiny-модель путается в фактах и бытовых понятиях;

  • предметные знания отсутствуют, модель компенсирует “общей говорливостью”;

  • ответы могут быть витиеватыми, но малоинформативными;

  • в некоторых случаях уходит в ассоциативную болтовню (“книга” вместо “крыжовник”).

Почему так?

Потому что задача была чисто стилевая, а не “учебно-фактовая”.

Модель не обучалась на ботанике, астрономии или учебниках, а только на синтетических диалогах, выдержанных в определённой манере.
Tiny-LLM не может «догадаться» о мире - она лишь воспроизводит статистику стиля. И с этой ролью она справляется.

Что можно сказать по итогам тестирования

Когда базовая версия модели заработала, я решил оформить результат так, чтобы им можно было легко делиться и тестировать без локального запуска. Для таких задач удобнее всего использовать HuggingFace - там и хранение моделей, и готовые инструменты для небольших интерактивных демо.

В итоге я создал репозиторий модели и собрал простой чат-интерфейс на Spaces, куда можно зайти и пообщаться с “дореформенным собеседником” напрямую.

Интерфейс чат-бота, опубликованный на HuggingFace
Интерфейс чат-бота, опубликованный на HuggingFace

Итоги

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

Для меня это стало хорошим подтверждением, что подход со стилевыми датасетами работает, а лёгкие модели вполне пригодны для таких точечных проектов. Дальше можно расширять корпус, улучшать устойчивость и интегрировать “говоруна” в демо Manuscript OCR.

В итоге эксперимент получился таким, как и планировалось: простой, локальный тест идеи - собрать датасет, обучить tiny-модель и посмотреть, что из этого выйдет. Никакого продакшна - но отличная база для дальнейших экспериментов и для развития компонентов Manuscript OCR.