
Зачем? Для создания скайнета, разумеется.
Ну, и чтобы досконально разобраться, чем сейчас дышит эта чарующая меня сфера. А лучший способ разобраться в чем‑то — это попробовать обьяснить это другому.
Помимо всего прочего, я хочу вкатиться в глубокое обучение профессионально, и начать светить своими интересными проектами в этих ваших интернетах мне кажется наиболее быстрым способом добиться, чтобы тебя заметили.
Лично я получил от процесса большое удовольствие, и предлагаю окунуться в это небольшое путешествие читателям Хабра.
Cсылки на датасет, веса и код будут приложены в конце статьи. Датасет и веса на Hugging‑Face, кодовая база на Codeberg (аналог Github, работа с ним такая же).
Поехали.
Важная ремарка
Автор является программистом со стажем, но все ниже было навайбкодено с помощью Claude Code более чем, полностью. При этом автор честно постарался во всем написанном разобраться. В любом случае, используйте приведенные исходники на свой страх и риск, я предупредил.
Так же стоит учесть, что для автора это первая техническая статья за его жизнь. Я предпринял значительные усилия, чтобы привести в статью в удобный для чтения вид, но в статье все равно могут быть шероховатости.
Большая часть статьи была написана моими руками, но из-за обьема материала мне пришлось прибегнуть к услуге Opus-редактора, в основном пользовался его сносками по обучению модели, инфу старался проверять и корректировать, но опять же имейте ввиду.
С языковыми моделями я впервые познакомился в начале 2022 года, когда подрядчиком веб‑студии, где я работал python‑разрабом, была американская компания Inita, которая делала ИИ‑стартап для малого бизнеса.
Я получил доступ к API OpenAI, и модели GPT-3, и зачаровался этой технологией с первого взгляда. Есть что‑то почти магическое в том, когда обычные строчки кода могут вступить с тобой в диалог, и учится мышлению.
К сожалению, из‑за известных событий, наши заказчики в какой‑то момент перестали иметь возможность платить нам деньги, а нанимался я фактически именно на этот проект, других задач для меня не нашли.
Спустя полгода тщетных поисков работы, я смог устроиться на небольшой приборостроительный завод программистом микроконтроллеров, но фактически я писал и прошивки для МК, и графические интерфейсы для работы с МК.
Все эти годы я старался быть в теме языковых моделей, и очень активно пробовал разных чат‑ботов, в том числе активно и долго использовал платную подписку ChatGPT, но это больше была экспертиза на уровне пользователя, чем разработчика, что меня не очень устраивало.
Так я проработал почти 3 года, пока мне не предложили место в крупной корпорации с ощутимым приростом зп. Внезапно оказалось, что работа разработчиком в малом бизнесе в РФ имеет свою специфику — из тебя пытаются постоянно выжать все соки, и в крупной компании порядки иные.
У меня высвободилось большое количество ментальных ресурсов, которые я использовал, чтобы реализовать свою давнюю мечту, которую лелеял с 22го — разобраться в глубоком обучений.
Начал я с малого, опробовал небольшие глубокие сети на интересующихся меня задачах (помогал Claude Code) — по итогу получились, например, самообучающаяся 2д змейка, а так же квадропед Anymal в среде Mujoco, который учился ходить. В подробности углубляться не буду, покажу лишь пару демонстрации.


