Training Tiny Manas: как я собрал маленькую языковую модель на основе эпоса Манас
Аннотация
Несколько лет я строил системы вокруг LLM, но затем решил разобраться в самой модели: от того, как текст превращается в числа, до того, как обратное распространение ошибки меняет миллионы параметров. Первым большим достижением в этом обучении стала Tiny Manas — небольшая языковая модель только с декодером, которую я написал сам и обучил продолжать кыргызский эпос Манас.
Эксперимент начинается с обычного текста и проходит через весь путь современной языковой модели. Мой кыргызский байтовый BPE-токенайзер превращает эпос в токены. Эмбеддинги токенов и позиций создают их внутреннее представление. Восемь блоков Transformer собирают и обрабатывают контекст с помощью Causal Multi-Head Self-Attention и Feed-Forward Networks. LM Head оценивает следующий токен, Cross-Entropy измеряет ошибку, а обратное распространение ошибки и AdamW постепенно изменяют веса.
Итоговая версия Tiny Manas содержит 26 877 696 параметров и работает с контекстом длиной до 256 токенов. Я обучил её с нуля на 465 069 токенах одной редакции Манаса, используя M5 MacBook Pro с 16 ГБ объединённой памяти. Полное обучение заняло 24,4 минуты. Увеличение модели с 13,2 до 26,9 миллиона параметров снизило перплексию на валидационной выборке на 25,4%, а на тестовой — на 23,1%. Более очевидная гипотеза не сработала: удвоение контекстного окна замедлило обучение и ухудшило результаты.
Tiny Manas научилась воспроизводить имена, ритм, обращения и локальные цепочки действий из эпоса, но всё ещё повторяется, создаёт сломанные слова и со временем теряет сюжет. Поэтому эта статья не представляет модель как кыргызский аналог ChatGPT. Она последовательно объясняет, как устроена языковая модель, как я проверял каждую часть реализации, где поймал ошибку измерения, как увидел переобучение и почему итоговый результат оказался именно таким.
Часть I. Как работает языковая модель
Сначала мы соберём всю теорию в одну понятную цепочку: как обычный текст превращается в токены, откуда у токенов появляются внутренние числовые представления, как Transformer работает с контекстом и каким образом модель учится предсказывать продолжение. Это фундамент, без которого цифры моего эксперимента дальше были бы просто красивой таблицей.
1. Зачем вообще лезть внутрь LLM
Я уже несколько лет плотно работаю с искусственным интеллектом: начинал с простых чат-ботов, затем дошёл до сложных агентских систем, в которых несколько моделей пользуются инструментами, хранят состояние и выполняют длинные рабочие процессы. Но в какой-то момент я поймал себя на неприятной мысли. Я умел строить всё больше вещей вокруг больших языковых моделей, или LLM, но не мог честно сказать, что понимаю саму модель изнутри.
Неприятное открытие, если честно. Снаружи ты уже собираешь довольно сложные системы, а внутри самой модели всё ещё туман. Меня это не устроило.
Чтобы развиваться в любой области, мало двигаться только в ширину. Иногда нужно остановиться и начать копать вниз: разобраться в математике, пройти руками через механику и понять, почему система вообще работает. Поэтому значительную часть свободного от работы времени я посвятил изучению устройства LLM. Я отдельно разбирал токенизацию, эмбеддинги, обратное распространение ошибки, Attention и остальные детали, которые обычно скрываются за одним вызовом API.
В какой-то момент разрозненные детали наконец сложились в достаточно устойчивую картину. Я дошёл до первого настоящего достижения: смог написать и обучить собственную языковую модель. Не большую LLM уровня ChatGPT, конечно, а малую языковую модель. Она называется Tiny Manas и учится продолжать кыргызский эпос Манас.
Эта статья нужна мне не только как отчёт о результате. Это попытка собрать всё, что я понял, в одну последовательную историю и передать её так, чтобы суть понял даже читатель без технического образования. Технические детали здесь будут. Но сначала у каждой детали появится причина: какую проблему мы встретили, зачем вообще понадобилось её решать и только затем как именно работает решение.
2. Что такое языковая модель
Начнём с самого простого определения. Если совсем просто, языковая модель — это предсказатель следующего фрагмента текста. Она получает начало текста и пытается продолжить его. Мы пишем:
Манас поднял свой ...
а модель должна решить, какой фрагмент текста вероятнее всего идёт следующим. Может быть, меч. Может быть, голос. Может быть, что-то совершенно другое.
Если убрать всю магию, работа над такой моделью состоит из трёх больших частей:
- подготовить текст и превратить его в числа, с которыми умеет работать компьютер;
- обучить модель находить закономерности в этих числах;
- использовать обученную модель для генерации нового текста.
Последняя часть называется инференсом, или генерацией. Во время инференса модель уже не учится. Она просто использует то, чему научилась раньше, и один за другим предсказывает новые фрагменты текста.
Но что значит «обучить модель»? Представьте, что у неё есть огромное количество крошечных мышц. В реальности эти мышцы называются весами, или параметрами, и каждая из них является обычным числом. Количество параметров задаётся архитектурой ещё до тренировки. Если мы построили модель на 26,9 миллиона параметров, после обучения их не станет больше или меньше. Изменятся только значения этих 26,9 миллиона чисел. И выглядят они примерно так:
Сразу после создания параметры почти случайны и не согласованы друг с другом. Модель уже способна что-то посчитать, но результат напоминает попытку новорождённого управлять всеми мышцами одновременно.
Во время процесса тренировки модель делает предсказание, узнаёт размер своей ошибки и немного изменяет параметры так, чтобы в следующий раз ошибиться меньше. Один такой шаг почти ничего не меняет. Но после тысяч шагов в числах появляется структура.
Здесь, как и в тренировке тела, есть две крайности. Если занятий слишком мало, получится недообучение: модель не успела выучить даже основные закономерности. Если слишком долго заставлять её повторять один и тот же ограниченный материал, получится переобучение: тренировочный текст она знает почти наизусть, а на новом ломается. Это как человек, который идеально выучил ответы к одному варианту экзамена, но не понял предмет. Или качал только жим лёжа, а присесть не может и пяти раз. В идеале мы так не тренируемся.
Поэтому текст обычно делят на три части. Обучающая выборка нужна для изменения параметров. Валидационную выборку модель видит только как контрольную работу: по ней мы выбираем удачные решения и момент остановки. Тестовую выборку оставляем до конца для итоговой честной проверки. Если ошибка на обучающей выборке продолжает падать, а на валидационной растёт, модель не становится умнее. Она начинает заучивать.
Когда обучение закончено, начинается инференс. Если тренировка была долгой подготовкой спортсмена, инференс похож на один выход на соревнование. Мышцы уже не перестраиваются. Модель просто использует накопленную способность.
3. Как текст становится числами
Компьютер не читает слово Манас так, как его читаем мы. Для алгоритма это не герой эпоса и даже не слово. На самом нижнем уровне компьютер хранит только биты: нули и единицы. Вы наверняка видели в фильмах, как хакеры взламывают Пентагон, а по экрану летят нули и единицы. Вот тут в кино есть хоть немного правды. Например, латинское слово Manas в UTF-8 можно показать так:
01001101 01100001 01101110 01100001 01110011
Каждая группа из восьми битов называется байтом. Ту же самую информацию можно записать не двоичными строками, а обычными числами от 0 до 255:
77 97 110 97 115
Длинные цепочки из нулей и единиц трудно читать, поэтому каждую группу из восьми битов мы будем показывать одним обычным числом от 0 до 255. Например, 01001101 и 77 — это две записи одного и того же байта. Информация не изменилась, мы просто записали её короче и понятнее. Поэтому Manas вместо сорока нулей и единиц теперь выглядит как пять понятных чисел: 77 97 110 97 115. Ура, слово превратилось в числа! Можно ли теперь сразу кормить ими модель? Технически да. Но тут же появляется следующая проблема.
Технически эти байты уже можно подавать модели. Но вы заметили, что даже короткое слово Manas занимает целых пять позиций и состоит из пяти отдельных чисел? Кыргызское слово может занять ещё больше. Чем длиннее последовательность, тем больше работы предстоит проделать модели в будущем и тем быстрее заполняется её ограниченное контекстное окно. Это фиксированное количество токенов, с которыми модель может работать одновременно. Если одно короткое слово занимает много токенов, в это окно поместится меньше полезного текста: меньше слов, предложений и контекста.
Получается, нам нужно как-то сжать эту длинную последовательность: сделать её короче, но не потерять исходный текст. И вот тут на сцену выходит токенайзер. Знакомое слово, да? «Токены» мы сейчас слышим постоянно, но что такое токен на самом деле? Это минимальный фрагмент, которым оперирует языковая модель. Он не обязан быть словом. Токеном может быть целое слово, часть слова, знак препинания или один байт. Спойлер: каким именно будет токен, решает сам токенайзер.
Как вы уже могли догадаться, токенайзер — это своеобразный переводчик между нами и языковой моделью. По определённым правилам он превращает наш обычный текст — слова, буквы, пробелы и знаки препинания — в последовательность токенов, то есть чисел, с которыми может работать модель. А когда модель заканчивает генерацию, токенайзер делает обратный перевод и снова собирает эти числа в понятный нам текст.
Например, один токенайзер может представить Hello как один токен, а другой — как Hel + lo. Оба варианта декодируются обратно в тот же текст, но длина последовательности получится разной. Для модели это важно: она считает не слова и не буквы, а именно токены.
3.1. Почему я использовал байтовый BPE
Такс, теперь мы понимаем задачу: длинную последовательность байтов нужно как-то сжать. Но как именно это сделать? Для этого существует несколько разных алгоритмов. В своём токенайзере я использовал один из самых известных — Byte Pair Encoding, или BPE. Название звучит серьёзнее самой идеи. Алгоритм ищет части текста, которые часто стоят рядом, и склеивает их в новый токен.
Возьмём байты слова Manas:
77 97 110 97 115
Допустим, пара 77 97, то есть Ma, часто встречается в корпусе. Числа от 0 до 255 уже зарезервированы под все возможные байты, поэтому первый новый токен получает идентификатор 256. Почему именно 256? Потому что все предыдущие места уже заняты. Если пока не уловили логику, не парьтесь. Просто запомните: все новые токены начинаются с 256.
новый токен: 256 = [77, 97] вместе = Ma
Теперь в исходной последовательности мы заменяем два соседних числа 77 97 одним новым токеном 256:
было: 77 97 110 97 115
стало: 256 110 97 115
Видите? Информация не исчезла: токен 256 по-прежнему означает Ma. Но сама последовательность стала короче — теперь в ней четыре токена вместо пяти.
Дальше повторяем тот же процесс. Если сочетание 256 110, то есть Man, тоже часто встречается в тексте, создаём ещё один токен:
новый токен: 257 = [256, 110] вместе = Man
И снова заменяем два соседних токена одним:
было: 256 110 97 115
стало: 257 97 115
Теперь от исходных пяти токенов осталось только три. Если слово Manas встречается достаточно часто, BPE может продолжить объединения и со временем представить его ещё короче.
Но откуда BPE вообще знает, какие пары встречаются часто? Его тоже нужно обучить. Да-да, токенайзер тоже обучается, хотя нейронной сетью он не является. Он берёт большой текстовый корпус, считает все соседние пары, объединяет самую частую, пересчитывает пары и повторяет процесс, пока словарь не достигнет заданного размера. Частые слова и части слов постепенно становятся крупными токенами. Редкие слова остаются составленными из небольших частей.
Главное преимущество байтового подхода в том, что неизвестного текста для него практически не существует. Если BPE никогда не видел Avada Kedavra, он в худшем случае разложит строку на байты и всё равно сможет её прочитать. Не очень компактно, зато никакой Волан-де-Морт его не сломает. Вот и вся основная идея BPE: часто встречается вместе, значит, склеиваем вместе. Ей! Мы молодцы!
До Tiny Manas я уже написал собственный кыргызский байтовый BPE-токенайзер и опубликовал реализацию на GitHub. В том проекте я сам реализовал обучение, кодирование и декодирование, собрал корпус и сравнил несколько вариантов. Для Tiny Manas я зафиксировал один конкретный артефакт kyrgyz-byte-bpe-v1 со словарём из 32 768 токенов. Это значит, что к 256 исходным байтовым токенам BPE во время обучения добавил ещё 32 512 новых токенов: часто встречающиеся пары, части слов и целые слова.
Также я не менял токенайзер между экспериментами, иначе не смог бы понять, улучшилась сама модель или просто изменился способ упаковки текста.
Теперь обычная строка превращается в короткую последовательность идентификаторов. Например:
Манас кыргыз элинин баатыры
↓
[2499, 772, 3953, 15726]
Для нас это четыре фрагмента текста. Для модели это четыре адреса в словаре. Наконец у нас появился язык, на котором с ней можно разговаривать.
4. На чём учить Tiny Manas
А дальше начинается самое интересное. Токенайзер мы сделали, текст в числа превращать умеем. Можно тренировать языковую модель? Ещё нет. Знаете почему? Нам по-прежнему не на чем её обучать. Теперь нужен набор данных, то есть реальный текст, закономерности которого она будет искать.
В теории можно взять что угодно. Хотим модель в стиле Гарри Поттера, обучаем её на книгах о Гарри Поттере. Правда, текста там настолько мало, что модель скорее выучит отдельные имена и обороты, чем приобретёт широкое понимание языка.
В англоязычных учебных проектах часто используют Tiny Shakespeare: один небольшой корпус, узнаваемый стиль и результат, который легко оценить на слух. Есть и более буквальные эксперименты. Автор Vintage-LLM собрал тексты, опубликованные до 1900 года, чтобы получить исторически ограниченную модель. Спойлер: первая версия на 14 миллионов параметров в основном выдавала викториански звучащую бессмыслицу. Когда автор увеличил и данные, и модель, стало лучше. Эпоха не отменяет простого закона: мало данных плюс маленькая модель дают маленькие способности.
Моей целью не было создать эксперта по всей кыргызской культуре или конкурента современным LLM. Я хотел собственными руками пройти весь путь через Transformer и получить хотя бы локально осмысленную генерацию. Поэтому мне был нужен узкий, цельный и интересный мир. Кыргызский эпос Манас оказался естественным аналогом Tiny Shakespeare.
Я использовал Manas01 из корпуса Manas-UdS, записанный в исполнении Саякбая Каралаева и распространяемый по лицензии CC BY-NC-SA 4.0. Исходный цифровой документ начинался примерно с 30 тысяч символов научного введения. В раннем эксперименте с биграммной моделью я случайно оставил эту часть и получил модель, которая вместе с эпосом учила академическую лексику. В Tiny Manas я удалил 30 877 вводных символов и начал текст с заголовка Манастын туула элегиндеги бабалары.
После очистки осталось:
- 1 794 194 символа;
- 3 249 652 байта в UTF-8;
- 465 069 BPE-токенов;
- 9 593 разных идентификатора токенов, которые реально встретились в эпосе.
Я сохранил порядок повествования и разделил последовательность хронологически: 418 562 токена для обучения, 23 253 для валидации и 23 254 для тестирования. Если бы я случайно перемешал соседние куски, модель могла бы тренироваться прямо рядом с проверочным отрывком. Результат выглядел бы лучше, но проверка была бы нечестной.
5. Чему именно учится языковая модель
Такс. У нас есть чистый эпос и его токены. Но какую задачу мы вообще дадим модели? Оказывается, самую простую: предсказать следующий токен.
Пусть после токенизации получилась последовательность:
Манас | көтөрдү | өз | кылычын
Из неё автоматически создаются и вопросы, и правильные ответы:
вход: Манас | көтөрдү | өз
ответ: көтөрдү | өз | кылычын
Правильные ответы являются той же последовательностью, просто сдвинутой на одну позицию вперёд. После Манас правильный ответ көтөрдү. После көтөрдү правильный ответ өз. После өз правильный ответ кылычын.
Звучит подозрительно просто, да? Но чтобы постоянно хорошо угадывать следующий токен, модели приходится выучить структуру языка, частые факты, стиль, персонажей и зависимости между далёкими словами. Мы не программируем понятие героя или меча вручную. Мы просто много раз спрашиваем: «Что идёт дальше?» Всё остальное модель вынуждена открыть как полезный способ отвечать на этот вопрос.
6. Эмбеддинги токенов: как идентификатор получает внутренний смысл
Окей, данные готовы, задача тоже. Теперь можно переходить к самому соку. Или почти можно. Сначала нужно решить ещё одну проблему: идентификатор токена сам по себе ничего не значит. Число 2499 не ближе к Манасу по смыслу, чем 2500. Это всего лишь адрес. Нам нужно дать каждому токену внутреннее числовое представление, которое модель сможет менять во время обучения.
Для этого создаётся таблица эмбеддингов токенов. В Tiny Manas у неё 32 768 строк, по одной на каждый токен словаря, и 384 числа в каждой строке. Если токен Манас условно имеет идентификатор 2499, обращение к таблице возвращает его текущий вектор:
2499 → [0.10, 1.20, 0.70, -0.50, ... ещё 380 чисел]
Изначально это почти случайные числа. Во время обучения они меняются вместе со всеми остальными весами. Постепенно токены, которые используются в похожих ситуациях, получают полезные внутренние свойства. Не стоит думать, что одно число буквально означает «герой», а другое «оружие». Смысл распределён по всему вектору и понятен прежде всего следующим слоям самой модели.
Если вернуться к аналогии с мышцами, каждая строка таблицы эмбеддингов является первым набором обучаемых мышц конкретного токена. Но одного смысла недостаточно. Модель ещё должна знать, где токен находится. Получается, что нам нужна ещё одна таблица…
7. Эмбеддинги позиций: одинаковые слова на разных местах
Механизм внимания модели, который мы скоро разберём, сам по себе не знает порядка токенов. Для него набор слов без дополнительной информации похож на людей за круглым столом без нумерации мест. Поэтому к смысловому вектору токена добавляется второй вектор, описывающий позицию.
Tiny Manas использует обучаемую таблицу эмбеддингов позиций. Её размер равен не словарю, а максимальному контексту: 256 позиций, и для каждой тоже хранится 384 числа.
числа, описывающие токен «Манас»
+ числа, описывающие третье место
= один новый вектор: «Манас находится на третьем месте»
Эмбеддинг токена говорит модели: «что это примерно за фрагмент». Эмбеддинг позиции добавляет: «где он находится в текущем окне». Поэтому одинаковый токен на первой и на сотой позиции получает разное итоговое представление.
Число 256 называется размером блока, или размером контекста модели. Это максимум токенов, которые Tiny Manas может одновременно использовать для одного предсказания. Если текущий фрагмент короче, его фактическая длина обычно обозначается T. Значит, T может быть от 1 до 256, а размер блока остаётся архитектурным потолком.
После сложения эмбеддингов токенов и позиций мы получаем X формы (B, T, 384), где B означает количество независимых последовательностей в одном батче, T — их текущую длину, а 384 — размер вектора каждого токена. Вот с этим X мы наконец входим в Transformer.
8. Transformer: место, где токены получают контекст
Что вообще такое Transformer? Нет, это не Оптимус Прайм, хотя с первого взгляда схема выглядит не менее устрашающе.
Transformer — основная вычислительная часть Tiny Manas. Он состоит из восьми почти одинаковых блоков, поставленных друг за другом. Представление текста проходит через первый блок, его результат отправляется во второй, затем в третий и так далее. Каждый блок делает две большие вещи: сначала позволяет токенам собрать информацию друг у друга через Causal Multi-Head Self-Attention, а затем обрабатывает собранное внутри Feed-Forward Network.
Слово он само по себе почти бесполезно. Но в строке Манас поднял меч, и он... его представлению нужна информация о предыдущем тексте. После прохождения через Transformer токен уже описывает не только самого себя, но и тот контекст, в котором оказался.
Под контекстом я буду понимать предыдущие токены, доступные модели в текущем окне. Tiny Manas может видеть до 256 токенов. Это не значит, что она понимает весь эпос сразу. При генерации длинного текста старые токены постепенно выпадают за левую границу окна.
Все восемь блоков устроены одинаково, поэтому разбирать каждый по отдельности нет смысла. Давайте возьмём один Transformer-блок, условно разрежем его и посмотрим, что именно происходит внутри. Здесь начинается самая насыщенная часть всей модели. Так что, как писал Данте Алигьери: «Оставь надежду, всяк сюда входящий».
9. LayerNorm: держим числа в рабочем диапазоне
Первым внутри Transformer-блока нас встречает LayerNorm. Не пугайтесь названия: его задача довольно простая. Он следит, чтобы 384 числа, представляющие каждый токен, оставались в удобном для модели диапазоне.
Представьте звукорежиссёра, который получает запись с очень громкими и очень тихими фрагментами. Перед дальнейшей обработкой он приводит громкость к нормальному рабочему уровню. Музыка от этого не меняется — с ней просто становится удобнее работать.
То же самое делает LayerNorm с числами. Он не превращает их в одинаковые и не стирает смысл токена. Самое большое значение всё ещё остаётся самым большим, а самое маленькое — самым маленьким. LayerNorm лишь переносит весь набор в более удобную «весовую категорию», чтобы модель обучалась стабильнее.
На этом всё. LayerNorm — это небольшая подготовка чисел: с ними легче проводить следующие вычисления, а параметры модели обучаются стабильнее, потому что им не приходится постоянно подстраиваться под резко меняющийся масштаб.
10. Self-Attention: как токен решает, на кого посмотреть
Так, вы всё ещё со мной? Тогда прошу обратить внимание. Каламбур полностью намеренный, потому что сейчас начинается самая важная часть Transformer: Self-Attention, или механизм внимания.
До этого каждый токен знал свой смысл и своё место в предложении, но почти ничего не знал о соседях. Возьмём слово он. Само по себе оно мало что говорит. Но в строке Манас поднял меч, и он... модели нужно оглянуться назад и понять, с кем или с чем связано это слово. Именно такую возможность даёт Attention.
Механизм внимания строится вокруг трёх свойств: Query, Key и Value. Это три всадника апокалипсиса наша святая троица.
Представим, что этими свойствами обладает каждый токен в текущем предложении.
Query означает: «Какую информацию я сейчас ищу у других токенов?»
Key означает: «Вот какую информацию я могу предложить». Но Key содержит не саму информацию, а только этикетку на ней.
Value содержит уже саму полезную информацию, которую другой токен сможет забрать.
Можно представить обычную посылку. Key похож на наклейку с описанием содержимого: по ней мы решаем, нужна ли нам эта коробка. Value лежит уже внутри. Сначала мы читаем этикетку, и только если она подходит нашему запросу, распаковываем посылку и забираем содержимое.
Такс, со свойствами разобрались. Теперь посадим токены за круглый стол.
За столом находятся не все 32 768 токенов словаря и не весь эпос. Там сидят только токены из текущего фрагмента текста. Например:
Манас | поднял | свой | меч
Каждый токен показывает остальным свой Query и свой Key. Условно токен свой говорит: «Вот какую информацию я ищу». Токены Манас, поднял и даже сам свой отвечают: «А вот что могу предложить я».
Затем модель сравнивает запрос свой с этикеткой каждого доступного токена. Если Query хорошо подходит к Key, связь получается сильной. Если они почти не подходят друг другу, связь получается слабой. Так каждый токен постепенно решает, на кого за круглым столом ему стоит обратить больше внимания.
На языке математики весь этот разговор записывается коротко:
Q @ Kᵀ
Не пугайтесь этой записи. Она означает только одно: «Сравни Query каждого токена с Key каждого другого доступного токена». Результатом становятся внутренние оценки связи. Чем выше оценка, тем полезнее один токен кажется другому.
10.1. Causal mask: закрываем ответы на контрольной
Но тут возникает серьёзная проблема. Во время обучения всё предложение уже загружено в компьютер целиком. Когда модель находится на слове свой, справа уже лежит правильный ответ меч. Без ограничений она могла бы просто посмотреть направо и сказать: «А, ну понятно. Следующий токен будет меч. Легчайшая тренировка в моей жизни».
Но это не обучение, а обычное списывание. Примерно как решать контрольную, когда перед тобой уже лежат правильные ответы.
Поэтому мы закрываем все будущие токены специальной шторкой, которая называется causal mask. Для токена свой открыты только:
Манас | поднял | свой
А смотреть сюда уже нельзя:
меч
Получается, каждый токен может смотреть на себя и на всё, что было слева, но не может заглядывать вправо. Именно поэтому механизм называется causal, а не casual: он сохраняет правильный порядок причины и следствия.
10.2. Softmax: распределяем 100% внимания
После круглого стола у нас есть внутренние оценки связи. Но сами по себе они мало что говорят. Что значит оценка 2.0? Это много или мало? И тут появляется Softmax.
Представьте, что у токена свой есть ровно 100% внимания, которое нужно распределить между доступными участниками разговора. Softmax превращает непонятные оценки в понятные доли. Например:
Манас: 67%
поднял: 24%
свой: 9%
меч: 0% ← закрыт causal mask
Теперь всё намного понятнее. Токен свой решил, что больше всего полезной информации может получить от Манас, немного меньше от поднял, ещё меньше от самого себя и ничего от будущего слова меч.
Эти проценты называются весами внимания. Это ещё не вероятности следующего токена. Они лишь показывают, сколько информации нужно взять у каждого доступного участника круглого стола.
10.3. Value: наконец забираем информацию
Такс. Мы уже нашли подходящие токены и решили, кому сколько внимания достанется. Но саму полезную информацию пока не получили. До этого мы только читали этикетки на коробках.
И вот тут наконец появляется Value.
Query помог сформулировать, что мы ищем. Key помог найти подходящие источники. Softmax решил, сколько внимания выделить каждому из них. А Value содержит саму информацию, которую мы теперь забираем.
В нашем примере токен свой возьмёт 67% информации из Value токена Манас, 24% из поднял, 9% из собственного Value и ничего из будущего меч. Затем вся полученная информация смешивается в один новый вектор.
Теперь токен свой больше не находится в вакууме. Внутри его представления появилась информация о том, что раньше был Манас и что он что-то поднял. Токен посмотрел на доступный контекст, решил, кто для него важен, забрал нужную информацию и обновил самого себя. Вот это и есть Self-Attention.
Важно: Query, Key и Value не приклеиваются к слову Манас навсегда. Они создаются заново для каждого конкретного предложения. В другом контексте токен Манас будет искать и предлагать уже другую информацию.
11. Зачем Attention несколько голов
До сих пор я рассказывал про Attention так, будто за круглым столом работает один наблюдатель. Но одному наблюдателю сложно одновременно заметить все возможные связи в тексте.
Представьте расследование, которым занимается один детектив. Он может понять, кто совершил действие, но не заметить, кому принадлежал предмет. Или хорошо разобраться в соседних словах, но пропустить важную улику в самом начале текста.
Поэтому Tiny Manas использует не одного, а восемь детективов. Каждый получает один и тот же текст целиком, но рассматривает его по-своему. Один может научиться чаще замечать действующих лиц, другой — связи между предметами, третий — более далёкий контекст. Мы не назначаем эти роли вручную. В начале обучения все детективы почти ничего не понимают, а полезные способы смотреть на текст находят постепенно.
Каждый такой детектив называется головой внимания, или Attention Head. Внутри каждой головы отдельно проходит весь уже знакомый процесс: Query сравнивается с Key, causal mask закрывает будущее, Softmax распределяет внимание, а Value передаёт информацию.
В конце каждая голова пишет небольшой числовой отчёт о том, что она заметила. Но выбирать самого умного детектива не нужно. Модель собирает отчёты всех восьми голов вместе и смешивает их в одно обновлённое представление токена.
Так и получается Causal Multi-Head Self-Attention. Self означает, что токены общаются внутри одной последовательности. Multi-Head означает, что на неё одновременно смотрят несколько голов. Causal означает, что ни одна из них не может заглянуть в будущее.
12. Остаточные связи: не заставляем каждый слой переписывать всё
После Attention токен получает новую информацию из контекста. Но если полностью заменить ею всё, что токен знал раньше, часть полезной информации может потеряться.
Поэтому модель сохраняет исходное представление токена и просто добавляет к нему новые сведения. Это называется Residual Connection, или остаточной связью.
Представьте документ, который редактор не переписывает с нуля. Он сохраняет исходный текст и добавляет к нему свои замечания. Даже если какое-то замечание оказалось бесполезным, сам документ никуда не исчез.
С токеном происходит то же самое:
что токен знал раньше
+ что он узнал сейчас
= обновлённое представление токена
Так каждый Transformer-блок не создаёт представление токена заново, а понемногу дополняет уже существующее.
13. Feed-Forward Network: время обдумать собранное
Помните наш круглый стол? Во время Attention токен пообщался с предыдущими токенами и собрал от них нужную информацию. Например, он узнал, что в предложении есть Манас, рядом происходит какое-то действие и где-то упоминается меч.
Но пока это просто набор разрозненных заметок. Attention помог их собрать, но не помог нормально обдумать. И вот тут появляется Feed-Forward Network, или FFN.
Представьте скамейку, на которую токен садится после большого собрания. Он снимает с плеч тяжёлый рюкзак. Внутри лежат все заметки, которые он только что получил от других токенов.
Токен открывает рюкзак и раскладывает заметки перед собой на большой поляне. Теперь их можно соединять между собой и проверять разные предположения:
«Если здесь упоминается Манас, а рядом находится действие, возможно, это действие совершает именно Манас».
«Если после действия появляется меч, возможно, Манас его поднял, взял или использовал».
Конечно, модель не формулирует такие мысли человеческими словами. Она по-прежнему работает только с числами. Но смысл примерно такой: FFN смешивает уже собранную информацию разными способами и ищет в ней полезные сочетания.
Чтобы токену было где разложить все возможные варианты, мы как будто даём ему намного больше свободного пространства. Маленький рюкзак превращается в большую поляну. На ней можно проверить намного больше сочетаний.
Но тут появляется новая проблема. Некоторые предположения могут быть полезными, а другие окажутся полной ерундой. Поэтому рядом на скамейку садится ещё один персонаж: внутренний критик.
Полезный сигнал он пропускает дальше почти целиком. Слабый приглушает. А если пока не уверен, как будто говорит: «Ладно, звучит подозрительно, но полностью выбрасывать пока не будем».
После этой проверки токен собирает оставшиеся выводы, складывает их обратно в рюкзак прежнего размера и отправляется дальше по Transformer. Вот и весь смысл Feed-Forward Network в нашей маленькой сцене.
Теперь коротко переведём её на язык чисел. Изначальный рюкзак токена состоит из 384 чисел. Первый линейный слой смешивает их и расширяет до 1536 чисел. Это наша большая поляна.
Затем 1536 чисел проходят через функцию GELU. Это и есть внутренний критик: одни сигналы она пропускает почти целиком, другие ослабляет, а некоторые почти обнуляет.
Второй линейный слой собирает результат обратно в 384 числа. Токен пришёл с рюкзаком из 384 чисел и должен уйти с рюкзаком такого же размера. Просто теперь внутри лежит уже обработанная информация.
384 → Linear 1 → 1536 → GELU → Linear 2 → 384
Получается простое разделение обязанностей. Attention отправляет токен на собрание, где он получает информацию от других токенов. FFN сажает его на скамейку, помогает спокойно обдумать услышанное и упаковать выводы обратно в рюкзак.
14. Dropout: иногда полезно временно отобрать часть заметок
Но перед тем, как отправить собранный рюкзак дальше, токен встречает ещё одного персонажа. Это Dropout.
Во время обучения Dropout случайно вытаскивает из рюкзака часть готовых заметок и временно выбрасывает их. В Tiny Manas Dropout равен 20%. Это значит, что примерно 20% чисел на каждом шаге превращаются в нули.
Звучит как вредительство. Модель только что столько думала, а мы берём и выкидываем часть её выводов. Но делаем мы это специально, чтобы она не привыкала всегда опираться на одни и те же признаки. Сегодня исчезли одни заметки, на следующем шаге исчезнут другие. Поэтому модели приходится искать разные способы прийти к правильному ответу. Так она меньше заучивает тренировочный текст.
На языке чисел Dropout создаёт случайную маску из нулей и единиц и накладывает её на содержимое рюкзака:
вектор: [0.4, -0.2, 0.7, 0.1]
маска: [1, 0, 1, 0]
результат: [0.4, 0, 0.7, 0]
Нули означают, что эти заметки временно исчезли. Маска каждый раз создаётся заново, поэтому модель не знает заранее, на какую информацию сможет опереться. Оставшиеся числа немного увеличиваются, чтобы общий уровень сигнала не стал слабее.
Когда обучение заканчивается и начинается обычная генерация текста, Dropout выключается. Модель открывает весь рюкзак и использует уже все свои заметки.
После этого токен переходит в следующий Transformer-блок, где весь путь повторяется снова: собрание Attention, скамейка FFN и новый Dropout.
15. LM Head: превращаем внутренние мысли в кандидатов
Такс. Наша модель прошла через восемь блоков Transformer, пообщалась с предыдущими токенами через Attention, посидела на скамейке внутри FFN и собрала все выводы обратно в рюкзак из 384 чисел. Но есть одна проблема: она до сих пор не сказала нам ни одного слова.
В этих 384 числах уже есть информация о самом токене, его месте и доступном контексте. Но это всё ещё не текст. Нужен переводчик из внутреннего пространства модели обратно в словарь.
Этим занимается последний линейный слой под названием Language Model Head, или LM Head. Представьте конкурс, в котором участвуют все 32 768 токенов словаря. LM Head смотрит на итоговый вектор текущей позиции и выдаёт каждому кандидату один балл:
Манас: 1.2
меч: 4.7
поднял: -0.8
и: 2.1
Эти баллы называются логитами. Чем больше логит, тем сильнее модель сейчас склоняется к соответствующему токену. Но число 4.7 не означает вероятность 47%. Пока это просто внутренние оценки.
Математически LM Head выполняет одно преобразование:
384 числа → 32 768 логитов
В Tiny Manas веса LM Head связаны с таблицей эмбеддингов токенов. Этот приём называется weight tying, или связыванием весов. Одна и та же матрица помогает в начале переводить идентификаторы токенов во внутренние векторы, а в конце — оценивать токены словаря. Так модель использует параметры экономнее.
Здесь дорога разделяется. Во время обучения логиты сравниваются с правильными ответами. Во время инференса по ним выбирается следующий токен.
16. Обучение: как одна ошибка меняет 26,9 миллиона чисел
Вернёмся к сдвинутым правильным ответам:
вход: Манас | поднял | свой
ответ: поднял | свой | меч
Для каждой позиции LM Head выдаёт 32 768 логитов. Функция Cross-Entropy смотрит, какую вероятность модель отвела правильному следующему токену.
Можно представить Cross-Entropy как строгого учителя. Он смотрит не только на то, ошиблась модель или нет, но и на то, насколько уверенно она ошиблась. Если модель немного сомневалась, наказание будет умеренным. Но если она отвела правильному ответу один процент, учитель вполне справедливо скажет: «Ты не просто ошиблась. Ты ещё и была абсолютно уверена в этой ерунде».
Внутри Cross-Entropy применяется Softmax, который превращает логиты в вероятности. Затем берётся вероятность правильного токена и считается её отрицательный логарифм:
вероятность правильного токена = 0.90 → ошибка ≈ 0.105
вероятность правильного токена = 0.10 → ошибка ≈ 2.303
вероятность правильного токена = 0.01 → ошибка ≈ 4.605
Cross-Entropy наказывает модель не только за ошибку, но и за уверенность в ней. Если правильному ответу достался 1%, штраф намного больше, чем при 10%. Ошибки всех позиций во всех последовательностях батча усредняются в одно число, которое называется loss, или общей ошибкой. Чем оно меньше, тем лучше текущие предсказания.
Но если на выходе остаётся одно число, как модель понимает, какой из миллионов параметров виноват? Дело в том, что во время прямого прохода наша реализация сохраняет граф вычислений: историю того, какие операции и параметры повлияли на каждую локальную ошибку, а затем на их среднее значение.
Затем начинается обратное распространение ошибки, или backpropagation. Оно проходит по всей истории вычислений в обратную сторону и определяет, как каждый параметр повлиял на итоговую ошибку.
Градиент отвечает на вопрос: «Если немного изменить именно этот параметр, как изменится общая ошибка?» Так каждый параметр получает небольшую подсказку, в какую сторону ему нужно сдвинуться. Затем модель обновляет параметры с учётом этих подсказок.
Затем модель получает новый батч, и весь путь повторяется:
текст → токены → эмбеддинги → блоки Transformer
→ LM Head → логиты → Cross-Entropy → ошибка
→ обратное распространение → обновление параметров
Так почти случайные параметры постепенно превращаются в систему, способную продолжать текст.
17. Инференс: учитель ушёл домой
После обучения правильных ответов рядом больше нет. Учитель действительно ушёл домой, и модель осталась одна. Она получает начало текста, строит логиты для последней позиции и должна сама выбрать продолжение.
Softmax превращает логиты в вероятности:
меч: 70%
голову: 15%
голос: 8%
руку: 4%
остальные: 3%
Можно всегда брать самый вероятный токен, но такой текст быстро становится однообразным. Поэтому следующий токен обычно выбирается случайно, но с учётом получившегося распределения.
У генерации есть множество настроек, которые влияют на выбор следующего токена. Но для начала достаточно понять две самые базовые: температуру и top-k.
Параметр температуры управляет тем, насколько острым будет это распределение. Низкая температура делает лидеров ещё вероятнее: текст становится осторожнее, но чаще повторяется. Высокая температура даёт больше шансов редким вариантам: разнообразия становится больше, ошибок тоже.
Параметр top-k оставляет перед выбором только k самых вероятных кандидатов и отсекает длинный хвост почти невозможных вариантов.
Допустим, модель выбрала токен меч. Мы добавляем его к исходной строке:
Манас поднял свой меч
Обновлённая последовательность снова проходит через модель, и та предсказывает ещё один токен. Потом ещё один. Потом ещё один. Вся генерация является повторением одного действия: предсказать токен, дописать его, снова предсказать. Когда текст становится длиннее 256 токенов, Tiny Manas продолжает видеть только последние 256.
Часть II. Как я построил и обучил Tiny Manas
До этого момента мы разбирали языковую модель в теории: брали каждую деталь отдельно, выясняли, какую проблему она решает, и постепенно собирали всю цепочку в голове. Теперь теория заканчивается. Дальше начинается мой конкретный эксперимент: какую модель я написал, как проверил, что она действительно учится, какие версии обучил на Манасе, какие гипотезы провалились и что в итоге получилось на одном Mac.
1. Архитектура Tiny Manas
Перед тем как строить дом, нужен чертёж. С моделью то же самое. Мы уже знаем, из каких деталей состоит Transformer, но теперь нужно зафиксировать их реальные размеры и соединить в одну работающую систему.
Tiny Manas — это decoder-only Transformer, то есть Transformer только с декодером. Её единственная задача — смотреть на уже написанные токены и предсказывать следующий. Отдельный энкодер ей не нужен: мы не переводим один текст в другой и не передаём модели второй источник информации. По той же причине здесь нет cross-attention. Есть одна последовательность и Causal Multi-Head Self-Attention, который работает только с ней и не заглядывает в будущее.
Чертёж первой версии на 26,9 миллиона параметров выглядел так:
- словарь из 32 768 токенов;
- контекстное окно на 256 токенов;
- внутреннее представление из 384 чисел для каждого токена;
- 8 блоков Transformer;
- 8 голов внимания по 48 чисел;
- FFN с расширением
384 → 1536 → 384и GELU; - LayerNorm перед Attention и FFN;
- остаточные связи вокруг Attention и FFN;
- Dropout 0.2;
- обучаемые абсолютные эмбеддинги позиций;
- общие веса у таблицы эмбеддингов токенов и LM Head;
- 26 877 696 обучаемых параметров.
А теперь пройдём по этому чертежу уже технически. На вход приходит матрица идентификаторов формы (B, T), где B — количество независимых фрагментов в одном батче, а T — длина каждого фрагмента. Таблица эмбеддингов заменяет каждый идентификатор вектором из 384 чисел. К нему добавляется вектор позиции, и получается X формы (B, T, 384).
Дальше X проходит через восемь одинаковых блоков. В каждой голове размер представления равен:
384 / 8 голов = 48 чисел на голову
Каждая голова вычисляет собственные Q, K и V, строит оценки внимания Q @ Kᵀ / √48, закрывает будущие позиции причинной маской и смешивает доступные Value-векторы. Результаты восьми голов склеиваются обратно в 384 числа. Затем FFN временно расширяет каждый вектор до 1536 чисел, применяет GELU и сжимает его обратно до 384.
После восьмого блока финальный LayerNorm ещё раз приводит числа к удобному масштабу, а LM Head превращает каждую позицию в 32 768 логитов — по одному баллу на каждый токен словаря:
token IDs (B, T)
token + position embeddings → (B, T, 384)
8 блоков Transformer → (B, T, 384)
LM Head → (B, T, 32 768)
Во время обучения логиты выравниваются в матрицу (B × T, 32 768), а правильные ответы — в список (B × T). Так Cross-Entropy может одним вызовом проверить каждую позицию батча, хотя ошибка на выходе всё равно усредняется в одно число.
В качестве проверенного ориентира я использовал ясную структуру nanoGPT, но компактную реализацию написал сам. Мне было важно не просто запустить чужой готовый код, а понимать, откуда берётся каждая форма тензора, что именно хранится в каждом параметре и почему данные проходят по модели именно так.
2. Сначала модель должна доказать, что вообще умеет учиться
Архитектура написана. Можно сразу отправлять её на полноценную тренировку? Технически да. Практически это похоже на дальнюю поездку на машине, двигатель которой мы только что собрали в гараже и даже не завели. Через полчаса что-нибудь обязательно задымится, только мы уже не поймём, где именно допустили ошибку.
В модели может быть неправильно сдвинут target, оценка может читать другой батч, сохранённый файл может не загружаться, а вычисления на Mac — незаметно уйти с MPS на обычный процессор. При этом loss иногда даже будет уменьшаться и создавать приятную иллюзию, что всё работает.
Поэтому первой проверкой стало намеренное переобучение на одном фиксированном батче. Звучит странно, да? В первой части я говорил, что переобучение — проблема, а теперь специально его вызываю. Но здесь задача другая. Мы даём модели один и тот же маленький лист с ответами снова и снова. Если она не способна выучить даже его почти наизусть, значит где-то между входом, loss и обновлением параметров разорвана цепь.
Dropout для этой проверки я отключил: случайно выбрасывать заметки из рюкзака, когда мы проверяем способность точно запомнить один пример, было бы бессмысленно. Результат получился таким:
loss: 10.4385 → 0.02673
перплексия: ~34 150 → 1.027
top-1: 100%
top-5: 100%
время: 1.89 секунды
Здесь появляются три технические метрики. Top-1 показывает, как часто правильный токен занял первое место среди всех 32 768 кандидатов. Top-5 проверяет, попал ли он хотя бы в первую пятёрку. Перплексия вычисляется как exp(loss) и очень грубо показывает, между сколькими правдоподобными продолжениями модель будто бы выбирает. Чем она ближе к единице, тем увереннее модель. Для намеренно заученного батча перплексия 1.027 и точность 100% означают именно то, чего мы добивались: двигатель завёлся.
Причём эта проверка сразу окупилась. Я обнаружил ошибку не в обучении, а в измерении. Оптимизатор учился на одном «фиксированном» батче, а оценка из-за другого состояния генератора случайных чисел создавала второй. Модель честно запоминала один лист, а проверяющий незаметно подсовывал ей другой.
Я создал батч один раз и передал один и тот же объект и в обучение, и в оценку. После повторного запуска модель дошла до 100%. Хороший урок: красивая метрика бесполезна, пока не проверено, какие именно данные попали в её расчёт.
3. Маленький пробный запуск: репетиция полного обучения
Один батч доказал, что основные детали соединены правильно. Но уметь заучить один лист и уметь учиться на целом тексте — разные вещи. Теперь требовалась генеральная репетиция: достаточно маленькая, чтобы быстро заметить проблему, но уже с настоящим разделением на обучение и проверку.
Я взял 10 000 токенов. Первые 9 000 использовались для обучения, последние 1 000 — для валидации. Модель в этом запуске содержала 8,1 миллиона параметров.
Примерно на шаге 200 результат на валидации оказался лучшим. После этого training loss продолжал красиво падать, а validation loss развернулся и пошёл вверх. К шагу 1000 получилось:
training loss: 0.1235
validation loss: 10.4687
Представьте ученика, который запомнил страницы учебника вместе с запятыми, но теряется, когда ему задают тот же вопрос другими словами. На знакомых 9 000 токенах модель стала почти отличницей. На отложенной тысяче — только хуже.
Вот теперь переобучение снова стало настоящей проблемой. Этот запуск не должен был дать красивый текст. Он должен был проверить цикл обучение → валидация → выбор лучшего состояния. И он чётко показал, зачем нельзя судить о модели только по training loss: обучение можно продолжать, цифра будет уменьшаться, а способность работать с незнакомым текстом уже разрушится.
4. Первая полная модель: создаём точку отсчёта
После двух проверок можно было переходить к полному корпусу. Но мне всё ещё не с чем было сравнивать будущие улучшения. Фраза «генерация стала как будто связнее» звучит приятно, но для эксперимента этого мало. Нужна базовая версия — точка отсчёта, которую все следующие гипотезы должны победить на одних и тех же данных.
Первая полная модель получила:
параметры: 13 193 216
блоки Transformer: 6
размер эмбеддинга: 256
головы внимания: 8
контекст: 256 токенов
шаги обучения: 3 000
обработано позиций: 12 288 000
время на M5: 15.1 минуты
Под «обработанной позицией» я имею в виду один вопрос вида «какой токен идёт следующим?». За один батч модель отвечает сразу на множество таких вопросов, а за 3 000 шагов их накопилось больше двенадцати миллионов.
После обучения я не оценивал модель на том же тексте, которым менял её параметры. Для этого у нас заранее были валидационная и тестовая части. Валидация нужна, чтобы сравнивать решения по ходу эксперимента. Тест — последняя независимая проверка, к которой лучше не подстраивать каждую новую идею.
validation loss: 4.6384
validation perplexity: 103.38
validation top-1: 26.64%
validation top-5: 45.24%
test loss: 5.0201
test perplexity: 151.42
test top-1: 23.81%
test top-5: 41.09%
Эти числа уже описывали настоящую работающую языковую модель. На тесте правильный следующий токен оказывался первым почти в каждом четвёртом случае и входил в пятёрку лучших примерно в двух случаях из пяти. Для маленькой модели, обученной с нуля на одном эпосе, это была нормальная отправная точка.
На слух результат тоже перестал быть случайным набором кыргызских фрагментов. Модель подхватила имена, обращения, ритм и частые конструкции Манаса. Но длинную мысль она быстро теряла, а повторения оставались заметными. Хорошо. Теперь у меня была не только работающая модель, но и конкретная проблема для следующего эксперимента.
5. Гипотеза, которая не сработала: больше контекста
Если модель забывает, что происходило раньше, первая мысль почти очевидна: надо дать ей больше памяти. Контекст первой версии составлял 256 токенов. Я увеличил его до 512, почти не меняя остальную архитектуру.
Больше памяти — значит, модель станет умнее. Красиво. Логично. И, как оказалось, неправильно.
Сначала важно разделить два понятия. Контекстное окно определяет, сколько прошлых токенов модель может увидеть. Но оно не гарантирует, что маленькая модель сумеет правильно использовать всё увиденное. Можно положить на стол в два раза больше документов, но детектив не станет от этого в два раза умнее. Скорее он потратит больше времени на перебирание бумаг.
Именно это происходит внутри Attention. При контексте 256 каждая голова строит таблицу сравнений размером 256 × 256, то есть 65 536 ячеек. При контексте 512 таблица становится 512 × 512, уже 262 144 ячейки. Последовательность выросла вдвое, а количество попарных сравнений — примерно в четыре раза.
Количество параметров при этом почти не изменилось: 13 193 216 превратились в 13 258 752. Добавилось ровно 65 536 чисел — новые строки в таблице позиционных эмбеддингов для позиций от 256 до 511. Вычислений стало намного больше, а способностей анализировать текст — почти столько же.
Запуск занял 17,2 минуты, скорость обработки снизилась на 12,3%, а качество не улучшилось:
validation perplexity: 112.94 (было 103.38)
test perplexity: 171.85 (было 151.42)
Более длинное окно дало модели более тяжёлую задачу, но не дало достаточно новых возможностей для её решения. При том же вычислительном бюджете она делала меньше эффективной работы и в итоге обобщала хуже. Поэтому я не стал защищать красивую гипотезу после того, как данные сказали обратное, и вернулся к контексту на 256 токенов.
6. Что действительно помогло: увеличиваем саму модель
Ладно, больше прошлого не помогло. Тогда я поставил вопрос иначе: возможно, модель видит достаточно текста, но ей не хватает внутренних возможностей, чтобы его нормально обработать?
Если продолжить аналогию с детективом, в прошлом эксперименте я просто принёс ему больше документов. Теперь я оставил прежний объём документов, но увеличил команду и дал ей больше места для работы. Контекст остался равным 256, зато представление каждого токена выросло, блоков стало больше, а FFN получил более широкое пространство для обработки признаков.
Итоговая версия Tiny Manas выглядела так:
параметры: 26 877 696
размер эмбеддинга: 384
блоки Transformer: 8
головы внимания: 8 × 48 чисел
FFN: 384 → 1536 → 384
контекст: 256 токенов
Dropout: 0.2
лучший checkpoint: шаг 2900
Обучение заняло 24,4 минуты. Все вычисления выполнялись через PyTorch MPS на графическом процессоре Apple. Автоматический переход на CPU я запретил: иначе часть неподдерживаемых операций могла бы незаметно выполняться медленнее, а сравнение времени стало бы нечестным.
В сохранённых замерах PyTorch живая память MPS достигала примерно 959 МБ. Драйвер показывал около 4,33 ГБ используемой памяти. Это выборочные показания, а не точное измерение пикового потребления всего процесса. Сам запуск помещался в 16 ГБ объединённой памяти моего MacBook Pro.
Результаты стали лучше на обеих отложенных частях:
validation loss: 4.3457
validation perplexity: 77.15
validation top-1: 31.55%
validation top-5: 49.83%
test loss: 4.7575
test perplexity: 116.45
test top-1: 27.74%
test top-5: 45.25%
По сравнению с первой полной моделью перплексия снизилась на 25,4% на валидации и на 23,1% на тесте. Top-1 и top-5 тоже выросли. Значит, улучшилась не одна случайная цифра: более крупная модель действительно чаще ставила правильные продолжения выше.
Бесплатным улучшение, конечно, не было. Скорость упала с 13 565 до 8 378 обрабатываемых позиций в секунду. Но в этот раз дополнительное время купило измеримое качество. Эксперимент с контекстом сделал модель медленнее и хуже. Увеличение ширины и глубины сделало её медленнее, но лучше. Вот это уже полезный инженерный обмен.
7. Что модель научилась делать, а что нет
Метрики стали лучше. Но читатель языковой модели вполне справедливо спросит: «А текст-то она какой пишет?»
Итоговая Tiny Manas иногда создаёт отрывки, которые сразу узнаются как подражание эпосу:
Манас! — Деп, ошонтүп, Арслан Манас кабылан
Көзүнүн жашы төгүлүп...
Она выучила имена, обращения, поэтические формулы, структуру строк и некоторые локальные цепочки действий. Несколько строк могут удерживаться вокруг одного героя или события. Это уже заметно лучше случайных кыргызских фрагментов.
И вот тут очень легко влюбиться в красивый ритм и объявить победу. Но звучать похоже и понимать происходящее — совсем не одно и то же. Модель может построить правдоподобную строку с невозможным смыслом, повторить одно имя много раз, придумать сломанное окончание или забыть причину битвы через несколько предложений.
Есть и другая ловушка: показать один самый удачный пример из сотни и сделать вид, что модель всегда пишет именно так. Поэтому для каждой принятой версии я сохранял двадцать генераций с одинаковыми настройками. Затем считал долю повторяющихся триграмм — последовательностей из трёх слов, которые модель использовала повторно.
первая полная модель: 4.77% повторяющихся триграмм в среднем
итоговая Tiny Manas: 4.21% повторяющихся триграмм в среднем
Повторов в среднем стало меньше, но разброс между отдельными примерами остался большим. В одном из итоговых текстов повторялось больше четверти триграмм. Значит, проблема уменьшилась, но никуда не исчезла.
Я также проверил, не копирует ли модель обучение длинными кусками. Первый счётчик показал максимум семь слов у версии на 26,9 миллиона параметров. Позже я обнаружил, что он пропускал совпадения через знаки препинания и переносы строк. После одинаковой нормализации обеих сторон максимум в тех же двадцати сохранённых текстах вырос до девяти слов. Подробности есть в отчёте об исправлении измерения. Это совпадение последовательности слов без учёта регистра, пунктуации и пробелов, а не побайтовое копирование. Такой небольшой набор примеров не доказывает, что модель нигде не запомнила более длинный отрывок.
8. Чего этот эксперимент не доказывает
После удачного результата особенно важно вовремя остановиться и не приписать модели способности, которых мы не проверяли.
Обучающая, валидационная и тестовая части относятся к одной редакции и одному исполнителю. Я разделил их хронологически, поэтому модель действительно проверялась на более поздних отрывках, которых не использовала для изменения весов. Но это всё ещё один большой текст Саякбая Каралаева. Эксперимент не доказывает, что Tiny Manas так же продолжит версию другого сказителя.
Токенайзер до этого обучался на более широком кыргызском корпусе. Нейронные параметры Tiny Manas начинались со случайных значений, но способ разбиения кыргызского текста уже был полезным. Поэтому результат нельзя приписывать только Transformer, полностью отделив его от качества токенайзера.
Дорогие полные запуски проводились с одним фиксированным начальным состоянием генератора случайных чисел. Итоговые метрики считались на 204 800 случайно выбранных позициях в каждой отложенной части, а не на каждом возможном окне. Для инженерного сравнения версий внутри одного проекта этого достаточно. Для заявления о новом общем эталоне — нет.
И самое главное: Tiny Manas не является общей кыргызской моделью, моделью для выполнения инструкций или чат-ботом. Она знает один узкий мир. Если спросить её о погоде, программировании или современной жизни, в обучающих данных просто нет основания для хорошего ответа.
Следующим сильным испытанием был бы законно доступный текст другого сказителя или другой редакции, полностью отделённый на уровне документов. Тогда мы смогли бы проверить, выучила ли модель более общие закономерности эпоса или главным образом приспособилась к формулам одного исполнения. Просто дольше учить её на том же тексте — слабая идея: скорее всего, она лишь глубже его запомнит.
9. После первого обучения: что стоило менять
Описанные выше запуски дали мне работающую версию на 26,9 миллиона параметров. Дальше возникла другая задача: можно ли на том же Mac обучать её дешевле и получать более точные предсказания, не наращивая модель бесконечно?
Я разделил изменения на две группы. Одни должны делать ту же работу быстрее: например, не вычислять результат, который сразу выбрасывается. Другие меняют саму модель и могут изменить качество. Для первой группы нужны совпадающие предсказания и замеры времени. Для второй одного быстрого вычисления недостаточно: модель нужно обучить и сравнить на отложенном тексте.
Возьмём LM Head. При генерации нам нужен следующий токен после последней позиции. Раньше модель всё равно переводила в 32 768 логитов каждую позицию окна. Для окна из 256 токенов получалось 256 наборов ответов, хотя использовался только последний. Я оставил все позиции внутри Transformer, где они нужны для контекста, но стал применять финальную проекцию только к последней. Размер выходного тензора уменьшился с 32 МиБ до 128 КиБ. На этом конкретном входе вычисление ускорилось с 7,307 до 5,894 мс, а проверенные предсказания сохранились.
Другой полезной переменой оказался BF16: формат чисел с меньшей точностью, который можно использовать для части операций обучения. В двух новых полных запусках с одинаковыми начальными весами и обучающими окнами он сократил время цикла обучения на 20,58%. Ошибка на validation изменилась всего на 0,0000464. При этом параметры и состояние оптимизатора остались в FP32, обычном 32-битном формате; я не переводил всю модель целиком в короткие числа.
Самый заметный выигрыш в предсказании дала замена способа учитывать позиции. Вместо отдельной обучаемой таблицы для мест в строке я проверил RoPE, rotary position embeddings. Этот механизм поворачивает пары чисел в Query и Key в зависимости от позиции. При сравнении Q и K появляется информация о взаимном расстоянии токенов. Value при этом не поворачивается.
Сначала я остановил эту идею из-за дополнительных затрат времени на шаг. Это оказалось слишком ранним решением: я измерил цену, но ещё не проверил пользу. В повторном эксперименте небольшое замедление разрешалось, если validation показывал выигрыш. RoPE прошёл промежуточные проверки и завершил все 3 000 шагов. На одинаковой дополнительной оценке loss снизился с 4,34578 до 4,11584, а perplexity с 77,15 до 61,30. После выбора модели тест дал perplexity 92,88. Эти числа относятся к новой версии и не заменяют исторические результаты выше.
Остальные идеи тоже остались в журнале:
| Изменение | Что показал эксперимент | Решение |
|---|---|---|
| KV cache: сохранять уже вычисленные Key и Value | Короткая генерация ускорилась примерно в 1,16 раза; около границы контекста выигрыш почти исчезал | Использовать, заново пересчитывая окно после его сдвига |
| torch.compile: компиляция вычислений | Первая попытка нарушила совпадение градиентов; исправленная оказалась на 3,02% медленнее | Оставить обычное выполнение |
| Activation checkpointing: пересчёт промежуточных состояний вместо хранения | На 38,46% меньше измеренной живой памяти, но на 30,63% дольше шаг | Оставить опцией, выключенной по умолчанию |
| Словарь 16k или 8k вместо 32k | Обработка текста ускорилась, но ошибка в битах на байт ухудшилась относительно принятой модели | Сохранить 32k; сравнивать одинаковый текст, а не количество разных токенов |
| RMSNorm вместо LayerNorm | За 900 шагов заметного выигрыша не появилось | Остановить пробный запуск; результат полного обучения неизвестен |
| SwiGLU вместо GELU в FFN | Полные 3 000 шагов дали худшую validation и больше повторов | Сохранить GELU |
| GQA: общие Key и Value для нескольких голов | Кэш стал вчетверо меньше, но после одинаковой короткой адаптации ошибка заметно выросла | Сохранить восемь независимых KV-голов |
| Специализированный расчёт выходной ошибки | Текущий запуск помещается в память; измеренного ограничения, ради которого нужен этот путь, не обнаружено | Не запускать ещё один эксперимент без причины |
Все конфигурации, ограничения и отрицательные результаты собраны в журнале экспериментов. После RoPE в модели осталось 26 779 392 параметра: обучаемая таблица позиций больше не нужна. Но красивый показатель не исправил сюжет. В двадцати продолжениях средняя доля повторных триграмм была около 4,13%, встречались сломанные слова и скачки между героями. Я принял улучшение предсказания, не объявляя задачу связного повествования решённой.
10. Новые книги: выигрыш, который я не принял
После всех замен внутри модели оставалась неприятная граница: она училась на одной редакции эпоса. Можно бесконечно переставлять детали двигателя, но новых историй в обучающих данных от этого не появится. Поэтому следующий эксперимент начался с книг, а не с ещё одного слоя Transformer.
Я подготовил тексты из вариантов Сагымбая Орозбакова и Жусупа Мамая, доступных в коллекции Bizdin. Для этого исследования было подтверждено разрешение на их использование. Полные книги и извлечённый корпус в GitHub не выкладываются.
С PDF пришлось повозиться. Обычное извлечение местами раскладывало слова на отдельные буквы, а вместе со стихами забирало научные комментарии. Я выделил страницы эпоса, собирал слова по их координатам и убирал мелкие сноски и номера строк. Стихотворные переносы сохранились. Ошибки распознавания полностью не исчезли; несколько более проблемных сканов вообще не вошли в этот запуск.
Три тома Орозбакова добавили 343 402 обучающих токена к прежним 418 562. Отдельный четвёртый том, 99 630 токенов, стал validation. Версия Мамая, 408 359 токенов, осталась для финального test. Разделение было зафиксировано до просмотра оценок моделей. Я также проверил точное восстановление текста токенайзером и длинные совпадающие последовательности слов между обучением и отложенными книгами.
Теперь сравнение могло начаться. Две модели получили одинаковую архитектуру RoPE, одинаковые начальные веса и по 3 000 шагов, или 12,288 млн обучающих позиций. Первая видела только прежний текст. У второй половина обучающих окон приходилась на прежний текст, а половина на новые тома. Окна не пересекали границы книг.
Но я поставил дополнительное условие. Победить на новой книге недостаточно, если модель при этом заметно разучилась продолжать знакомый источник. Поэтому рядом с новой оценкой сохранил проверку на прежнем validation. Предельное ухудшение loss задал заранее: не больше 0,02.
| Модель | Loss на отдельном томе Орозбакова | Loss на знакомом источнике | |---|---:|---:| | Действующая RoPE | 11,847 | 4,088 | | Модель на расширенном корпусе | 4,208 | 4,134 |
На новой книге разница огромная. Однако знакомый текст ухудшился на 0,0457, больше разрешённых 0,02. Поэтому новые веса я не принял. Это результат конкретной смеси и конкретного бюджета, а не доказательство того, что расширять данные бесполезно.
Откуда взялся такой разрыв в первой колонке? Часть ответа оказалась в переносах строк. Прежний источник был склеен в сплошной текст, а новые книги сохраняли стихи. Дополнительный разбор на тех же позициях показал, что около 47% общего уменьшения ошибки приходится прямо на токены с переносами. На остальных токенах предсказание тоже улучшилось. Но и их контекст содержит переносы, поэтому весь оставшийся выигрыш нельзя честно приписать только новым сюжетам или другому сказителю.
Эта таблица использует новый фиксированный набор оцениваемых позиций. Нельзя вычесть её 4,088 из прежних 4,11584 и объявить ещё одно улучшение: это одни и те же действующие веса, посчитанные другим способом. Для каждого сравнения модели должны отвечать на одинаковые вопросы.
Повторная проверка контекста 512
Расширенный корпус не прошёл условие принятия, поэтому следующая ветка снова училась на прежнем тексте. На этот раз я проверял 512 токенов уже с RoPE, а не с первоначальной таблицей позиций. Число параметров и начальные веса сохранились. Вместо восьми окон по 256 токенов микробатч содержал четыре окна по 512, так что количество обучающих позиций за шаг осталось прежним.
Запуск прошёл промежуточные проверки на 900 и 1 200 шагах, но остановился на 1 500. Последние три разницы loss с контролем были +0,1123, -0,5095 и +0,4115. Устойчивого выигрыша хотя бы на 0,02 не получилось. Я сохранил контекст 256 и не потратил оставшуюся половину бюджета. Это остановка пробного запуска по заданному правилу, а не доказательство того, что полное обучение с 512 токенами всегда даст худшую модель.
BF16 при генерации: обучение и выдача текста оказались разными задачами
Раз BF16 ускорил обучение, почему бы не включить его и при выдаче текста? Я проверил это отдельно на действующей модели, не меняя её весов. Здесь она обрабатывает короткий запрос, а затем выдаёт по одному токену, поэтому прежнее ускорение не обязано повториться.
Точность предсказаний почти сохранилась: изменение validation loss составило всего +0,000024. Но короткая генерация стала на 18,73% медленнее. Около заполненного контекста время уменьшилось лишь на 3,14%. Кэш действительно сократился с 6 до 3 МиБ, однако нужного уменьшения общей измеренной живой памяти не получилось. Поэтому обучение осталось в BF16, а генерация в FP32. Формат чисел выбирается под измеренную задачу, а не один раз «для всей модели».
В итоге эта серия не заменила действующую модель. После закрытия выбора я проверил её на удержанном тексте Мамая: loss составил 12,371. Этот высокий результат показывает, насколько слабым остаётся перенос на другой источник и его оформление. Он не относится к модели на расширенном корпусе: её на финальном тесте я не проверял.
Полный протокол и результаты находятся в отчёте O14–O17, а подробное техническое изложение с графиками и ограничениями в исходниках paper.
11. Попробовать Tiny Manas
После всех таблиц и оговорок результат можно наконец потрогать руками. Ниже работает именно выбранная модель примерно на 26,8 миллиона параметров, а не другая LLM, которая притворяется Tiny Manas. Перед запуском сервис проверяет файл модели и токенайзер.
Запрос ограничен 300 символами, а продолжение — 128 токенами. Частота обращений тоже ограничена, чтобы один посетитель не занял весь сервер. Температура управляет смелостью выбора, а top-k ограничивает число кандидатов на каждом шаге.
Модель очень маленькая и обучена только на эпосе. Поэтому странные слова, повторы и потеря сюжета — не ошибка интерфейса, а честные границы эксперимента.
Try Tiny Manas
Give the model a short Kyrgyz opening. It will continue one token at a time.
The continuation will appear here.
Experimental model, not an assistant. Four generations per visitor every ten minutes.
12. Что я вынес из Tiny Manas
Я начал этот путь с желания понять, что скрывается между обычной строкой текста и следующим сгенерированным токеном. В конце у меня появилась не только работающая модель, но и цельная цепочка причин.
Первая часть статьи собрала эту цепочку в теории. Байты дают универсальное представление текста. BPE делает последовательность короче. Эмбеддинги превращают идентификаторы в обучаемые векторы и добавляют порядок. Attention собирает информацию из контекста, FFN обрабатывает её, остаточные связи сохраняют основу, а LayerNorm и Dropout помогают модели устойчиво учиться. LM Head создаёт логиты, Cross-Entropy измеряет ошибку, обратное распространение распределяет ответственность, а затем параметры понемногу меняются.
Вторая часть проверила всю эту механику на реальном эксперименте. Переобучение на одном батче подтвердило, что реализация вообще способна учиться, и помогло найти ошибку в измерении. Маленький запуск показал разрыв между запоминанием и обобщением. Первая полная модель дала точку отсчёта. Удвоенный контекст оказался медленнее и хуже, хотя на словах выглядел очевидным улучшением. Увеличение самой модели, наоборот, снизило перплексию и повысило точность на обеих отложенных частях.
Если вы дочитали до этого места, то теперь знаете: внутри слова «LLM» нет одного волшебного алгоритма. Есть длинная цепочка довольно понятных решений. Каждое закрывает свою маленькую проблему, а вместе они превращают последовательность чисел в предсказание следующего токена. Магия, конечно, звучит красивее. Но мне гораздо интереснее понимать механику.
Tiny Manas не стала умной общей LLM, и такой цели не было. Она стала моим первым полноценным достижением: моделью, в которой я могу открыть любую основную часть, объяснить, зачем она нужна, проследить формы тензоров, запустить обучение и честно показать, где заканчиваются возможности результата. Именно ради этого я и начал копать в глубину.