Scaled dot-product self-attention
Разбираем формулу softmax(QKᵀ/√d_k)V по шагам с размерностями, выясняем, зачем делить на √d_k и как причинная маска не даёт модели подсматривать в будущее, и пишем всё на NumPy.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В прошлом уроке внимание было мягким поиском: запрос сравнивается с ключами, веса считаются через softmax, ответ — смесь значений. Теперь запишем ту формулу, которая стоит в каждом слое GPT, Qwen и DeepSeek:
Это масштабированное скалярное вниманиемасштабированное скалярное внимание (SDPA)EN scaled dot-product attention (SDPA)softmax(Q Kᵀ / √d_k) V — формула внимания трансформера (Vaswani и др., 2017). В PyTorch — F.scaled_dot_product_attention. из статьи «Attention Is All You Need». В PyTorch это одна строка, но у каждого символа есть причина. После урока вы сможете назвать форму каждого тензора, объяснить, зачем корень из и что делает причинная маска, и напишете всё сами на NumPy. В модуле 4.6 эта функция станет сердцем вашей GPT.
От эмбеддингов к Q, K и V
Заголовок раздела «От эмбеддингов к Q, K и V»На вход слою приходит матрица размера : токенов, по строке-эмбеддингу на токен (урок 4.3). Возьмём сквозной пример — китайскую идиому «карп прыгает через Врата дракона» в коротком русском варианте: «Карп прыгает через врата». Для простоты считаем, что каждое слово — один токен, так что .
В самовниманиесамовниманиеEN self-attention запросы, ключи и значения получаются из одной матрицы тремя обучаемыми матрицами:
и имеют размер , — . Строка матрицы — запрос токена , строка матрицы — его ключ, строка матрицы — его значение.
Зачем три разные матрицы? Один и тот же токен играет три роли:
- запрос — «что я ищу». Для «прыгает»: «кто совершает действие?»;
- ключ — «по каким признакам меня можно найти». У «Карп»: «существительное, живое, подлежащее»;
- значение — «что я передам тому, кто на меня посмотрел». У «Карп»: смысл слова (рыба, сказочный сюжет).
Почему бы не сравнивать сами эмбеддинги, ? По двум причинам. Во-первых, такие оценки симметричны: насколько «прыгает» интересуется «Карпом», настолько и наоборот, а реальные связи несимметричны. Во-вторых, обычно больше всех остальных произведений, и токен смотрел бы в основном на себя. Отдельные и снимают оба ограничения, а позволяет передавать не те признаки, по которым токен нашли.
Формула по шагам
Заголовок раздела «Формула по шагам»| Шаг | Операция | Форма | Смысл |
|---|---|---|---|
| 1 | три роли каждого токена | ||
| 2 | — насколько токен интересуется токеном | ||
| 3 | возвращаем оценкам разумный масштаб | ||
| 4 | маска | будущие позиции получают | |
| 5 | по строкам | строка — распределение внимания токена | |
| 6 | строка — взвешенное среднее значений |
flowchart LR X["X (T, d_model)"] --> Q["Q = X·W_Q"] X --> K["K = X·W_K"] X --> V["V = X·W_V"] Q --> S["S = Q·Kᵀ (T, T)"] K --> S S --> SC["÷ √d_k"] SC --> M["маска: будущее = −∞"] M --> SM["softmax по строкам"] SM --> Y["Y = A·V (T, d_v)"] V --> Y
Матрицу называют оценками вниманияоценки внимания (attention scores)EN attention scores (logits)Сходство запроса с каждым ключом до softmax, в трансформере — q·k / √d_k. Могут быть любыми числами; именно их маскируют значением −∞. (scores), матрицу — весами вниманиявеса вниманияEN attention weightsРезультат softmax от оценок внимания. Неотрицательны, в сумме по ключам дают 1; показывают, «куда смотрит» запрос.. Обратите внимание на форму : каждый токен сравнивается с каждым. Это та самая квадратичная стоимость, к которой мы вернёмся в уроке 3.
Считаем руками
Заголовок раздела «Считаем руками»Возьмём три первых токена — «Карп прыгает через» — и маленькие векторы . Пусть проекции уже сделаны:
Шаг 2. — скалярное произведение запроса и ключа . Например, :
Шаг 3. Делим на :
Шаги 4–5. Без маски первая строка дала бы веса : «Карп» почти половину внимания отдал бы слову «через», которое стоит после него. Для языковой модели это подглядывание в ответ. С маской:
Вторая строка — , третья — .
Шаг 6. Выход — веса, умноженные на значения:
Первый токен видит только себя и получает своё же значение. Второй на 80% взял значение «прыгает» и на 20% — «Карп». Новый вектор каждого токена — это смесь информации из тех токенов, на которые он посмотрел. Ровно этот пример проверяет один из тестов задания ниже.
В настоящем коде на вход приходит тензор : текстов сразу. Оператор @ в NumPy и
PyTorch умножает две последние оси и транслирует (broadcasting) остальные, поэтому формулы не меняются.
Только транспонировать нужно по двум последним осям: np.swapaxes(K, -1, -2) или k.transpose(-2, -1).
Зачем делить на √dk
Заголовок раздела «Зачем делить на √dk»Оценки растут вместе с размерностью
Заголовок раздела «Оценки растут вместе с размерностью»Скалярное произведение — сумма слагаемых: . Пусть компоненты и независимы, со средним 0 и дисперсией 1 (примерно так и есть в начале обучения, когда вход нормализован). Напомним из модуля 1.3: дисперсиядисперсияEN varianceVar[X] = E[(X − E[X])²] — средний квадрат отклонения от матожидания. — средний квадрат отклонения от среднего, а корень из неё, стандартное отклонение, — типичный размах значений; — среднее значение (матожидание). Тогда у каждого слагаемого среднее 0 и дисперсия . Дисперсии независимых слагаемых складываются:
Что здесь написано: чем длиннее векторы, тем сильнее разброс оценок. У Llama 3 и Qwen размер головы . При единичной дисперсии компонент стандартное отклонение оценок было бы , а отдельные оценки доходили бы до и дальше. После деления на дисперсия снова равна 1 при любом .
Почему большие оценки — это плохо
Заголовок раздела «Почему большие оценки — это плохо»Из урока 1.3.2 вы знаете: разница логитов на 1 — это разница вероятностей в раз. Разница в 20 — в полмиллиарда раз. Softmax превращается почти в one-hot: весь вес у одного токена.
Плохо не только то, что внимание становится «жёстким», но и то, что оно перестаёт учиться. Производные softmax:
Если , то , а при — уже 0.1875, в 19 раз больше. Хуже всего ключам, на которые голова почти не смотрит. Градиент по оценке равен , где — градиент loss по весу . Если , то и этот градиент почти 0. Голова «залипает» на текущем фаворите: даже если другой токен был бы полезнее, сигнал «посмотри туда» до его оценки почти не доходит, и модель плохо учится, куда смотреть. Это та же беда, что и у сигмоиды в насыщении.
Проверим числами
Заголовок раздела «Проверим числами»Возьмём случайные запросы и ключи с единичной дисперсией компонент, по 16 ключей на запрос, и посмотрим на разброс оценок, средний максимальный вес и норму якобиана softmax (насколько сильно градиент проходит насквозь):
import numpy as np
rng = np.random.default_rng(0)T = 16 # сколько ключей видит каждый запрос
def softmax(z): z = z - z.max(axis=-1, keepdims=True) e = np.exp(z) return e / e.sum(axis=-1, keepdims=True)
def jac_norm(p): """Норма якобиана softmax: diag(p) - p pᵀ. Чем она меньше, тем слабее градиент.""" # Якобиан (урок 1.2.3) — таблица всех производных ∂p_i/∂z_j, формулы см. выше. Для каждой из n строк p: # первый einsum кладёт p на диагональ матрицы (diag(p)), второй строит матрицу p_i * p_j (внешнее произведение) J = np.einsum("ni,ij->nij", p, np.eye(p.shape[-1])) - np.einsum("ni,nj->nij", p, p) return np.linalg.norm(J, axis=(1, 2)).mean()
print(" d_k std(q·k) max вес: без / с √d градиент: без / с √d")for d_k in [4, 16, 64, 128, 512]: q = rng.standard_normal((2000, d_k)) # 2000 случайных запросов k = rng.standard_normal((2000, T, d_k)) # у каждого — 16 случайных ключей s = np.einsum("nd,ntd->nt", q, k) # скалярные произведения, (2000, 16) p_raw, p_sc = softmax(s), softmax(s / np.sqrt(d_k)) print(f"{d_k:4d} {s.std():9.2f} {p_raw.max(-1).mean():11.2f} / {p_sc.max(-1).mean():.2f}" f" {jac_norm(p_raw):13.3f} / {jac_norm(p_sc):.3f}") d_k std(q·k) max вес: без / с √d градиент: без / с √d 4 2.00 0.43 / 0.24 0.322 / 0.297 16 4.05 0.68 / 0.25 0.279 / 0.304 64 8.04 0.85 / 0.25 0.175 / 0.305 128 11.26 0.89 / 0.25 0.136 / 0.304 512 22.61 0.95 / 0.25 0.072 / 0.305Разброс оценок растёт точно как : 2, 4, 8, 11.3, 22.6. Без масштаба при один ключ из 16 забирает в среднем 95% веса, а градиент через softmax слабее в 4 раза. С делением на картина одинакова при любой размерности. Это и есть цель: поведение слоя не должно зависеть от того, насколько широкую модель вы построили.
Тонкость для любознательных: при делении на градиент к и по правилу цепочки ещё и умножается на . Так что дело не в абсолютной величине градиента (её всё равно выравнивает Adam), а в том, как он распределён: насыщенный softmax отдаёт почти весь сигнал одному «победителю».
Причинная маска
Заголовок раздела «Причинная маска»Языковая модель предсказывает следующий токен (урок 4.1.1). При обучении мы подаём весь текст «Карп прыгает через врата» и требуем на каждой позиции предсказать следующий токен: после «Карп» — «прыгает», после «Карп прыгает» — «через» и так далее. На вход идёт настоящий текст, а не то, что сгенерировала сама модель, — это teacher forcingteacher forcing (обучение на верном префиксе)EN teacher forcingПри обучении модель на каждом шаге видит настоящий текст, а не свои предсказания. Поэтому все позиции можно обучать параллельно.. RNN считала бы эти предсказаний шаг за шагом, а трансформер делает их за один параллельный проход: обучающих примеров сразу. Это одна из главных причин, почему трансформеры учатся так быстро.
Но в self-attention без ограничений токен «Карп» видит «прыгает» и может просто скопировать ответ. Модель научится жульничать: loss на обучении упадёт почти до нуля, а при генерации, где будущего ещё нет, она ничего не сможет. Нужно правило: токен смотрит только на позиции . Это причинная маскапричинная (каузальная) маскаEN causal maskЗапрещает токену смотреть на будущие позиции — оценки выше диагонали заменяются на −∞ до softmax. Нужна, чтобы GPT-подобная модель училась предсказывать следующий токен на всех позициях сразу..
Перед softmax ставим во все клетки выше диагонали:
| Карп | прыгает | через | врата | |
|---|---|---|---|---|
| Карп | ||||
| прыгает | ||||
| через | ||||
| врата |
Так как , будущее получает вес ровно ноль, а softmax нормирует оставшиеся позиции, и строка
по-прежнему в сумме даёт 1. Матрица весов становится нижнетреугольной. В NumPy маску удобно строить так:
np.tril(np.ones((T, T), dtype=bool)) — True там, куда смотреть можно.
Ловушки
Заголовок раздела «Ловушки»- Маска нулём вместо . Нулевая оценка даёт вес , будущее остаётся видимым. Тесты задания ловят это сразу.
- Маска после softmax. Если обнулить веса будущего уже после softmax, строки перестанут суммироваться в 1. Маскируйте оценки, а не веса.
- Разные соглашения о
True. В нашем коде и вF.scaled_dot_product_attentionбулева маска означает «можно смотреть». Вnn.MultiheadAttentionнаоборот:Trueзначит «запрещено». Перед использованием чужой функции читайте документацию. - Полностью закрытая строка. Причинная маска всегда оставляет диагональ, но маска паддинга (позиций с токеном-заполнителем
PAD из урока 4.2.1, на которые смотреть нельзя) может закрыть строку
целиком. Тогда все оценки , и softmax даст
nan. Поэтому в коде часто пишут не , а очень большое отрицательное число. - Маска на одну позицию шире.
np.tril(..., k=1)открывает позицию — ровно тот токен, который нужно предсказать. Ничего не падает: loss на обучении подозрительно низкий, а генерация — мусор. Как поймать такой баг тестом — в уроке 11.1.2.
Без маски получается двунаправленное внимание, как в BERT: каждый токен видит весь текст. Это хорошо для понимания и классификации, но генерировать так нельзя. Все авторегрессионные LLM, то есть генерирующие текст токен за токеном (GPT, Llama, Qwen, DeepSeek), — с причинной маской. Подробнее о семействах архитектур — в уроке 4.5.1.
Что видно в весах внимания
Заголовок раздела «Что видно в весах внимания»Строка матрицы показывает, куда смотрит токен . Если нарисовать её у обученной модели, увидите осмысленные картины: местоимение смотрит на существительное, глагол — на подлежащее. Но есть и странность: во многих головах огромная доля веса уходит на самый первый токен текста, даже если он ничего не значит.
Причина в softmax: веса обязаны давать в сумме 1. Если голове «нечего сказать» про текущий токен, вес всё равно нужно куда-то деть. Первый токен виден со всех позиций (маска его никогда не закрывает), и модель учится сваливать лишний вес туда. Явление назвали attention sink«сток» внимания (attention sink)EN attention sinkПервые токены получают аномально большой вес внимания независимо от смысла — туда softmax «сливает» лишнюю массу. Не путайте со сходным китайским термином из d2l-zh («схождение внимания»): там так переведено attention pooling (взвешенное усреднение значений). («сток внимания») в работе StreamingLLM (Xiao и соавт., 2023). Там же нашли практическое следствие. Если при генерации длинного текста хранить ключи и значения только последних токенов («скользящее окно»), качество рушится, как только первые токены выпадают из окна. Стоит сохранить ключи и значения четырёх первых токенов, и модель снова работает нормально. Как от стока избавились в Qwen — ниже.
Практика
Заголовок раздела «Практика»Задание в браузере
Заголовок раздела «Задание в браузере»Реализуйте causal_mask, softmax, attention и causal_self_attention. Тесты проверят пример из урока,
масштаб , причинность, совпадение с эталоном-циклом и работу на батче.
import numpy as np
def causal_mask(T):
"""Причинная маска «куда можно смотреть» формы (T, T), dtype=bool.
mask[i, j] = True, если токен i может смотреть на токен j, то есть j <= i
(прошлое и сам токен). Будущее (j > i) — False.
Для T = 3:
[[ True, False, False],
[ True, True, False],
[ True, True, True]]
"""
# TODO: одна строка (подсказка: np.tril)
...
def softmax(x):
"""Устойчивый softmax по последней оси (как в уроке 1.3, но для массивов).
Должен работать со строками, где есть -np.inf: exp(-inf) = 0, такие позиции получают вес 0.
"""
# TODO: вычтите максимум по последней оси (keepdims=True), возьмите exp, поделите на сумму
...
def attention(Q, K, V, mask=None):
"""Scaled dot-product attention.
Q: (..., T_q, d_k), K: (..., T_k, d_k), V: (..., T_k, d_v) — «...» может быть пустым или батчем.
mask: None или bool-массив (T_q, T_k); False — туда смотреть нельзя (оценка = -inf).
Возвращает кортеж (out, weights):
weights = softmax(Q Kᵀ / sqrt(d_k)) — форма (..., T_q, T_k), строки в сумме дают 1;
out = weights @ V — форма (..., T_q, d_v).
"""
# TODO: 1) scores = Q @ Kᵀ / sqrt(d_k) (транспонируйте две последние оси: np.swapaxes(K, -1, -2))
# 2) если mask задана — np.where(mask, scores, -np.inf)
# 3) weights = softmax(scores); 4) out = weights @ V
...
def causal_self_attention(X, W_q, W_k, W_v):
"""Причинное self-attention для последовательности эмбеддингов.
X: (T, d_model) или (B, T, d_model); W_q, W_k: (d_model, d_k); W_v: (d_model, d_v).
Возвращает (out, weights) как attention(): out — (..., T, d_v), weights — (..., T, T).
"""
# TODO: Q = X @ W_q, K = X @ W_k, V = X @ W_v; T = X.shape[-2]; attention(Q, K, V, causal_mask(T))
...
# Проверьте себя кнопкой «Запустить» — пример из урока:
Q = np.array([[2.0, 0.0], [0.0, 2.0], [1.0, 1.0]])
K = np.array([[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]])
V = np.array([[10.0, 0.0], [0.0, 10.0], [5.0, 5.0]])
print(causal_mask(3))
res = attention(Q, K, V, causal_mask(3))
if res is not None:
out, w = res
print(np.round(w, 3)) # ожидается [[1 0 0] [0.196 0.804 0] [0.248 0.248 0.503]]
print(np.round(out, 3)) # ожидается [[10 0] [1.956 8.044] [5 5]]
То же на PyTorch (запустите локально)
Заголовок раздела «То же на PyTorch (запустите локально)»import math
import torchimport torch.nn.functional as F
torch.manual_seed(0)
def attention(q, k, v, causal=True): """softmax(Q Kᵀ / √d_k) V с причинной маской. q, k, v: (B, T, d).""" d_k = q.size(-1) scores = q @ k.transpose(-2, -1) / math.sqrt(d_k) # (B, T, T) if causal: T = q.size(-2) allowed = torch.ones(T, T, dtype=torch.bool).tril() # True — смотреть можно # ~ — логическое «НЕ»: ~allowed = True там, где смотреть НЕЛЬЗЯ; туда пишем −∞ scores = scores.masked_fill(~allowed, float("-inf")) weights = scores.softmax(dim=-1) # строки в сумме дают 1 return weights @ v, weights
B, T, d_model, d_k = 2, 4, 16, 8x = torch.randn(B, T, d_model) # «эмбеддинги» 4 токенов# три случайные матрицы (d_model, d_k); деление на √d_model держит выходы в разумном масштабе (инициализация, модуль 3.4)W_q, W_k, W_v = (torch.randn(d_model, d_k) / math.sqrt(d_model) for _ in range(3))q, k, v = x @ W_q, x @ W_k, x @ W_v # (B, T, d_k)
out, w = attention(q, k, v)ref = F.scaled_dot_product_attention(q, k, v, is_causal=True) # встроенная версия (FlashAttention на GPU)print("формы:", tuple(out.shape), tuple(w.shape))print("совпадает с PyTorch:", torch.allclose(out, ref, atol=1e-6))print(w[0].round(decimals=2))Ожидаемый вывод:
формы: (2, 4, 8) (2, 4, 4)совпадает с PyTorch: Truetensor([[1.0000, 0.0000, 0.0000, 0.0000], [0.3900, 0.6100, 0.0000, 0.0000], [0.4400, 0.3100, 0.2500, 0.0000], [0.3500, 0.2400, 0.1500, 0.2600]])Нули выше диагонали — работа маски. В настоящих моделях вы будете вызывать F.scaled_dot_product_attention:
на GPU она выбирает быстрое ядро (FlashAttention), которое не хранит матрицу целиком. Но считает
она ровно то же, что ваши десять строк, и теперь вы это проверили.
Проверь себя
Заголовок раздела «Проверь себя»- Self-attention: , , , затем . Оценки и веса имеют форму , выход — .
- Три матрицы нужны, потому что токен играет три роли: что ищет, по чему его находят, что он отдаёт.
- Деление на возвращает оценкам дисперсию 1. Без него softmax насыщается и плохо пропускает градиент.
- Причинная маска ( выше диагонали до softmax) запрещает смотреть в будущее и позволяет учиться на всех позициях за один проход.
- Softmax обязан раздать весь вес, отсюда attention sink. Китайские команды лечат «перегрев» оценок гейтами (Qwen) и QK-Clip (Kimi).
Дальше: одна голова умеет задавать токену только один «вопрос» за раз. Как дать ей несколько — и во что это обходится по памяти и вычислениям — в уроке «Многоголовое внимание».