Математика внимания шаг за шагом: от входной матрицы X до softmax и V
Пройдём одну голову внимания вручную: откуда берутся Q, K и V, зачем оценки делят на корень из d_k, как softmax превращает их в веса, плюс пример на NumPy и заметки о KV-кэше.
Каждый раз, когда языковая модель выдаёт токен, снова и снова выполняется одно вычисление — внимание. Это не фигура речи. На каждом прямом проходе, для каждого токена, на каждом слое выполняется одна и та же арифметика.
Большинство объяснений останавливаются на интуиции «запросов, ключей и значений», и сама математика остаётся чёрным ящиком. Эта статья его открывает. В ней разобрано, что на самом деле представляет собой входная матрица, откуда берутся Q, K и V, числовой пример, достаточно маленький, чтобы прогнать его самому, и почему это называется self-attention. После этого вы сможете пройти одну голову внимания вручную.
Какую задачу решает внимание
Возьмём предложение: «The animal didn't cross the street because it was tired» («Животное не перешло улицу, потому что оно устало»).
Добравшись до токена «it», модель должна понять, к чему относится это слово. К животному? К улице? Само по себе слово ничего не говорит, так что ответ должен прийти из окружающей последовательности.
Внимание решает это, позволяя каждому токену дать каждому другому токену вес — число, показывающее, насколько ему стоит смотреть на эту позицию, пока он строит своё обновлённое представление. Для «it» веса могли бы получиться такими:
"it" → "animal" : 0.65
"it" → "street" : 0.20
"it" → "tired" : 0.10
"it" → others : 0.05
Эти числа никто не пишет вручную. Они получаются из обученного вычисления, построенного на трёх векторах — Query, Key и Value, а вся формула помещается в одну строку:
Attention(Q, K, V) = softmax( QKᵀ / √dₖ ) · V
Всё дальнейшее — просто распаковка этой строки.
Шаг 0: входная матрица X
Прежде чем появятся Q, K или V, нужна входная матрица X.
Каждый токен в «The cat sat» отображается в обученный вектор эмбеддинга. При размере эмбеддинга d_model = 4 это выглядит так:
The → [0.2, 0.5, 0.1, 0.7]
cat → [0.8, 0.3, 0.6, 0.2]
sat → [0.4, 0.9, 0.5, 0.1]
Сложите векторы построчно, и получится X формы [n_tokens, d_model].
Одну деталь легко упустить: X не постоянна от слоя к слою. Каждый слой внимания берёт выход предыдущего слоя в качестве своей X. На первом слое X — это просто сырой токен плюс информация о позиции. Выше по стеку, скажем на 12-м слое GPT-3, в котором 96 слоёв, X уже кодирует глубокий контекстный смысл, накопленный за много раундов внимания.
Шаг 1: вычисляем Q, K и V
Запросы, ключи и значения никто не назначает вручную. Все три вычисляются из X через три отдельные обученные матрицы весов:
Q = X · WQ
K = X · WK
V = X · WV
WQ, WK и WV — матрицы параметров, которые замораживаются после окончания обучения. Формы сходятся так:
X.shape = [n, d_model]
WQ.shape = [d_model, d_k] → Q.shape = [n, d_k]
WK.shape = [d_model, d_k] → K.shape = [n, d_k]
WV.shape = [d_model, d_v] → V.shape = [n, d_v]
Различие важно, поэтому вот оно в таблице:
| Символ | Что это | Зависит от входа? |
|---|---|---|
WQ, WK, WV |
Обученные матрицы проекции | Нет, фиксированы после обучения |
Q, K, V |
Вычисляемые активации | Да, следуют за текущей X |
Вот почему «bank» в «river bank» (берег реки) и «bank» в «deposit at the bank» (вклад в банке) получают совершенно разные паттерны внимания. Окружающий контекст даёт другую X, которая проходит через те же матрицы весов и выходит другими векторами Q, K и V.
Удобная мысленная модель — поисковик:
- Q — то, что ищет токен.
- K — то, что каждый токен сообщает о себе.
- V — информация, которая возвращается при совпадении.
Q и K решают, куда смотреть. V решает, что реально уходит на выход.
Шаг 2: скалярное произведение QKᵀ
Возьмите Q и умножьте на транспонированную K. Получится квадратная матрица [n, n] сырых оценок, где элемент в строке i, столбце j показывает, насколько сильно токен i обращает внимание на токен j. До подстановки чисел она выглядит так:
Keys
cat eats fish
┌──────┬──────┬──────┐
Q cat│ ? │ ? │ ? │
eat│ ? │ ? │ ? │
fish│ ? │ ? │ ? │
└──────┴──────┴──────┘
Чем больше скалярное произведение, тем релевантнее токены друг другу.
Шаг 3: масштабирование на √dₖ
Когда d_k велико, сырые скалярные произведения раздуваются. При d_k = 512 оценки вроде [180, 210, 95] — обычное дело. Подайте их в softmax, и на выходе будет [0.00, 1.00, 0.00] — полностью схлопнувшееся распределение. Тогда градиенты затухают, и обучение встаёт.
Деление на √d_k держит оценки в разумном диапазоне. Именно за это деление механизм и получил название scaled dot-product attention.
Шаг 4: softmax превращает оценки в веса
Softmax применяется построчно, и каждая строка становится распределением вероятностей по всем токенам. Вот полный расчёт для оценок [3.2, 4.0, 1.0, 6.2, 8.0]:
exp(3.2) = 24.53
exp(4.0) = 54.60
exp(1.0) = 2.72
exp(6.2) = 492.75
exp(8.0) = 2,980.96
──────────
Σ = 3,555.56
Token 1: 24.53 / 3555.56 → 0.69%
Token 2: 54.60 / 3555.56 → 1.54%
Token 3: 2.72 / 3555.56 → 0.08%
Token 4: 492.75 / 3555.56 → 13.86%
Token 5: 2980.96 / 3555.56 → 83.83% ← winner
Токен 5 забирает 83,83%, хотя опережает токен 4 всего на 1,8 пункта. Именно такое нелинейное усиление позволяет модели уверенно опираться на самые релевантные токены.
Совет по стабильности: сначала вычтите максимум строки, как в
np.exp(x - x.max()), и только потом делите. 64-битное число с плавающей точкой не вмещаетexp(800), а после сдвига все экспоненты остаются в безопасных пределах, и итоговые вероятности математически идентичны.
Шаг 5: взвешенная сумма с V
Последний шаг умножает веса на V и всё складывает. Для «it» это значит:
output("it") =
0.65 × V("animal")
+ 0.20 × V("street")
+ 0.10 × V("tired")
+ 0.05 × V(rest)
На выходе — новый вектор для «it», в котором в основном информация из «animal», немного из «street» и совсем чуть-чуть из остального. Это контекстуализированное представление и уходит в следующий слой.
Полный пример на NumPy
Здесь предложение «cat eats fish» с d_model = 2:
import numpy as np
X = np.array([[1, 2], [3, 1], [2, 4]], dtype=float)
WQ = np.array([[1, 0], [0, 1]], dtype=float) # identity
WK = np.array([[1, 0], [0, 1]], dtype=float) # identity
WV = np.array([[0, 1], [1, 0]], dtype=float) # swaps dimensions
Q, K, V = X @ WQ, X @ WK, X @ WV
# Raw scores for "fish" (Q=[2,4]) against each key:
# fish→cat: [2,4]·[1,2] = 10
# fish→eats: [2,4]·[3,1] = 10
# fish→fish: [2,4]·[2,4] = 20 ← self-attention dominates
scores = Q @ K.T / np.sqrt(2)
e = np.exp(scores - scores.max(-1, keepdims=True))
weights = e / e.sum(-1, keepdims=True) # softmax row-wise
output = weights @ V
# output[fish] ≈ [3.997, 2.001] ≈ V(fish)
# fish self-attends at ~99.8% in this toy example
В реальной обученной модели WQ и WK нарушили бы эту симметрию и дали бы осмысленное внимание между токенами. Единичные матрицы нужны лишь для того, чтобы арифметика оставалась читаемой.
Почему это называется self-attention
Q, K и V берутся из одной и той же исходной матрицы X. Каждый токен порождает запрос, ключ и значение из собственного представления, а затем каждый токен обращает внимание на каждый другой в этой одной последовательности.
Это отличает self-attention, как в GPT и LLaMA, от cross-attention, как в моделях encoder-decoder вроде T5, где Q берётся из одной последовательности, а K и V — из другой.
Практические инженерные заметки
KV-кэш. Пересчитывать K и V для всех предыдущих токенов на каждом шаге генерации стоило бы O(n²). KV-кэш хранит все прежние векторы K и V и переиспользует их, снижая стоимость шага до O(n) в обмен на память, растущую с длиной последовательности.
Размерность головы. Обычная конфигурация — d_k = d_model / num_heads. У GPT-2 это 768 / 12 = 64, у GPT-3 — 12288 / 96 = 128.
Grouped Query Attention (GQA). LLaMA-2, Mistral и Gemma позволяют нескольким головам Query делить одну голову KV. Это резко сокращает память под KV-кэш и во многом объясняет, почему эти модели справляются с длинным контекстом на потребительском железе.
Внимание в одном предложении
Никакой магии тут нет. Внимание — это обученная, дифференцируемая таблица поиска с мягкими весами, которая заново пересчитывается для каждого входа и даёт представления, несущие всю реляционную структуру текста.