Self-Attention и Multi-Head Attention

⏱ 40 мин · 🧩 заданий: 6

Самовнимание: токены смотрят друг на друга

В прошлом уроке запросы шли от одной последовательности (слова перевода), а ключи и значения — от другой (слова оригинала). В самовнимании (self-attention) всё берётся из одной последовательности: каждый токен одновременно

  • задаёт вопрос — свой запрос q_i: «какая информация мне нужна?»;
  • предлагает себя — свой ключ k_i: «вот что у меня есть»;
  • отдаёт содержимое — своё значение v_i: «вот что я передам тому, кто на меня посмотрит».

Все три вектора получаются из эмбеддинга токена умножением на обучаемые матрицы:

Q = XW_Q, \qquad K = XW_K, \qquad V = XW_V, \qquad \text{out} = \text{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V

Здесь X — матрица (T, d): эмбеддинги T токенов по строкам. Выход — тоже T векторов, но теперь каждый из них знает о контексте: вектор «ключа» в предложении про родник подмешал информацию от «бил» и «холодный».

X (T×d) ──┬── · W_Q ──► Q ──┐ │ ├── softmax(Q·Kᵀ/√d) ──► веса (T×T) ──┐ ├── · W_K ──► K ──┘ ├──► выход (T×d_v) │ │ └── · W_V ──► V ─────────────────────────────────────────┘
import numpy as np
import vis

rng = np.random.default_rng(0)
tokens = ["кот", "сел", "на", "тёплый", "коврик"]
T, d, dk = len(tokens), 8, 4
X = rng.normal(size=(T, d))                         # эмбеддинги (пока случайные)
Wq, Wk, Wv = (rng.normal(size=(d, dk)) for _ in range(3))

Q, K, V = X @ Wq, X @ Wk, X @ Wv
scores = Q @ K.T / np.sqrt(dk)
weights = np.exp(scores - scores.max(axis=1, keepdims=True))
weights /= weights.sum(axis=1, keepdims=True)
out = weights @ V
print("Q, K, V:", Q.shape, K.shape, V.shape, " веса:", weights.shape, " выход:", out.shape)
vis.heatmap(weights, xlabels=tokens, ylabels=tokens, title="Веса самовнимания (случайные матрицы)")

Матрицы здесь случайные, поэтому и веса бессмысленные. В обученной модели W_Q, W_K, W_V подбираются градиентным спуском так, чтобы каждый токен смотрел туда, откуда ему полезно взять информацию.

Зачем три разные матрицы

Почему бы не сравнивать сами эмбеддинги: \text{softmax}(XX^\top)X? Тогда каждый токен больше всего смотрел бы на себя (скалярное произведение вектора с собой обычно самое большое), а сходство было бы симметричным: если «кот» смотрит на «сел», то и «сел» на «кот» с той же оценкой. А нужно другое: глаголу полезно найти подлежащее, прилагательному — существительное, к которому оно относится. «Что я ищу» и «что я предлагаю» — разные вещи. Отдельные W_Q и W_K позволяют выучить такие несимметричные связи, а W_V — решить, какую именно информацию передавать.

Самовнимание не видит порядок

Переставим токены — что станет с выходом?

import numpy as np
import nanotorch as nt

rng = np.random.default_rng(0)
X = rng.normal(size=(1, 5, 8))                      # (батч, T, d)
nt.manual_seed(0)
mha = nt.MultiHeadAttention(d_model=8, num_heads=1)
out = mha(nt.tensor(X)).data[0]

perm = [3, 0, 4, 1, 2]
out_perm = mha(nt.tensor(X[:, perm])).data[0]
print("выход для переставленного входа == переставленный выход:", np.allclose(out_perm, out[perm]))

Каждый токен получил ровно тот же вектор, что и раньше, — просто на другом месте. Самовнимание работает с множеством токенов: веса зависят только от содержимого векторов, а не от их позиций. «Собака кусает человека» и «человек кусает собаку» для него неразличимы. Поэтому к эмбеддингам добавляют позиционные кодировки — это первое, что мы сделаем в следующем уроке.

Цена: квадратичная сложность

Матрица весов имеет размер T \times T: каждый токен сравнивается с каждым. Для 1000 токенов — миллион весов на каждую голову каждого слоя, для 100 000 токенов — 10 миллиардов. Время и память растут квадратично с длиной текста. Это главное ограничение трансформеров, и на борьбу с ним придумано множество приёмов: эффективные реализации вроде FlashAttention, разреженное и локальное внимание, кэширование ключей и значений при генерации.

Зато у самовнимания есть огромный плюс: путь между любыми двумя токенами — один шаг, как бы далеко они ни стояли. И все позиции обрабатываются параллельно — это идеально подходит для видеокарт.

Многоголовое внимание

Одна голова внимания даёт для каждого токена одно распределение весов — один «взгляд». Но связей в тексте много сразу: синтаксис (кто что делает), кореференция («он» → «кот»), соседние слова, тема. Multi-head attention запускает h голов параллельно, у каждой — свои W_Q^{(i)}, W_K^{(i)}, W_V^{(i)} меньшей размерности d_{\text{head}} = d_{\text{model}} / h:

\text{head}_i = \text{Attention}(XW_Q^{(i)},\, XW_K^{(i)},\, XW_V^{(i)}), \qquad \text{MHA}(X) = \text{concat}(\text{head}_1, \dots, \text{head}_h)\, W_O

Выходы голов склеиваются обратно в вектор размера d_{\text{model}}, а матрица W_O смешивает информацию от разных голов.

┌─► голова 1: смотрит на соседние слова ──────┐ X ──────┼─► голова 2: ищет подлежащее для глагола ────┼──► concat ──► · W_O ──► выход ├─► голова 3: связывает «он» с «кот» ─────────┤ └─► … ┘

На практике все головы считаются одним матричным умножением: делают одну большую W_Q размера d_{\text{model}} \times d_{\text{model}}, а результат разрезают на h частей по столбцам. Поэтому многоголовое внимание стоит столько же, сколько одноголовое той же ширины.

import inspect
import numpy as np
import nanotorch as nt

nt.manual_seed(0)
mha = nt.MultiHeadAttention(d_model=16, num_heads=4)
x = nt.tensor(np.random.default_rng(0).normal(size=(2, 6, 16)))    # батч из 2 последовательностей по 6 токенов
out = mha(x)
print(mha)
print("вход:", x.shape, " выход:", out.shape)
print("веса внимания (батч, головы, T, T):", mha.attn.shape)
print("параметров:", mha.num_params(), "= 4 · (16·16 + 16)")
print()
print(inspect.getsource(nt.MultiHeadAttention.forward))
print(inspect.getsource(nt.MultiHeadAttention._split))

В _split тензор формы (N, T, d_{\text{model}}) превращается в (N, h, T, d_{\text{head}}): последняя ось режется на головы, и ось голов переезжает вперёд. Дальше scaled_dot_product_attention работает со всеми головами сразу — матричное умножение @ в NumPy применяется к последним двум осям, а остальные считаются «батчем».

Итог

  • Self-attention: Q = XW_Q, K = XW_K, V = XW_V из одной последовательности; каждый токен собирает информацию из всех, и выход — контекстные векторы.
  • Три разные проекции позволяют выучить несимметричные связи «что ищу» и «что предлагаю».
  • Самовнимание эквивариантно к перестановкам — о порядке токенов нужно сообщить отдельно.
  • Сложность O(T^2) по длине; зато любые два токена связаны за один шаг, и всё считается параллельно.
  • Multi-head: h голов размерности d_{\text{model}}/h смотрят на разные связи, их выходы склеиваются и умножаются на W_O; параметров 4d_{\text{model}}^2 (+ bias).

Практика решено 0 из 6

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Один вариант ответа

Чем self-attention отличается от внимания в модели перевода из прошлого урока?

Задание 2 Ввод ответа

Последовательность из 1000 токенов. Сколько чисел в матрице весов внимания одной головы?

Задание 3 Ввод ответа

Сколько обучаемых весов (без bias) в слое multi-head attention с d_{\text{model}} = 512 и 8 головами?

Задание 4 Один вариант ответа

Переставим токены входа в другом порядке (без позиционных кодировок). Что произойдёт с выходом self-attention?

Задание 5 Программа на Python

Self-attention. Напишите self_attention(X, Wq, Wk, Wv): X — матрица токенов (T, d), Wq, Wk, Wv — матрицы (d, d_k). Посчитайте Q = XW_q, K = XW_k, V = XW_v и верните (out, weights), где weights = \text{softmax}(QK^\top/\sqrt{d_k}) по строкам, out = weights @ V.

Какие тесты проверяют код (2)
rng = np.random.default_rng(0)
X = rng.normal(size=(5, 8))
Wq, Wk, Wv = rng.normal(size=(8, 4)), rng.normal(size=(8, 4)), rng.normal(size=(8, 6))
out, w = self_attention(X, Wq, Wk, Wv)
assert w.shape == (5, 5) and out.shape == (5, 6), "weights — (T, T), out — (T, d_v)"
s = (X @ Wq) @ (X @ Wk).T / 2.0
ref = np.exp(s - s.max(1, keepdims=True)); ref /= ref.sum(1, keepdims=True)
assert np.allclose(w, ref), "weights = softmax(Q·Kᵀ/√d_k), Q = X·Wq, K = X·Wk"
assert np.allclose(out, ref @ (X @ Wv)), "out = weights · V, V = X·Wv"

rng = np.random.default_rng(1)
X = rng.normal(size=(4, 3))
Wq, Wk, Wv = rng.normal(size=(3, 3)), rng.normal(size=(3, 3)), rng.normal(size=(3, 3))
perm = [2, 0, 3, 1]
out, _ = self_attention(X, Wq, Wk, Wv)
out_p, _ = self_attention(X[perm], Wq, Wk, Wv)
assert np.allclose(out_p, out[perm]), "Перестановка входа должна переставлять выход"

Задание 6 Программа на Python

Разбиение на головы. Напишите две функции для тензора формы (T, d_model):

  • split_heads(x, h) → форма (h, T, d_model // h): голова i получает столбцы [i·d_head, (i+1)·d_head);
  • merge_heads(x) — обратная операция: (h, T, d_head) → (T, h·d_head).

Подсказка: reshape и transpose.

Какие тесты проверяют код (2)
x = np.arange(24.0).reshape(3, 8)        # T = 3, d_model = 8
s = split_heads(x, 2)
assert s.shape == (2, 3, 4)
assert np.array_equal(s[0], x[:, :4]) and np.array_equal(s[1], x[:, 4:]), "Голова 0 — первые 4 столбца, голова 1 — следующие 4"

x = np.random.default_rng(0).normal(size=(5, 12))
for h in (1, 2, 3, 4):
    assert np.array_equal(merge_heads(split_heads(x, h)), x), f"merge_heads(split_heads(x, {h})) должно вернуть x"

Войдите, чтобы отмечать пройденные уроки.