Главная / Статьи / Математика внимания шаг за шагом: от входной матрицы X до softmax и V

Математика внимания шаг за шагом: от входной матрицы X до softmax и V

Пройдём одну голову внимания вручную: откуда берутся Q, K и V, зачем оценки делят на корень из d_k, как softmax превращает их в веса, плюс пример на NumPy и заметки о KV-кэше.

1565 слов

Каждый раз, когда языковая модель выдаёт токен, снова и снова выполняется одно вычисление — внимание. Это не фигура речи. На каждом прямом проходе, для каждого токена, на каждом слое выполняется одна и та же арифметика.

Большинство объяснений останавливаются на интуиции «запросов, ключей и значений», и сама математика остаётся чёрным ящиком. Эта статья его открывает. В ней разобрано, что на самом деле представляет собой входная матрица, откуда берутся 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-кэш и во многом объясняет, почему эти модели справляются с длинным контекстом на потребительском железе.

Внимание в одном предложении

Никакой магии тут нет. Внимание — это обученная, дифференцируемая таблица поиска с мягкими весами, которая заново пересчитывается для каждого входа и даёт представления, несущие всю реляционную структуру текста.