Но все это было подготовкой перед главным боссом — языковыми моделями. Для начала я прочитал книгу «Строим LLM с нуля» Себастьяна Рашки, и пытался превратить GPT-2-small в бота следующего инструкциямии через LORA‑адаптер, обучал его посредством SFT.
Разберем эти два упомянутых термина более подробно.
LORA‑адаптер реализуется посредством добавления маленьких матриц к слоям модели. Таким образом мы получаем возможность дооучивать модель на интересующихся нас ситуациях, без изменения модели целиком.
SFT (Supervised Fine‑Tuning) — обучение модели на парах запрос пользователя — ответ ассистента, по обучению принцип тот же что и на обучений сырых данных, но важный момент в том, что лосс считается только на продолжений текста ассистента, не пользователя, который задал вопрос.
В целом, в чат‑бота GPT-2 превратился, хоть и было заметно, что модели не хватало «мозгов», чтобы отвечать правильно. Из интересного. можно взять пример с переводом английской фразы на французский.
# Промт
> Translate 'Good morning' to French.
### Базовая GPT-2-small
Translation:
Translation:
Translation:
... И так еще 100 раз### После SFT-обучения
Je suis arrivé.Модель ответила неправильно, но вот что интересно — она ответила фразой, которую на русский дословно можно перевести «Я прибыл», т.е она может использоваться в том же контексте что и Бонжур, как приветствие. Можно сделать вывод что у модели было понимание, что ей нужно ответить, но не хватило фактических знаний.
На самом деле, ошибки (особенно у маленьких LLM) моделей зачастую разбирать гораздо интереснеее чем когда они отвечают правильно, потому что их ошибки часто похожи на то как допускают ошибки люди, с которых они и учились мышлению.
Я хотел почувствовать магию, когда обычные массивы с цифрами спустя тысячи итерации начинают выдавать в ответ то же, что у людей требует мышления. Поэтому я решил создать и обучить модель сам.
Если упрощать, то создание LLM «с нуля» можно разделить на 4 этапа.
1. Сборка датасета.
2. Написание, обучение токенайзера (словаря модели), и токенизация имеющегося датасета.
3. Конфигурация модели.
4. Обучение модели на выбранном датасете.
Разберем эти этапы более подробно.
1. Сбор датасета
Парсинг интересующихся нас данных, которые мы будем скармливать модели, или скачивание готового датасета). При сборе датасета с нуля очень важна такая штука как нормализация, т.е очистка данных от нерелевантного мусора, особенно это важно при обучений LLM, ибо именно от качества исходных данных зависит будет ли модель выдавать то, что тебе нужно.
2. Написание, обучение токенайзера, и токенизация имеющегося датасета.
Важно понимать, что модели будет проще учится продолжать человеческий текст, когда мы предварительно разделим его на «куски», вместо того чтобы заставлять предсказывать текст побуквенно. Модель тогда обучается быстрее и выдает более качественный результат. Далее будет показана разница между посимвольным обучением и токенизированным. Стоит так же заметить, что под обучением токенайзера имеется ввиду не обучение глубокой сети, там используется классический алгоритм.
3. Конфигурация модели.
Самое важное тут итоговое количество параметров, ибо это напрямую кореллирует с максимумом интеллектуальных возможностей, которая модель может иметь для генерации осмысленного текста. Это будет наглядно показано далее, когда буду показывать результаты 10-миллионой и 50-миллионной модели на том же датасете.
4. Обучение модели на выбранном датасете.
Хорошей практикой в глубоком обучений является разделение датасета на обучающую и тестовую выборку. Обучающая выборка напрямую влияет на веса модели, именно на нем модель учится выбранной задаче, а тестовая нужна для контроля обучения.
Любые глубокие сети оптимизируют потери, а не наши хотелки, и это может приводить к тому, что вместо того, чтобы научится обобщать, сеть может начать запоминать тренировочные данные, это называется переобучение.
Как раз это контролирует тестовая выборка, на ней сеть не обучается, а только выдает результат. Как правило, она используется для сохранения лучших чекпоинтов модели, а так же ранней остановке обучения, если тестовый loss уже долго не снижается или растет, а обучающий loss наоборот продолжает падать.
Самый сложный в работе с lib.ru момент — это не получение страниц, а редакторский аппарат академических изданий. На lib.ru у Чехова лежит ПСС, и параллельно с самими рассказами там идут варианты разночтений, описания рукописей, текстологические комментарии, биографические справки.
Сырой парсинг даёт ~24 МБ текста, но половина — это аппарат. Я написал серию cleaner-ов на регулярках, которая итеративно отрезала:
Заголовки секций («Примечания», «История писания», «Список сокращений»)
Шапки писем («Чехов — А. С. Суворину», «Ал. П. Чехову»)
Текстологические пометки («Далее было начато:», «Вписано вместо:», «На полях помета:»)
Архивные шифры (ЦГАЛИ, ГПБ, ИРЛИ)
Библиографические сноски и ссылки на тома
После очистки осталось 16 МБ чистой чеховской прозы — рассказы, повести, пьесы, записные книжки. Письма попали в корпус полностью; аппарат — нет.
Char-level означает буквально: каждый символ — токен. Алфавит модели — 201 уникальный символ: кириллица в обоих регистрах, латиница (Чехов писал по-французски и по-немецки), пунктуация, тире, кавычки, цифры, типографские символы из изданий.
# prepare.py — стандартный nanoGPT char-level препроцессинг
chars = sorted(list(set(text)))
vocab_size = len(chars) # 201
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for i, c in enumerate(chars)}
90% корпуса в train, 10% в val. Никаких спецтокенов, никаких EOS — модель просто учится непрерывному потоку символов.
n_layer = 6
n_head = 6
n_embd = 384
block_size = 256 # ~120 русских слов в контексте
vocab_size = 201
dropout = 0.2 # маленькая модель, маленький корпус
bias = False
Часть | Параметры |
|---|---|
Token embedding (wte, tied) | 201 × 384 = 77K |
Position embedding (wpe) | 256 × 384 = 98K |
Per-layer attention | 4 × 384² ≈ 590K |
Per-layer MLP | 8 × 384² ≈ 1.18M |
× 6 layers | ~10.6M |
Всего | ~10.76M |
По сегодняшним меркам это сравнимо с историческими char-rnn моделями Карпатого 2015 года из его статьи «The Unreasonable Effectiveness of Recurrent Neural Networks» — тогда такие же по масштабу LSTM учили на текстах Шекспира и Толстого, и наш эксперимент прямо в той же традиции.
learning_rate = 1e-3
max_iters = 2000
warmup_iters = 100
lr_decay_iters = 2000
min_lr = 1e-4
beta2 = 0.99
batch_size = 64
dtype = 'bfloat16'
Стартовый лосс получился 5.41 ≈ ln(201) = 5.30 — модель честно начала с равномерного распределения по словарю. 180-250 мс на итерацию на RTX 3050 Mobile, общее время обучения около 20 минут. VRAM съедает чуть больше гигабайта — на 4 ГБ-видеокарте огромный запас.
Тестировал через sample.py на промпте «Антон Петрович» в разные моменты тренировки.
iter ~600 (loss ~1.9):
Антон Петрович на подложенном самому стречанием и поговорянулся оно отдавали с вам. С наставлялось, которые ни не мигние в воспектакле двух с радочной чиста, что он было он состоящения вечери, продолжал он, то он присуждение францелько от сохнеком чувственном держании не было быть то, чтобы списал приехал хвостор
Уже выучила правильные русские морфемы и окончания («продолжал он», «которые», «не было быть»), правильно балансирует кавычки, ставит заглавные после точек. Псевдослова вроде «продолжянулся» и «жизников» — нормальное явление на этом этапе: модель усвоила фонотактику (какие буквы за какими могут идти), но ещё не запомнила целые слова.
iter ~2250 (loss ~1.4):
Антон Петрович на проходе на бок, думал: «Дают симпатичную кулаку и отдавать себя на нее десять падает девочки и смехнуть такое. Написал о том, что нужно замечательный две за своих исполнительного прислатьев…»
Почти все слова теперь настоящие — «симпатичную», «десять», «девочки», «нужно», «замечательный». Появилась структура прямой речи через двоеточие и кавычки. Локальные согласования родов и падежей часто работают («симпатичную кулаку» — винительный жен.р.), даже когда слова стыкуются абсурдно.
Но самое интересное — второй семпл из той же пары:
Антон Петрович (см. примечании к нему Чехова). Был от деньги — см. упоминаний А.П. Чехов — А. П. Чехову Михаилу Павловичу
3 Рядом по собранию «Нового перепремена», к I, 45, 1.
4 «Три года» (СМ 8, 7 с изменением, 1886, No 31.
Модель выучила формат редакторского аппарата ПСС. Cleaner вычистил почти весь, но часть осталась — и эти редкие куски затвердели в модели как отдельный режим генерации.
iter ~5000 (финал, val loss ~1.20-1.22):
Антон Петрович медленно поглядел на меня и замигал глазами. Он стал маленьким и подложил спичку. – Я вас видела… – сказал он, смеясь. – Я спрашиваю вас, буду ждать вас. – Надо ли два года? – спросил я, улыбаясь. – Да. Ты подумайте, что вы увидите меня в этой квартире проживете. Ты поедешь в Петербург, а в Москву еще до Петербурга? – спросил я.
Это уже почти связный микро-эпизод. Структура диалога идеальная: тире в начале реплик, авторская речь после реплики через тире, чередование собеседников, глаголы говорения с деепричастиями. Между предложениями появилась связь: «поглядел на меня и замигал глазами» → «стал маленьким и подложил спичку» — два последовательных действия одного персонажа. Сцена согласована — два собеседника, кабинет, разговор о будущем, упоминания Петербурга и Москвы.
Где модель ломается — на семантике и логике:
«Я вас видела» (женский род) от «он» — рассогласование рода
«Ты подумайте» — смешение Ты/Вы
«Надо ли два года?» — фраза грамматически валидна, но смысла нет
Это и есть граница между синтаксисом и семантикой. Char-level модель на 10М освоила синтаксис прилично, но удержание глобального смысла — это уже работа более высоких уровней представления, которых при таком масштабе просто нет.
Самое интересное в этой модели — это аттракторы. На промпте «Антон Петрович» модель стабильно 50/50 уходит либо в прозу, либо в сноску ПСС:
Антон Петрович – 127, 438 Мария Каниновна (1848–1899), профессор А. П. Чехова – 188, 259, 313 Опубликов Иванович (1884–1881), замер “Чайку”, соловья Владимировны – 129, 383 “Сахалин” – 178, 439
Это path dependence: распределение вероятностей следующего токена после «Антон Петрович» резко бимодальное. Если следующий символ — перевод строки, модель почти детерминированно идёт в режим указателя имён, потому что в обучающих данных после Имя\n практически всегда шли пункты с цифрами и тире. Если следующий символ — запятая или пробел перед глаголом, модель уходит в прозу. Один случайный выбор первого токена коммитит всю дальнейшую траекторию.
Чтобы убрать сноски, достаточно жёстко зафиксировать траекторию в промпте. После «Антон Петрович медленно » указатель имён уже невозможен — там после имён идут цифры или годы, а не наречия:
Антон Петрович медленно улыбался, даже сухо прошелся и сказал ему: – Так и в доме поговорил Матвей Петрович, чтобы получать граф по Петербургу началась как значительно. В каком смысле я не югодно было себе и без комнаты пошла к комнате…
Способность | Качество |
|---|---|
Кириллица, буквенное распределение | ✅ идеально |
Морфология (падежи, окончания) | ✅ почти всегда правильно |
Структура диалога (тире, реплики, авторская речь) | ✅ узнаваемо |
Чеховская стилистика (имя-отчество, ритм, лексика) | ✅ ярко |
Локальная связность 2-3 предложений | ⚠️ местами |
Удержание темы на абзац | ❌ нет |
Любая семантика | ❌ галлюцинации |
Факты | ❌ нет |
С этой простой моделью я потом ковырялся в mech-interp анализе — но это отдельная глава, которая в итоговую статью не попала, чтобы излишнее ее не перегружать.
Стоит так же заметить, что позднее я обучал 10-миллионную модель на нормальном токенайзере (который будет разобран ниже), а так же на более обширном датасете русской классики, но поведение модели поменялось несильно — речь все так же бессвязна, судя по всему, на 10 млн параметрах добиться от модели «симуляции мышления» невозможно.
После Чехова стало интересно: что будет, если расширить корпус во много раз, добавить BPE-токенизатор, и увеличить модель до GPT-2-nano уровня.
Расширил парсер на 21 автора: всю золотую прозу (Толстой, Достоевский, Тургенев, Гончаров, Лесков, Бунин, Куприн, Гоголь, Андреев), пьесы (Островский, Грибоедов), литературную критику (Белинский, Добролюбов, Писарев, Герцен, Чернышевский), плюс малых классиков (Гаршин, Короленко, Салтыков-Щедрин, Лермонтов).
Сырой парсинг дал 369 МБ. Та же чистка, что для Чехова, плюс новые паттерны под академический аппарат каждого автора (у Тургенева — французские адреса и подписи, у Толстого — варианты издания «1868 г.», у Достоевского — текстологические разметки), — итого 264 МБ чистого корпуса.
Перед обучением токенизатора прогнал текст через нормализатор:
ё → е, Ё → Е (типографское ё в русских текстах ставится непоследовательно, и для модели лучше не дублировать токены)
Унификация кавычек: " " „ → « »
Унификация тире: – , -- → —
Многоточие: ... → …
Это сильно сокращает токенный словарь и даёт модели одну каноническую форму каждого пунктуационного знака.
spm.SentencePieceTrainer.train(
input="corpus_clean.norm.txt",
model_prefix="spm",
vocab_size=16000,
model_type="bpe",
character_coverage=1.0,
byte_fallback=True,
user_defined_symbols=["—", "«", "»", "…"],
)user_defined_symbols гарантирует, что эти знаки никогда не разрезаются на байты — модель видит их как атомарные токены. На чистом BPE без этой опции тире (—, U+2014, 3 UTF-8 байта) могло разлететься на куски, что для русской классики катастрофа: тире — главный синтаксический знак диалога.
Эффективность токенизатора: 3.49 символа на токен в среднем. Полные слова вроде «Достоевский», «помещик», «барышня» — один токен. Имена-отчества — 2-3 токена.
После токенизации: 75M токенов в train.bin.
n_layer = 12
n_head = 8
n_embd = 512
block_size = 512 # ~1700 символов в контексте
vocab_size = 16000
bias = False
dropout = 0.1Часть | Параметры |
|---|---|
Token embedding (16000 × 512, tied) | 8.2M |
Position embedding | 0.26M |
Per-layer (attn + mlp) | ~3.15M |
× 12 layers | 37.8M |
Всего | ~46M |
n_layer = 10
n_head = 8
n_embd = 512
ff_dim = 2048 # 4 × n_embd
block_size = 1024
vocab_size = 32000
bias = False # современный стандарт без bias в Linear/LN
dropout = 0.130000 × 34K ≈ 1B токенов, что около 14 эпох по корпусу. Это далеко за chinchilla — но 75M токенов слишком мало для 46M модели по оптимуму, и в условиях ограниченного корпуса дополнительные эпохи продолжают давать пользу, особенно по локальной связности.
VRAM: ~3.2 ГБ при bf16, ctx=512, bs=6. Итерация ~1.5 секунды на RTX 3050 Mobile.
iter 0 train loss 9.68 / val 9.68 (≈ ln(16000), случайная инициализация)
iter 500 ~5.5 warmup закончен
iter 3000 ~4.0
iter 8000 ~3.6
iter 14000 ~3.45
iter 21000 ~3.36 ← лучший val (best checkpoint сохранён)
iter 24500 ~3.36 ← плато, остановил вручнуюЛучший чекпойнт зафиксирован на 21000 шаге, после этого val перестал улучшаться. Train продолжал ползти вниз — типичный переобучающий хвост. always_save_checkpoint=False оставил на диске именно тот, что нужно.
Общее время обучения — около 13 часов непрерывной работы ноутбука.
На промпте «ыыыы» (намеренно бессмысленная затравка, чтобы посмотреть какие аттракторы у модели) после 21000 итераций модель выдала два радикально разных продолжения за два семпла:
Семпл 1 — пьеса в стиле Островского:
ыыыы! (Оставляет шляпу.)
Жмигулина . Ну, что, а еще куда?
Авдотья Васильевна . А вот уж давно, дожидайся; и так и пропадешь, и пропадешь. (Поглядывает в окно.) Поди, поди; я тебя дожидаться стану.
Жмигулина. Не пойду. (Уходит.)
Авдотья Васильевна (не слушая речей мужа). Вот еще! в последний раз!
Имена персонажей блоком, ремарки в скобках, диалог семантически почти связный. Модель сама ввела отношения между персонажами через ремарку «не слушая речей мужа» — это уже не повторение паттерна, это построение мира.
Семпл 2 — текстологический аппарат академического издания:
ыыыык же. (Берет письмо).
26 Вместо: излишнее ~ не было // les
38 Вместо: оставили // обставили
Стр. 391 2 Вместо: излишнюю // родную
Формат «варианты и разночтения» из академических ПСС. Cleaner не справился с этим до конца, и модель выучила формат как один из жанров корпуса.
Тот же эффект, что у Чехова, только теперь жанры более развитые: Островский vs ПСС-аппарат. Промпт «ыыыы» оказался достаточно неоднозначным, чтобы триггернуть оба аттрактора в разных семплах.
Способность | Качество |
|---|---|
Грамматика русского | ✅ почти безупречна |
Стилистика классики XIX века | ✅ узнаваемо |
Жанровое переключение (проза/пьеса/аппарат) | ✅ работает |
Имена-отчества, согласование рода | ✅ держит |
Локальная связность абзаца | ✅ 4-6 предложений |
Удержание темы на 100+ токенов | ⚠️ изредка |
Удержание сюжета на сцену | ❌ нет |
Факты | ❌ галлюцинации |
Любая семантическая задача | ❌ нет |
Что тут важно отметить — по сравнению с 10-миллионой моделью здесь уже видна вполне связная речь, притом это обеспечивается именно за счет увеличения параметров.
«Дикую» модель, которая может только пытаться правдоподобно продолжить введенный тобою текст мы сделали, сейчас нам необходимо создать модель, которая могла бы вести с тобой диалог — необходимо превратить языковую модель в чат‑бота, все это так же будет делаться с полного нуля.
По сравнению с предыдущей главой меняется только содержание датасета. Проще всего для этого выбрать качественные дистиляяции больших моделей с Hugging-Face.
Сначала я хотел перевести на русский дистилляции модели Opus-4.5/6 от Anthropic, но потом остановился на гораздо более обьемном датасете дистиллята от Kimi-2.6 и GLM-5, которые весили по несколько десятков ГБ.
Но у нас была проблема. Датасет очевидно англоязычный, а нужен был русскоязычный датасет. Я попробовал перевести его своими силами с помощью хорошей модели-переводчика от Гугла Translate-Gemma-4b, но столкнулся с тем, что переводить текст на моем ноутбуке Maibedden (4 ГБ VRAM RTX 3060, и 16 ГБ RAM) она будет чудовищно долго.
Как вариант, можно было использовать аренду машин с GPU на vast.ai, но особой нужды в этом не видел, ибо большой русскоязычный диалоговый датасет я уже нашел.
Я решил остановится на диалоговом датасете от российской компаний ZeroAgency, он более чем удовлетворял моим потребностям, он был довольно обьемный и там большое внимание было уделено рассуждениям. В итоге решил взять его.
Датасет был уже предварительно разделен на тестовую и обучающую выборку, и я мог приступать к обучению самой модели.
Представим нашего гостя -big-russian-dataset (HuggingFace) — русскоязычный SFT-корпус. 19 train-файлов + 1 test-файл в формате .parquet, всего ~3.9 ГБ сжатого parquet, ~7 ГБ после раскодировки в UTF-8.
conversation: list<{role: "system"|"user"|"assistant", content: string}>
question: string
source: string
topic: string, classified_topic: string
overall_score: int # 0-10, оценка LLM-судьёй
safety: int # 0-10
pii_leak: int # 0/1
refusal: int # 0/1, признак отказа модели отвечать
coherence, relevance, helpful, ...: int # дополнительные метрикиСплит | Файлов | Диалогов | После фильтра |
|---|---|---|---|
train | 19 | 1.71M | 1,709,621 (99.9%) |
val (test) | 1 | 18.5k | 10,396 (56%) |
После фильтрации overall_score≥6 AND safety≥8 AND pii_leak=0:
train отфильтровывается практически полностью (99.9% проходят), потому что в train авторы датасета сами уже почистили мусор — там нет записей с score < 6.
В val разброс score 1-10 оставлен специально, чтобы можно было оценивать модель на полной выборке трудности. Поэтому из 18.5k остаётся только 10.4k.
Low-score (score=3, отсеяны фильтром):
[user] Кто хотел арестовать Томаса Джефферсона?
[assistant] Пол Ревир был потомком гугенотских беженцев, как и Генри Лоуренс,
подписавший Статьи Конфедерации для Южной Каролины; Джек Джоуетт, который
совершил поездку из Куку-Таверны, чтобы предупредить Томаса Джефферсона...
Ассистент скопировал контекст вместо ответа на вопрос. Типичный артефакт RAG-генерации обучающих данных.
High-score (score=9, проходят фильтр):
[user] Резюмируйте статью: "Смерть медсестры": 50-летняя Вера Маресова призналась
в убийстве пяти женщин и одного мужчины в больнице...
[assistant] Вера Маресова, 50 лет, убила пять женщин и одного мужчины за четыре
года. Полиция сказала, что она совершила убийства, чтобы облегчить свою работу...метрика | chars | ≈ tokens (BPE-32k) |
|---|---|---|
mean | 1521 | ~550 |
p50 | 1237 | ~450 |
p90 | 2680 | ~1000 |
p95 | 2914 | ~1100 |
p99 | 3957 | ~1500 |
P90 ≈ 1000 токенов мотивировал выбор block_size=1024 для тренировки — покрывает большинство диалогов целиком.
Топ-5 в val: history (37%), sports (5%), news (4%), crime (4%), politics (3%). Доминируют исторические summarization-задачи (от subset ai-bond/ru-alpaca-summ).
ChatML-разметка с спецтокенами:
<|system|>{system}<|endoftext|>
<|user|>{user}<|endoftext|>
<|assistant|>{response}<|endoftext|>
Все четыре спецтокена зарегистрированы как user_defined_symbols в SentencePiece, что обеспечивает их атомарную токенизацию (не разрезаются на куски).
Loss-маска: при тренировке учитываем только токены ответа ассистента (включая закрывающий <|endoftext|>). System и user-сегменты идут с target = -1 (ignore_index в F.cross_entropy). Без этого 50M-модель не научилась бы отвечать — она бы тратила параметры на предсказание самих вопросов.
После фильтрации и разметки: 1.04 ГБ токенов в train.bin (uint16), из них 603M токенов под loss (assistant + EOT, 57.7%).
SentencePiece BPE, vocab=32000:
ID 0: <pad>
ID 1: <unk>
ID 2-5: спецтокены ChatML (<|system|>, <|user|>, <|assistant|>, <|endoftext|>)
ID 6-261: byte fallback (<0x00>…<0xFF>)
ID 262-31999: обычные BPE-pieces
Эффективность: средняя плотность токенизации 5.67 chars/token на русском тексте. Слова целиком («Привет», «отлично», «спасибо») попадают в один токен.
Для сравнения: прошлая 16k-vocab токенизация на русской классике давала ~4 chars/token. Удвоенный vocab дал плотнее представление, и в 1.4× больше реального текста влезает в те же 1024 контекстных токена.
Выбрал в качестве базы тот же NanoGPT, а параметры модели были такие.
n_layer = 10
n_head = 8
n_embd = 512
ff_dim = 2048 # 4 × n_embd
block_size = 1024
vocab_size = 32000
bias = False # современный стандарт без bias в Linear/LN
dropout = 0.1
Подсчёт параметров:
Часть | Параметры |
|---|---|
Embedding ( | 32000 × 512 = 16.4M |
Position embedding ( | 1024 × 512 = 0.5M |
Per-layer attention (c_attn + c_proj) | 4 × 512² = 1.05M |
Per-layer MLP (c_fc + c_proj) | 8 × 512² = 2.10M |
Per-layer LN (×2) | ~0.001M |
× 10 layers | ~31.5M |
Финальный LN | 0.001M |
Всего | 47.85M |
# AdamW
learning_rate = 3e-4
weight_decay = 0.1
beta1, beta2 = 0.9, 0.95
grad_clip = 1.0
# LR schedule (cosine с warmup)
warmup_iters = 200
lr_decay_iters = 16000
min_lr = 3e-5
# batch
batch_size = 2 # micro-batch
gradient_accumulation_steps = 32 # effective batch = 64 sequences
block_size = 1024
# tokens per iter = 2 × 32 × 1024 = 65,536
# training
max_iters = 16000 # ~1 эпоха (1.04B токенов / 65k токенов на iter)
# system
dtype = 'bfloat16'16000 iter × 65k token = 1.04B. Это примерно одна эпоха по корпусу.
iter 0 loss 10.49 (≈ ln(32000), стартовая случайная инициализация)
iter 500 loss ~5 warmup закончен, LR на peak
iter 3000 loss ~3
iter 5500 loss ~2.4 ← первый saved checkpoint (val_loss = ~3.3)
iter 8500 loss ~2.0 ← второй
iter 11500 loss ~1.7 ← третий
iter 14500 loss ~1.5 ← четвёртый (best val_loss ~ 1.8)
iter 16000 ← max_iters достигнут, дальше ckpt не сохранялся
(val_loss перестал улучшаться в самом конце cosine)Итерация: ~3 секунды холодная, ~4-4.5 секунды установившаяся (thermal). Общее время = ≈ 13 часов.
VRAM: ~2.8 ГБ занято при bs=2 ctx=1024 bf16 — есть запас от 4 ГБ потолка.
always_save_checkpoint = False: сохранение только при улучшении val_loss. Это страхует — если в конце что-то пойдёт не так, на диске останется лучший вариант. По факту последний чекпоинт был на iter 14500.
Тестировали через chat.py на одном промпте «Привет» в разные моменты тренировки.
iter ~6000 (ранний этап):
Привет! Конечно, вот предложение с предложением "Выбросите на работу"
для удобства чтения с экрана:
1. "Спланируйте свои задачи и задания" - это простой и понятный способ...
[грамматика чистая, но содержание -- лоскутное]
iter ~12000 (середина):
Расскажи о себе:
Как искусственный интеллект, я не имею возможности предоставлять мне точные
ответы на ваши вопросы. ### Шаг 1: Определение задачи Прежде всего, необходимо
определить, как это сделать. Это может быть так: "Я буду рад, что моя задача —
помогать с задачами"...Освоила markdown-туториал жанр, но смысла нет.
iter ~14500 (финал):
Привет
> Привет! Я готов помочь вам в достижении ваших целей. Хорошего дня!
Сколько будет 7 умножить на 8?
> Чтобы узнать, сколько будет 7 умножить на 8, нам нужно умножить количество
на 8. Для начала посчитаем общее количество способов разделить это число на 8:
5! = (5 × 8) / 8 = 120
Напиши сортировку пузырьком на Python.
> Вот пример сортировки пузырьком на Python...
def merge_sort(arr):
sorted_array = {}
for i in range(len(arr)):
if arr[i] < sorted_array[i - 1]:
return merge_sort(arr[i], reverse=True)Теперь разберем все 3 примера.
Первый — Идеально поняла контекст ситуации, поздоровалась и предложила помощь.
Второй
Второй — Математически каша, но жанр и грамматика идеальны, модель явно поняла что от нее требуется.
Третий — Имя функции merge_sort вместо bubble_sort, словарь вместо двух циклов — форма правильная, реализация бессмысленна.
Как по мне результат для модели такого размера очень достойный, хоть очевидно что ее не получится использовать в рабочих условиях. Но своего потолка модель едва ли достигла - качество генерации будет улучшаться при увеличений датасета.
Способность | Качество |
|---|---|
Грамматика русского | ✅ безупречно (падежи, согласования, синтаксис) |
Chat-формат (отвечает в роли ассистента) | ✅ устойчиво |
Markdown-структура (списки, bold, code-fences) | ✅ имитирует GPT-4-стиль |
Самоидентификация («я ИИ-ассистент») | ✅ говорит правильные слова |
Завершение по EOS | ✅ обычно сама останавливается |
Локальная связность 1-2 предложения | ⚠️ местами осмысленно |
Ответ по теме промпта | ⚠️ слышит триггерные слова, не суть вопроса |
Факты | ❌ галлюцинации |
Арифметика | ❌ имитирует подсчёт без него |
Логика, многошаговый reasoning | ❌ Отсутствует |
Code (синтаксически + семантически) | ❌ форма правильная, не работает |
Этими артефактами захотелось поделиться с друзьями и родственниками, но проблема в том, что они ничего не знают о llama.cpp и похожем софте.
Решение — сделать единый бинарник, который можно скинуть другу в Телеграме, и он смог бы это запустить.
Плюсом, интересно, когда модель, которая ведет себя как разумная сущность, можно запустить как какую‑то игру через компактный.exe.
Подход | Проблема |
|---|---|
PyInstaller (Python в exe) | Тяжёлый файл (~700 МБ-1 ГБ), долгий старт, не «настоящий single-binary» |
Go + ONNX runtime | Требует ONNX runtime DLL рядом — не один файл |
Go + llama.cpp через CGO | Нужна статическая сборка llama.cpp на Windows (MSVC headache) |
Чистый Go (выбран) | Самим писать forward pass и BPE encoder, но реальный single-binary |
go_serve/
├── export_weights.py # ckpt.pt → weights.bin (fp16) + config.json + vocab.json
├── go.mod # никаких внешних зависимостей (zero deps)
├── embed.go # //go:embed для weights/config/vocab
├── config.go # парсинг config.json
├── tokenizer.go # SentencePiece BPE encoder в чистом Go
├── model.go # forward pass + KV cache + sampling
└── main.go # chat REPL с /-командами
go build → один 97 МБ exe со всем встроенным.
Веса в state_dict хранятся в fp32. Перед embed конвертируем в fp16: размер файла в 2× меньше, точность для inference приемлема. Распаковка в fp32 происходит при загрузке (float16ToFloat32).
48M параметров × 2 байта = 92 МБ на всю модель.
Без cache каждый шаг генерации пересчитывает весь префикс (O(T²) per token). С cache — O(T) per token. Для 200-токенного ответа разница 100×.
type Model struct {
KCache [][]float32 // [layer][token_pos * n_embd]
VCache [][]float32
}
// На каждом forward'е:
m.KCache[l] = append(m.KCache[l], k...) // добавили новый K
m.VCache[l] = append(m.VCache[l], v...)
// attention работает с накопленным cache
В nanoGPT-style single-token inference основная операция — matrix-vector умножение y = W @ x, где W ∈ [out, in], x ∈ [in], y ∈ [out].
Простейшая реализация — двойной цикл O(out × in) — single-thread на 4-ядерном CPU использует 1/4 ресурса. Распараллелили по строкам:
func matVec(W []float32, rows, cols int, x, y []float32) {
nworkers := runtime.NumCPU()
chunk := (rows + nworkers - 1) / nworkers
var wg sync.WaitGroup
for w := 0; w < nworkers; w++ {
wg.Add(1)
go func(s, e int) {
defer wg.Done()
for i := s; i < e; i++ {
var sum float32
for j := 0; j < cols; j++ {
sum += W[i*cols+j] * x[j]
}
y[i] = sum
}
}(w*chunk, min(w*chunk+chunk, rows))
}
wg.Wait()
}
3-4× ускорение на 4-ядерном CPU без BLAS.
Все головы attention считаются конкурентно через sync.WaitGroup (forward для одного токена с KV-cache). Heads независимы — bottleneck’а нет.
Реальный SentencePiece-BPE encoder использует жадный merge с приоритетом по рангу, что эквивалентно «бери самый длинный piece, начинающийся в текущей позиции». Эта эвристика близка по результатам к точному алгоритму:
func (t *Tokenizer) segmentBPE(s string) []int {
out := make([]int, 0, len(s)/3)
i := 0
for i < len(s) {
end := min(i + t.maxPieceLen, len(s))
matched := false
for j := end; j > i; j-- {
if id, ok := t.pieceToID[s[i:j]]; ok {
out = append(out, id)
i = j
matched = true
break
}
}
if !matched {
out = append(out, t.byteToID[s[i]]) // byte fallback
i++
}
}
return out
}
Файл | Размер |
|---|---|
weights.bin (50M params, fp16) | 92 МБ |
vocab.json (32k pieces со scores) | 1.8 МБ |
config.json | ~12 КБ |
итоговый exe (после | 97 МБ |
После upx --best сжимается до ~70 МБ.
$ printf '/max_tokens 50\nПривет\n/quit\n' | ./nanogpt-chat.exe
=== nanoGPT chat (Go single-binary) ===
Модель: 10 слоёв, 8 голов, n_embd=512, ctx=1024, vocab=32000
Веса загружены за 189ms
you> Привет
bot> Привет! Я готов помочь вам в достижении ваших целей. Хорошего дня!
[15 токенов за 418ms, 35.9 tok/s]
Сравнение на той же машине, той же модели, том же промпте:
Стек | Скорость |
|---|---|
Python + PyTorch + CUDA (GPU) | ~14 ток/сек |
Python + PyTorch + CPU | не замеряли (~3-5 ток/сек ожидаемо) |
Go + параллельный matVec + CPU | 35.9 ток/сек |
PyTorch-overhead на single-token inference маленькой модели не окупается. Каждый token = десятки kernel-launches, между ними CPU-GPU, которое еще больше тормозит скорость. На 50M-модели это доминирует над собственно вычислениями.
CGO не используется — кросс-сборка тривиальна:
$env:GOOS="linux"; go build -ldflags="-s -w" -o nanogpt-chat-linux .
$env:GOOS="darwin"; go build -ldflags="-s -w" -o nanogpt-chat-mac .
$env:GOOS="windows"; go build -ldflags="-s -w" -o nanogpt-chat.exe .
Все три варианта собираются на любой из платформ.
Только CPU. Нет GPU-ускорения. Для 50M это нормально, для 1B+ уже будет медленно.
fp32 inference. Никакой quantization (int8/int4). Модель в RAM ~190 МБ.
Никакой батчинг. Один user, одна сессия за раз. Для server-сценария пришлось бы делать батч-обвязку.
Sampling — sort.Slice, что O(V·log V). На vocab=32k не критично, но можно ускорить через partial sort.
У модели оставались очевидные проблемы — несмотря на значительно возросшее качество, она не сможет корректно ответить практически на любой вопрос, который ты ей задашь. Очевидное решение — увеличить количество итерации, а так же количество обучающих данных, но мы решили пойти более интересным путем — через обучение с подкреплением. На выбор было два подхода — KTO и DPO.
KTO был выбран вместо DPO, потому что DPO учит модель сравнительному суждению — «X лучше Y». KTO учит абсолютному — «это хорошо / это плохо относительно некоторого уровня». Для нашей задачи абсолютный сигнал точнее: ты не говоришь модели «rejected хуже chosen», ты говоришь «вот эти три паттерна — плохие, точка».
Стратегия: на каждый промпт из train берём chosen (оригинальный high-score ответ из датасета) и rejected (наша SFT-модель сама генерирует с настройками, провоцирующими нужный failure mode).
3 пресета настроек — каждый ловит свой тип ошибок:
preset | temperature | rep_penalty | max_tokens | целевой failure |
|---|---|---|---|---|
loops | 1.20 | 1.00 (выкл) | 400 | token-level зацикливания |
canned | 0.40 | 1.15 | 80 | короткие canned-шаблоны |
tutorial | 0.70 | 1.10 | 500 | длинные markdown-простыни |
По 1000 пар на пресет = 3000 пар суммарно. Сбор занял ~3 часа.
Качество rejected подтвердили проверкой случайных сэмплов:
loops: (13-й.1) Вазнь<EOT> — token salad, бессмыслица
canned: на «начать малый бизнес» → шаблон 1. Сбор информации 2. Анализ данных 3. Создание отчётов без attribution на тему
tutorial: на промпт про neuroplasticity → ### 1. Понимание темы с цитатой английского текста, не отвечает
Все три пресета сработали как задумано — rejected ярко выраженно хуже chosen.
train_kto.py (~280 строк) — собственная имплементация KTO loss поверх nanoGPT.
# Forward (на каждый триплет 4 forward'а):
ref_lp_chosen = sequence_logp(ref, prompt, chosen) # frozen, no_grad
ref_lp_rejected = sequence_logp(ref, prompt, rejected) # frozen, no_grad
pol_lp_chosen = sequence_logp(policy, prompt, chosen) # gradients
pol_lp_rejected = sequence_logp(policy, prompt, rejected)
# Длина-нормированные log-ratios
chosen_lr = (pol_lp_chosen - ref_lp_chosen) / len(chosen)
rejected_lr = (pol_lp_rejected - ref_lp_rejected) / len(rejected)
# KTO loss
z = max(0, z_ref) # ВАЖНО: clamp в [0, +inf) -- эту строчку я забыл в первой версии
L_chosen = 1 - sigmoid(beta * (chosen_lr - z))
L_rejected = 1 - sigmoid(beta * (z - rejected_lr))
loss = lambda_d * L_chosen + lambda_u * L_rejected
# z_ref обновляется как EMA от среднего log-ratio (для diagnostic)
z_ref_ema = 0.99 * z_ref + 0.01 * (chosen_lr + rejected_lr) / 2
Запустили с дефолтными параметрами:
beta = 0.1
learning_rate = 5e-6
max_iters = 2000
batch_size = 1, grad_accum = 16
block_size = 768
Прогресс:
iter 0: loss=0.9997 gap=+0.01 z_ref=-0.001
iter 50: loss=0.9949 gap=+0.20 z_ref=-0.069
iter 100: ?
iter 500: loss=~0.5 gap=+30 z_ref=-15 ← подозрительно
iter 900: loss=0.24 gap=+55 z_ref=-37 val_acc=1.000 ← saved
iter 1500: loss=0.18 gap=+70 z_ref=-40 val_acc=1.000 ← best
iter 1900: loss=0.31 gap=+~70 z_ref=-40 val_acc=0.93 ← деградирует
val_acc=1.000 к iter 900 — модель идеально различает chosen/rejected на val. Но z_ref=-37 — это колоссальный сдвиг: средний log-ratio упал на 37 nats. Это значит модель в среднем сильно опустила likelihood обоих ответов относительно ref-модели.
Тест в chat.py на ckpt с iter 1500 (best val_loss):
you> Привет
bot> - Алексей! Один. Ты знаешь тебе на Земле. Я тебя ещё говорил, ты здесь
тебя зовут тебя,, твой день, Алексей. Конечно. Ты всегда знаешь твои на Земле!
you> Расскажи о себе
bot> — – залог – залог служит основой, что – от государства, государства
– иём государства{, к и,o иё – ко вечно...
[битый UTF-8 (), синтаксис разрушен, фрагменты случайных слов]
Модель полностью разрушена. Она по-прежнему «различает» chosen/rejected на val (acc=1), но она перестала быть ассистентом, и начала нести какой-то странный поэтический бред.
После обеих KTO-попыток модель выдавала не просто бессмыслицу, а бессмыслицу в специфическом стилистическом регистре — псевдо-философском:
you> Привет
bot> Ты знаешь твой особенный душ? Ты хочешь, улыбайся; Ты знаешь твой день,
ты можешь дождён. Вместе? Только всё – мудрость.
you> Расскажи о себе
bot> � —� – залог – залог служит основой, что – от государства, государства
– иём государства{, к� и,o иё – ко вечно...
Обратите внимание: апелляция «ты», эмоционально-окрашенная лексика («мудрость», «вечно», «Земля»), обрывистые образы, неологизмы («иём», «дождён»). Это не случайный мусор — это узнаваемый жанр, на котором модель явно никогда специально не училась. Откуда?
Гипотеза — inverse mode collapse:
В SFT-train примерно 1-2% корпуса — темы writing, literature, language, linguistics. Плюс summarization-задачи иногда содержат отрывки художественных текстов как input. Модель видела этот регистр слабо, но видела.
Наши rejected (canned + tutorial + loops) — это доминирующие моды распределения. KTO давила их вниз.
Распределение остаётся нормированным через softmax. Когда давим частые моды, оставшиеся (редкие) получают относительно больший вес при обучений.
Обычный mode collapse — модель сходится к одному частому паттерну. Тут обратное: мы убили частые паттерны, и модель сошлась в редкие.
Это побочный наглядный пример того что preference learning переписывает не только то на что направлено, но и всё распределение. Узкий KTO-сигнал по трём failure modes неожиданно пересобрал всю генеративную геометрию модели.
Из этого приключения можно сделать несколько интересных выводов.
Языковую модель можно обучить с нуля и превратить в чат-бота исключительно на диалоговом датасете, модель мира у нее будет более бедная чем у модели, которая прошла предварительное обучение на сыром тексте, но в рабочих условиях это можно фиксить через RAG.
В диапазоне между 10 и 50 млн параметров проходит граница, которая позволяет модели начать усваивать человеческое мышление. На 10 млн параметров мы получаем бессвязно бормочущую модель, которая теряет нить разговора спустя пару слов, на 50 млн параметрах получаем модель, которая может генерировать связный текст и даже вести диалог.
На определенном масштабе языковая модель на основе архитектуры трансформеров начинает демонстрировать поведение, которое у людей требует наличия мышления. Даже ошибки, которые модель допускает, похожи на то как ошибаются сами люди, когда пытаются что-то вспомнить. Это вызывает интересные вопросы об их природе.
Hugging Face
Диалоговая модель Mini-Tron-50 - https://huggingface.co/Imperius/mini-tron-50
Собранный мной корпус русской классики и публицистики 19-го века и начала 20-го - https://huggingface.co/datasets/Imperius/ru-classic
Codeberg
Парсер и токенайзер русской классики и публицистики 19го века - https://codeberg.org/imperius/libru-classics-bpe
Код LLM 10 млн параметров, обученной на русской классике, на основе NanoGPT -https://codeberg.org/imperius/nanogpt-chekhov
Код LLM 50 млн параметров, обученной на русской классике, на основе NanoGPT -https://codeberg.org/imperius/nanogpt-ru-classics
Код диалоговой LLM 50 млн параметров, обученной на диалоговом датасете, на основе NanoGPT - https://codeberg.org/imperius/mini-tron-50