Урок 48. Трансформеры
Зачем тебе этот урок
В Уроке 1 трансформер был назван — как архитектура, с которой начался нынешний ИИ. Тогда это была подпись под картинкой. Сейчас у тебя есть всё, чтобы разобрать его по-настоящему: эмбеддинги из Урока 44, языковая модель из Урока 45, боль рекуррентности из Урока 46, механизм внимания из Урока 47.
Урок 47 закончился неудобным вопросом: если внимание позволяет за один шаг посмотреть на любое слово входа, зачем вообще рекуррентность? Этот урок — ответ. Оказалось, что не зачем.
К концу урока ты:
- Посчитаешь self-attention руками, на трёх словах, до последней цифры.
- Поймёшь, откуда берётся деление на корень из размерности, и что ломается без него.
- Разберёшь блок трансформера послойно и соберёшь его на PyTorch без
nn.Transformer. - Будешь различать encoder-only, decoder-only и encoder-decoder — и понимать, почему современные LLM устроены именно так.
- Узнаешь, почему модель путается в буквах и откуда берётся лимит контекста.
1. Главный ход: выкинуть рекуррентность
Напомню, чем плоха RNN из Урока 46. Она обрабатывает последовательность по одному элементу, и состояние на шаге t зависит от состояния на шаге t−1. Отсюда два следствия, оба скверные:
- Нельзя распараллелить по времени. Пока не посчитан шаг 5, шаг 6 не начать. Тысяча токенов — тысяча последовательных шагов, и видеокарта, умеющая делать тысячи операций разом, простаивает.
- Путь между словами длинный. Чтобы связать первое слово с сотым, сигнал проходит сто преобразований. Каждое немного размывает и ослабляет его — та самая проблема затухания из Уроков 34 и 46. LSTM смягчила её, но не убрала.
Внимание из Урока 47 решало обе беды, но работало прицепом к рекуррентной сети: RNN считала состояния, внимание поверх них выбирало, куда смотреть. Статья 2017 года «Attention Is All You Need» сделала простую вещь — убрала рекуррентность и оставила одно внимание. Название буквальное.
| RNN / LSTM | Трансформер | |
|---|---|---|
| Обработка последовательности | По шагу за раз | Вся сразу, параллельно |
| Путь между словом 1 и словом 100 | 99 шагов | 1 шаг |
| Что ограничивает скорость | Длина текста | Размер видеокарты |
| Откуда знает порядок слов | Из самой рекуррентности | Порядок надо добавить явно |
Обрати внимание на нижнюю строку — за параллельность приходится платить, и мы вернёмся к этому в разделе 6. Но сначала главное: именно параллельность позволила масштабировать обучение. Пока модель училась по шагу за раз, увеличивать данные и параметры было бессмысленно — обучение растягивалось на годы. Как только весь текст стал прогоняться одним матричным умножением, стало выгодно брать больше данных, больше слоёв, больше видеокарт. Всё, что случилось с языковыми моделями после 2017 года, стоит на этом.
2. Self-attention: та же тройка, но внутрь себя
В Уроке 47 внимание работало между двумя последовательностями: декодер спрашивал (query), энкодер отвечал (key и value). Self-attention — то же самое, но последовательность одна, и она спрашивает саму себя.
Каждый токен получает три роли сразу, тремя обучаемыми матрицами:
q = x · W_Q query — «что я ищу»
k = x · W_K key — «что я про себя объявляю»
v = x · W_V value — «что я отдам тому, кто меня выберет»
Дальше — как в Уроке 47. Токен берёт свой query, скалярно умножает на key каждого токена последовательности (включая себя), получает набор оценок совпадения, превращает их softmax в веса и складывает value с этими весами. Результат — новое представление токена, собранное из всей фразы.
В формуле вся операция помещается в одну строку — её стоит запомнить:
Attention(Q, K, V) = softmax( Q · Kᵀ / √d_k ) · V
Q, K, V здесь — матрицы: одна строка на токен. Поэтому внимание для всех токенов сразу — это два матричных умножения и один softmax. Никаких циклов по времени, ровно то, что любит видеокарта.
Смысловая сторона: в фразе «ключ от двери заржавел» слово «ключ» соберёт себе контекст из «двери» и «заржавел» и перестанет быть похоже на «ключ» из «гаечный ключ». Эмбеддинг из Урока 44 был один на слово, независимо от фразы. Здесь представление контекстное — оно меняется в зависимости от соседей.
3. Считаем руками: одна голова на трёх словах
Возьмём фразу «кот пьёт молоко» и совсем маленькую размерность — 2. Эмбеддинги (условные, для счёта):
кот x1 = [1, 0]
пьёт x2 = [0, 1]
молоко x3 = [1, 1]
Матрицы проекций (тоже условные, в реальной сети они обучаются):
W_Q = [[1, 0], W_K = [[1, 1], W_V = [[2, 0],
[1, 1]] [0, 1]] [0, 1]]
Шаг 1 — проекции. Умножаем каждый эмбеддинг на каждую матрицу:
query key value
кот [1, 0] [1, 1] [2, 0]
пьёт [1, 1] [0, 1] [0, 1]
молоко [2, 1] [1, 2] [2, 1]
Шаг 2 — скалярные произведения. Берём query слова «пьёт» — это [1, 1] — и умножаем на все три key:
q_пьёт · k_кот = 1·1 + 1·1 = 2
q_пьёт · k_пьёт = 1·0 + 1·1 = 1
q_пьёт · k_молоко = 1·1 + 1·2 = 3
Шаг 3 — делим на корень из размерности. d_k = 2, √2 ≈ 1.414:
2 / 1.414 = 1.414
1 / 1.414 = 0.707
3 / 1.414 = 2.121
Шаг 4 — softmax. Возводим e в каждую степень и делим на сумму:
e^1.414 = 4.113 4.113 / 14.483 = 0.284 ← кот
e^0.707 = 2.028 2.028 / 14.483 = 0.140 ← пьёт
e^2.121 = 8.342 8.342 / 14.483 = 0.576 ← молоко
сумма весов = 1.000
Шаг 5 — взвешенная сумма value.
z_пьёт = 0.284·[2, 0] + 0.140·[0, 1] + 0.576·[2, 1]
= [0.568, 0] + [0, 0.140] + [1.152, 0.576]
= [1.720, 0.716]
Вот и всё. Слово «пьёт» получило новое представление, в котором 58% пришло от «молоко», 28% от «кот» и 14% от него самого. То же самое считается параллельно для «кот» и «молоко» — всего одно матричное умножение на всю фразу.
Заметь важное: размерность выхода такая же, как у входа. Токен вошёл вектором длины 2 и вышел вектором длины 2 — просто наполненным контекстом. Именно поэтому такие слои можно ставить друг на друга стопкой.
4. Зачем деление на корень из размерности
Этот шаг выглядит как случайная деталь, но без него ничего не учится.
Скалярное произведение двух векторов длины d — это сумма d слагаемых. Если компоненты примерно независимы и имеют разброс около единицы, то дисперсия суммы растёт как d, а разброс — как √d. При d = 64 оценки гуляют в диапазоне порядка ±8, при d = 512 — уже порядка ±23.
А softmax к большим разницам в аргументах беспощаден. Посмотри на наш пример без деления — оценки 2, 1, 3:
с делением на √2: 0.284 / 0.140 / 0.576 ← распределение мягкое
без деления: 0.245 / 0.090 / 0.665 ← уже заметно острее
На d = 2 разница косметическая. На больших d softmax схлопывается почти в «единица на максимуме, нули на остальном». Модель перестаёт смешивать контекст — она жёстко выбирает один токен. Хуже того, в зоне насыщения softmax производные почти нулевые: градиент не проходит, обучение встаёт. Ровно та же болезнь, что у sigmoid из Урока 33, только в другом месте.
Деление на √d_k возвращает разброс оценок примерно к единице независимо от размерности. Поэтому внимание и называют scaled dot-product attention — «масштабированное».
5. Multi-head attention: несколько взглядов сразу
Одна голова внимания даёт один набор весов на токен. Но связей в предложении несколько сортов одновременно: синтаксическая (какое существительное к какому глаголу), кореферентная (к чему относится «он»), смысловая. Одним распределением весов их не выразить — softmax обязан всё сложить в единицу, и признаки конкурируют за один и тот же бюджет внимания.
Решение прямолинейное: считать несколько независимых вниманий параллельно. Каждое со своими W_Q, W_K, W_V. Это головы (heads).
Ключевая деталь — размерность не растёт. Пусть d_model = 512 и голов 8. Тогда каждая голова работает в размерности 512 / 8 = 64:
вход: (T токенов, 512)
8 голов: каждая проецирует в 64 → считает внимание → отдаёт (T, 64)
склейка: 8 × 64 = 512 → снова (T, 512)
финал: умножаем на выходную матрицу W_O (512 × 512)
Головы конкатенируются по признакам, а выходная матрица W_O перемешивает их результаты между собой — без неё головы остались бы восемью независимыми кусками вектора. По вычислениям multi-head почти бесплатен относительно одной большой головы: тот же объём умножений, просто разложенный на восемь независимых пачек.
На практике головы действительно специализируются. Исследования показывают, что в обученных моделях одни головы устойчиво смотрят на соседний токен, другие — на синтаксически связанное слово, третьи ни на что осмысленное и их можно удалить без большой потери. Но это не задаётся заранее: специализация возникает сама, ровно как ядра CNN из Урока 37 сами приходят к детекторам краёв.
6. Позиционное кодирование: вернуть порядок
Вот цена параллельности. Посмотри на формулу внимания ещё раз: там нет ничего про порядок. Переставь слова во входной матрице — переставятся и строки выхода, но каждый токен получит ровно то же самое представление. Для self-attention «кот укусил собаку» и «собака укусила кота» — один и тот же мешок слов.
Значит, позицию нужно сообщить явно. Способ прямолинейный: к эмбеддингу каждого токена прибавляют вектор его позиции.
вход блока = эмбеддинг токена + позиционный вектор
Откуда берётся позиционный вектор — два основных подхода.
Синусоидальное кодирование (оригинальная статья). Позиция кодируется набором синусов и косинусов разной частоты:
PE(pos, 2i) = sin( pos / 10000^(2i/d) )
PE(pos, 2i+1) = cos( pos / 10000^(2i/d) )
Логика та же, что у двоичной записи числа: младшие разряды меняются быстро, старшие медленно, вместе они однозначно задают позицию. Только вместо нулей и единиц — плавные волны. Плюс подхода: ничего не обучается, и формула определена для любой позиции — в том числе длиннее тех, что встречались при обучении.
Обучаемое кодирование. Просто таблица векторов: позиция 0, позиция 1, позиция 2… — обычный nn.Embedding, веса которого подбирает градиентный спуск. Проще и часто чуть точнее, но у таблицы конечный размер: позиции за её пределами модель никогда не видела.
Отсюда, кстати, часть ответа на вопрос «почему у модели фиксированный лимит контекста». Вторая часть — в разделе 11.
7. Блок трансформера: что именно ставят стопкой
Внимание само по себе — только смешивание. Оно складывает существующие value с весами, но не преобразует их нелинейно: без нелинейности стопка слоёв внимания схлопывается в одно линейное преобразование. Поэтому внимание всегда идёт в паре с полносвязной сетью, и вместе они образуют блок.
x ──┬─────────────────────────────┐
│ LayerNorm → Multi-Head │
│ Self-Attention │
└──────────────► + ◄───────────┘ residual
│
┌────────────────┴─────────────┐
│ LayerNorm → Linear → GELU │
│ → Linear │
└──────────────► + ◄───────────┘ residual
│
выход
Полносвязная часть (feed-forward). Два линейных слоя с нелинейностью между ними, применяются к каждому токену независимо. Внутренняя размерность обычно в четыре раза больше внешней: 512 → 2048 → 512. Разделение труда простое: внимание отвечает за обмен информацией между токенами, feed-forward — за обработку внутри токена. Здесь же лежит большая часть параметров модели.
Residual-связи. Ты уже видел их в Уроке 37 у ResNet: y = F(x) + x. Причина та же. В стопке из десятков блоков градиент, идущий назад, должен пройти через все преобразования — и затухает. Прибавление входа к выходу даёт градиенту прямой путь, минуя слои. Плюс блоку достаточно выучить поправку к представлению, а не строить его с нуля. Без residual-связей глубокие трансформеры просто не обучаются.
Layer normalization. Нормирует вектор каждого токена по его собственным признакам: вычитает среднее, делит на разброс, потом умножает на обучаемый масштаб и добавляет сдвиг. Почему не BatchNorm из Урока 37? BatchNorm считает статистику по батчу, а в тексте последовательности разной длины и батчи маленькие — статистика скачет. LayerNorm смотрит только на один токен, поэтому не зависит ни от размера батча, ни от длины текста и одинаково ведёт себя при обучении и инференсе.
Про место нормализации есть нюанс. В оригинальной статье она стояла после подслоя (post-norm). Современные реализации почти всегда ставят её перед (pre-norm) — так на схеме выше: глубокие стопки обучаются заметно устойчивее и не требуют аккуратного разогрева learning rate.
Дальше — самое скучное и самое важное: блок повторяется. Шесть раз, двенадцать, сотню. Каждый блок сохраняет размерность, поэтому стопка собирается тривиально. Никакой новой идеи в глубине нет — просто одно и то же смешивание контекста, применённое много раз.
8. Причинная маска: как декодер не подглядывает
Задача языкового моделирования из Урока 45 — предсказать следующий токен по предыдущим. Но self-attention по построению смотрит на всю последовательность, включая то, что справа. Если обучать так, модель просто прочитает ответ во входе и ничему не научится.
Решение — причинная маска (causal mask). Перед softmax все оценки, где токен смотрит вперёд, заменяются на минус бесконечность. После softmax e в степени минус бесконечность даёт ровно ноль — вес такой связи исчезает, и остаток нормируется сам.
Возьмём наши три слова. Полная матрица оценок (уже делённых на √2), строка — кто смотрит, столбец — на кого:
кот пьёт молоко
кот 0.707 0.000 0.707
пьёт 1.414 0.707 2.121
молоко 2.121 0.707 2.828
Накрываем маской всё выше главной диагонали и делаем softmax по строкам:
кот пьёт молоко
кот 1.000 — — видит только себя
пьёт 0.670 0.330 — себя и «кот»
молоко 0.306 0.074 0.620 всю фразу
Каждая строка суммируется в единицу, и никто не заглянул в будущее. Ценность приёма в том, что обучение остаётся параллельным: одним прогоном модель одновременно учится предсказывать второй токен по первому, третий по двум первым и так далее. Именно так учатся современные языковые модели — подробности в Уроке 50.
Генерация при этом всё равно идёт по одному токену: предсказали, приписали к входу, прогнали заново. Параллелизм есть на обучении, но не на выводе.
9. Три семейства архитектур
Из одного и того же блока собирают три разные вещи — отличаются они тем, что токену разрешено видеть.
| Архитектура | Что видит токен | Задачи |
|---|---|---|
| Encoder-only | Всю последовательность, слева и справа | Классификация, поиск сущностей (Урок 49), оценка близости текстов, эмбеддинги предложений |
| Decoder-only | Только то, что слева (причинная маска) | Генерация текста, продолжение, диалог — все современные LLM |
| Encoder-decoder | Энкодер — весь вход; декодер — свой левый контекст плюс весь выход энкодера | Перевод, суммаризация, любое преобразование текста в текст |
Encoder-only. Обучается не предсказанию следующего слова, а заполнению пропусков: часть токенов маскируют, модель их восстанавливает. Двусторонний контекст здесь огромное преимущество — чтобы понять слово, полезно видеть и то, что стоит после него. Генерировать такая модель не умеет и не должна.
Decoder-only. Стопка блоков с причинной маской, обученная предсказывать следующий токен. Именно это семейство победило в масштабе: обучающие данные не нужно размечать вообще — годится любой текст, а задачи вроде классификации или перевода формулируются как продолжение промпта.
Encoder-decoder. Прямой потомок seq2seq из Урока 47. Энкодер читает вход целиком, декодер генерирует выход, а связывает их cross-attention — тот же механизм, но query берутся из декодера, а key и value из энкодера. Ровно та схема, которую ты разбирал в Уроке 47, только без RNN внутри.
Итого в трансформере ровно три места, где встречается внимание: self-attention энкодера (двунаправленный), self-attention декодера (с маской) и cross-attention между ними. Механизм один и тот же, различаются только источники Q, K, V и наличие маски.
10. Токенизация: почему не слова и не буквы
Мы всё время говорили «токен», а не «слово». Это не педантизм.
Почему не слова. Словарь русского языка со всеми падежами и склонениями — миллионы форм. Выходной слой модели — матрица размера d_model × размер словаря, и на миллионах строк она становится неподъёмной. Хуже другое: любое слово вне словаря превращается в заглушку. Опечатка, имя, новый термин — модель слепа.
Почему не буквы. Словарь крошечный, ничего не потеряется — но последовательности становятся в пять-семь раз длиннее, а внимание за это платит квадратично (раздел 11). И каждому слою приходится тратить силы на сборку букв в слова.
Компромисс — подслова. Частые слова остаются целыми, редкие разбиваются на куски. Самый известный алгоритм — BPE (byte pair encoding): начинаем со словаря отдельных символов и много раз повторяем один шаг — находим самую частую пару соседних элементов и склеиваем её в новый элемент. Пары «ст», «пре» склеятся рано, потому что встречаются повсюду; целые частые слова доживут до конца как единый токен. Родственные схемы — WordPiece, Unigram, SentencePiece — отличаются критерием склейки, но идея та же.
На русском разбиение выглядит примерно так (точные границы зависят от токенизатора):
«кот» → один токен
«молоко» → один токен
«непереводимость» → не + перевод + имость
«Абырвалг» → А + бы + рв + алг
Два практических следствия, которые объясняют странности моделей.
Русский текст дороже. Большинство популярных токенизаторов обучались на корпусах с преобладанием английского, поэтому русские слова чаще дробятся на куски. Один и тот же смысл занимает больше токенов — а значит, больше контекста и больше денег за запрос.
Модель «не видит» букв. Спроси у языковой модели, сколько букв «о» в слове «молоко», — и она вполне может ошибиться. Не потому что не умеет считать: слово пришло к ней одним неделимым токеном, номером в словаре. Внутри этого номера букв нет. Чтобы ответить, модель должна восстановить написание из выученных ассоциаций — а это ненадёжно. Ровно та же причина у проблем с рифмой, палиндромами и подсчётом символов.
11. Цена: внимание квадратично по длине
Теперь про главный недостаток. Чтобы посчитать внимание, нужна матрица оценок «каждый токен против каждого». Для T токенов это T² чисел.
T = 1 000 → 1 000 000 пар
T = 10 000 → 100 000 000 пар в 100 раз больше при длине в 10 раз
T = 100 000 → 10 000 000 000 пар
Удвоил длину текста — учетверил и вычисления, и память под матрицу внимания. Это и есть настоящая причина, по которой у моделей есть лимит контекста: дело не в принципиальной невозможности, а в том, что стоимость растёт быстрее, чем полезность.
Направления, в которых с этим борются, — просто чтобы ты узнавал термины, детали за пределами урока:
- Разреженное внимание — считать не все пары, а часть: локальное окно, случайные связи, несколько глобальных токенов.
- Линейные приближения — переписать формулу так, чтобы сложность росла линейно, ценой приближённости.
- Эффективные реализации точного внимания — те же вычисления, но без хранения матрицы T×T целиком в памяти.
- Работа с контекстом снаружи — не тащить всё в окно, а искать нужные куски и подкладывать только их.
Ни одно из направлений не отменяет квадрат в базовой формуле. Он остаётся ценой за то, что путь между любыми двумя токенами равен одному шагу.
12. Почему трансформер вышел за пределы текста
Посмотри на формулу внимания ещё раз: там нигде не сказано «слово». Есть набор векторов и операция «каждый смотрит на каждого». Ни грамматики, ни языка, ни даже требования, чтобы элементы шли подряд — порядок и так добавляется отдельно, позиционным кодированием.
Отсюда рецепт: если данные можно нарезать на последовательность векторов, трансформер применим.
- Изображения. Картинку режут на квадратные заплатки, каждую разворачивают в вектор — и это токены. Никаких свёрток. То, что CNN из Урока 37 зашивала в архитектуру (локальность, соседство), такая модель выучивает из данных — и потому требует больше данных.
- Звук. Спектрограмма — это последовательность коротких кадров. Токены готовы.
- Код. Формально текст, но с длинными связями: имя переменной объявлено за двести строк до использования. Единичный путь между токенами здесь особенно ценен.
- Белки. Аминокислотная цепочка — буквально последовательность из двадцати с небольшим символов, где важны связи между далёкими участками.
Практическое следствие — мультимодальность. Если и текст, и картинка превращаются в токены одной размерности, их можно сложить в одну последовательность и подать одной модели. Никакой особой архитектуры для «понимания картинок» не нужно, достаточно общего словаря представлений.
13. Код: собираем блок руками
В PyTorch есть nn.MultiheadAttention и nn.TransformerEncoderLayer. Мы ими не воспользуемся — ты уже писал слои сам в Уроке 34, здесь та же логика. Понимание стоит сорока строк.
import math
import torch
import torch.nn as nn
torch.manual_seed(0)
def sinusoidal_pe(T, d_model):
"""Позиционное кодирование из статьи: синусы и косинусы разных частот."""
pos = torch.arange(T).unsqueeze(1) # (T, 1)
i = torch.arange(0, d_model, 2) # чётные индексы
freq = torch.exp(-math.log(10000.0) * i / d_model) # (d_model/2,)
pe = torch.zeros(T, d_model)
pe[:, 0::2] = torch.sin(pos * freq)
pe[:, 1::2] = torch.cos(pos * freq)
return pe
class MultiHeadSelfAttention(nn.Module):
def __init__(self, d_model, n_heads):
super().__init__()
assert d_model % n_heads == 0, "размерность должна делиться на число голов"
self.n_heads = n_heads
self.d_head = d_model // n_heads
self.wq = nn.Linear(d_model, d_model, bias=False)
self.wk = nn.Linear(d_model, d_model, bias=False)
self.wv = nn.Linear(d_model, d_model, bias=False)
self.wo = nn.Linear(d_model, d_model, bias=False)
def split_heads(self, t, B, T):
# (B, T, d_model) -> (B, n_heads, T, d_head)
return t.view(B, T, self.n_heads, self.d_head).transpose(1, 2)
def forward(self, x, causal=False):
B, T, C = x.shape # батч, длина, d_model
q = self.split_heads(self.wq(x), B, T)
k = self.split_heads(self.wk(x), B, T)
v = self.split_heads(self.wv(x), B, T)
# оценки совпадения: каждый токен против каждого, с делением на корень
scores = q @ k.transpose(-2, -1) / math.sqrt(self.d_head) # (B, h, T, T)
if causal:
future = torch.triu(torch.ones(T, T, device=x.device), diagonal=1).bool()
scores = scores.masked_fill(future, float("-inf"))
weights = scores.softmax(dim=-1) # по строкам, сумма = 1
out = weights @ v # (B, h, T, d_head)
out = out.transpose(1, 2).reshape(B, T, C) # склеили головы обратно
return self.wo(out), weights
class TransformerBlock(nn.Module):
def __init__(self, d_model, n_heads, d_ff):
super().__init__()
self.ln1 = nn.LayerNorm(d_model)
self.attn = MultiHeadSelfAttention(d_model, n_heads)
self.ln2 = nn.LayerNorm(d_model)
self.ff = nn.Sequential(
nn.Linear(d_model, d_ff),
nn.GELU(), # сглаженная родственница ReLU из Урока 33
nn.Linear(d_ff, d_model),
)
def forward(self, x, causal=False):
a, weights = self.attn(self.ln1(x), causal) # pre-norm
x = x + a # residual
x = x + self.ff(self.ln2(x)) # residual
return x, weights
B, T, d_model, n_heads, vocab = 2, 5, 16, 4, 20
emb = nn.Embedding(vocab, d_model)
block = TransformerBlock(d_model, n_heads, d_ff=4 * d_model)
tokens = torch.randint(0, vocab, (B, T))
x = emb(tokens) + sinusoidal_pe(T, d_model)
y, weights = block(x, causal=True)
print("токены ", tuple(tokens.shape)) # (2, 5)
print("вход блока ", tuple(x.shape)) # (2, 5, 16)
print("веса внимания", tuple(weights.shape)) # (2, 4, 5, 5)
print("выход блока ", tuple(y.shape)) # (2, 5, 16)
print(weights[0, 0].round(decimals=2)) # нижнетреугольная — маска работает
Смотри на формы. Вход блока и выход блока совпадают — (2, 5, 16), поэтому блоки складываются стопкой без переходников. Веса внимания (2, 4, 5, 5): батч, четыре головы, и для каждой матрица 5×5 «каждый токен против каждого» — тот самый квадрат из раздела 11. Последний print печатает нижнетреугольную матрицу: нули выше диагонали означают, что маска отработала.
14. Тонкости и подводные камни
- Забыл позиционное кодирование. Модель обучается, loss падает, а качество упирается в потолок. Причина: для сети текст — мешок слов, порядок она не видит. Ошибка молчаливая, ищется долго.
- Маску ставят на оценки, а не на веса. Обнулять уже посчитанный softmax неправильно: сумма перестанет равняться единице, и «вклад будущего» просто размажется. Минус бесконечность идёт до softmax.
- Перепутан порядок осей при разделении на головы. Нужно
viewв четыре оси и потомtranspose, а неreshapeнапрямую в (B, h, T, d_head) — иначе головы перемешаются с токенами. Ошибок не будет, будет молча обучающийся мусор. Ровно та же ловушка, что с осями изображений в Уроке 37. - Забыл про padding-маску. В батче тексты разной длины дополняют пустыми токенами. Если их не замаскировать, модель будет собирать контекст из пустоты. Это отдельная маска, не путать с причинной.
- Нет residual-связей или нормализации. На двух блоках сойдёт, на двенадцати — обучение развалится. Оба элемента не украшение, а условие работоспособности.
- Токенизатор от другой модели. Веса и токенизатор — комплект. Другой словарь означает другие номера токенов: модель получит бессмыслицу и не сообщит об этом.
15. Глоссарий
Self-attention
Внимание внутри одной последовательности: каждый токен строит query, key и value и собирает своё новое представление как взвешенную сумму value всех токенов.
Scaled dot-product attention
Формула softmax(Q·Kᵀ/√d_k)·V. Деление на корень из размерности удерживает оценки в разумном диапазоне, иначе softmax насыщается и градиент исчезает.
Multi-head attention
Несколько независимых вниманий в уменьшенной размерности параллельно. Результаты склеиваются и перемешиваются выходной матрицей. Разные головы ловят разные типы связей.
Positional encoding
Вектор позиции, прибавляемый к эмбеддингу токена. Синусоидальный (формула, работает на любой длине) или обучаемый (таблица, ограничен своим размером).
Causal mask
Причинная маска: оценки для токенов справа заменяются на минус бесконечность до softmax. Позволяет обучать генерацию параллельно, не давая подглядеть ответ.
BPE
Byte pair encoding: подсловная токенизация, где словарь строится многократной склейкой самой частой пары соседних элементов. Частые слова остаются целыми, редкие дробятся на куски.
16. Практика (75 минут)
- Досчитай раздел 3 до конца: получи веса внимания и выходные векторы для «кот» и «молоко» — так же, как в уроке сделано для «пьёт». Проверь, что веса в каждой строке дают единицу.
- Пересчитай тот же пример без деления на √2 и сравни распределения. Потом мысленно увеличь размерность до 64 и объясни себе, что станет с softmax.
- Запусти код из раздела 13 и убедись, что формы совпадают с комментариями. Напечатай
weights[0, 0]и глазами проверь нижнетреугольность. - Поставь
causal=Falseи сравни матрицу весов с предыдущей. Сформулируй одной фразой, какое семейство архитектур из раздела 9 ты только что получил. - Убери residual-связи: замени в
forwardстроки наx = aиx = self.ff(self.ln2(x)). Собери стопку из восьми блоков, прогони случайный вход и посмотри на разброс значений выхода черезy.std(). Верни residual и сравни. - Убери позиционное кодирование, подай в блок одну и ту же фразу в двух порядках токенов и проверь, что выходные векторы совпали с точностью до перестановки строк. Верни кодирование — совпадение исчезнет.
- Собери из
TransformerBlockстопку из четырёх блоков плюсnn.Linear(d_model, vocab)на выходе и посчитай общее число параметров:sum(p.numel() for p in model.parameters()). Прикинь, какая доля лежит в feed-forward, а какая во внимании. - Найди любой готовый токенизатор с подсловным словарём и прогони через него десяток русских слов: короткое, длинное, имя собственное, выдуманное. Посмотри на границы разбиения и на количество токенов.
- Запиши в
progress.md: формулу внимания по памяти, зачем нужно деление на корень, что сломалось в пунктах 5 и 6, и во сколько раз вырастет матрица внимания при удвоении длины текста.
17. Проверь себя
1. Что конкретно даёт отказ от рекуррентности?
Два выигрыша. Вся последовательность обрабатывается параллельно, а не по шагу за раз — обучение упирается в размер видеокарты, а не в длину текста. И путь между любыми двумя токенами становится равен одному шагу вместо десятков — далёкие связи перестают затухать. Именно это позволило масштабировать обучение.
2. Зачем в формуле внимания деление на √d_k?
Скалярное произведение векторов длины d имеет разброс порядка √d. Без деления при больших размерностях оценки разъезжаются, softmax насыщается почти в one-hot, производные падают к нулю и обучение встаёт. Деление возвращает разброс примерно к единице.
3. Почему трансформеру нужно позиционное кодирование, а RNN — нет?
RNN получает порядок бесплатно: она физически идёт по последовательности слева направо. Внимание же смотрит на все токены сразу и не различает их позиции — для него вход это множество, а не последовательность. Порядок приходится сообщать явно, прибавляя вектор позиции к эмбеддингу.
4. Чем encoder-only отличается от decoder-only и почему LLM устроены вторым способом?
Encoder-only видит контекст с обеих сторон и хорош в понимании — классификация, поиск сущностей. Decoder-only видит только левый контекст из-за причинной маски и потому умеет генерировать. LLM пошли вторым путём: обучающие данные не нужно размечать (годится любой текст), а любую задачу можно сформулировать как продолжение промпта.
5. Почему у моделей есть лимит контекста?
Матрица внимания содержит T² оценок: удвоение длины учетверяет вычисления и память. Плюс у обучаемого позиционного кодирования конечная таблица позиций. Лимит — не принципиальный запрет, а точка, где стоимость перестаёт окупаться.
18. Что должно остаться в голове
- Трансформер — это внимание без рекуррентности. Вся последовательность считается параллельно, путь между любыми двумя токенами равен одному шагу.
- Формула целиком: softmax(Q·Kᵀ/√d_k)·V. Query, key и value — три обучаемые проекции одного и того же входа.
- Деление на √d_k держит оценки в рабочем диапазоне. Без него softmax насыщается и градиент умирает.
- Multi-head — несколько вниманий в уменьшенной размерности параллельно, склейка и выходная матрица. Головы специализируются сами.
- Параллельность стоит потери порядка. Позицию возвращают явно: синусоидальным или обучаемым кодированием, прибавлением к эмбеддингу.
- Блок: LayerNorm → внимание → residual → LayerNorm → feed-forward → residual. Размерность сохраняется, поэтому блоки складываются стопкой. Residual и нормализация обязательны, а не опциональны.
- Внимание обменивает информацию между токенами, feed-forward обрабатывает каждый токен отдельно, и в нём же лежит большая часть параметров.
- Причинная маска ставит минус бесконечность на всё, что справа, — до softmax. Так генерация обучается параллельно, не подглядывая в ответ.
- Три семейства: encoder-only для понимания, decoder-only для генерации (современные LLM), encoder-decoder для перевода и суммаризации. Внимание в них одно и то же, различаются источники Q, K, V и маска.
- Токены — не слова и не буквы, а куски слов. Отсюда дороговизна русского текста и слепота модели к отдельным буквам. А квадратичная стоимость внимания по длине — источник лимита контекста.