Расчёт весов внимания: softmax(QKᵀ)·V вручную (запускаемый пример)

Сердце трансформера — одна формула. В этом уроке мы посчитаем self-attention руками на трёх токенах и увидим каждый шаг вживую.

Self-attention вычисляется как softmax(QKᵀ / √d) · V: похожести запросов и ключей превращаются в веса, которыми усредняются значения.

Формула по шагам

Для каждого токена-запроса делаем четыре действия:

  1. Похожести. Скалярное произведение его query с key каждого токена: QKᵀ. Чем сильнее совпадение, тем больше число.
  2. Масштабирование. Делим на √d (корень из размерности ключа), чтобы числа не «разъезжались» при большой размерности.
  3. Softmax. Превращаем похожести в веса внимания — положительные, в сумме равные 1.
  4. Взвешенная сумма. Складываем value всех токенов с этими весами — это и есть выход внимания для нашего токена.

Шаг softmax отдельно

Поскольку softmax — ключевая операция, посмотрим на неё изолированно. Она превращает произвольные числа (логиты) в вероятности.

import math

def softmax(scores):
    # Вычитаем максимум для численной устойчивости (результат не меняется).
    m = max(scores)
    exps = [math.exp(s - m) for s in scores]
    total = sum(exps)
    return [e / total for e in exps]

raw = [2.0, 1.0, 0.1]
probs = softmax(raw)

print("Сырые числа (логиты):", raw)
print("После softmax:")
for s, p in zip(raw, probs):
    print(f"  логит {s:>4} -> вероятность {p:.3f}")
print("Сумма вероятностей:", round(sum(probs), 5))

Вывод:

Сырые числа (логиты): [2.0, 1.0, 0.1]
После softmax:
  логит  2.0 -> вероятность 0.659
  логит  1.0 -> вероятность 0.242
  логит  0.1 -> вероятность 0.099
Сумма вероятностей: 1.0

Большее число получает большую долю, но и маленькие не обнуляются полностью, а сумма всегда равна 1. Именно так похожести «запрос-ключ» становятся весами внимания.

Считаем внимание целиком

Теперь полный расчёт. Три токена «кошка», «пьёт», «молоко», у каждого вручную заданы Q, K, V размерности 2. Запустите и проследите веса и выход для каждого токена.

import math

# Три токена, каждый описан вектором Q, K, V размерности 2.
# В настоящей модели Q,K,V получаются умножением эмбеддинга на обучаемые матрицы.
tokens = ["кошка", "пьёт", "молоко"]
Q = [[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]]
K = [[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]]
V = [[10.0, 0.0], [0.0, 10.0], [5.0, 5.0]]

def dot(a, b):
    return sum(x * y for x, y in zip(a, b))

def softmax(xs):
    m = max(xs)
    e = [math.exp(x - m) for x in xs]
    s = sum(e)
    return [v / s for v in e]

d_k = len(Q[0])  # размерность ключа, для масштабирования

# Считаем выход внимания для КАЖДОГО токена-запроса.
for qi, token in enumerate(tokens):
    # 1. Похожесть запроса этого токена со всеми ключами: QK^T / sqrt(d_k)
    scores = [dot(Q[qi], K[kj]) / math.sqrt(d_k) for kj in range(len(tokens))]
    # 2. Превращаем в веса внимания (сумма = 1)
    weights = softmax(scores)
    # 3. Берём взвешенную сумму значений V
    out = [0.0, 0.0]
    for kj in range(len(tokens)):
        out[0] += weights[kj] * V[kj][0]
        out[1] += weights[kj] * V[kj][1]
    pretty = ", ".join(f"{tokens[j]}={weights[j]:.2f}" for j in range(len(tokens)))
    print(f"Токен '{token}':")
    print(f"  веса внимания -> {pretty}")
    print(f"  выход         -> [{out[0]:.2f}, {out[1]:.2f}]")

Вывод:

Токен 'кошка':
  веса внимания -> кошка=0.40, пьёт=0.20, молоко=0.40
  выход         -> [6.02, 3.98]
Токен 'пьёт':
  веса внимания -> кошка=0.20, пьёт=0.40, молоко=0.40
  выход         -> [3.98, 6.02]
Токен 'молоко':
  веса внимания -> кошка=0.25, пьёт=0.25, молоко=0.50
  выход         -> [5.00, 5.00]

Разберём вывод. Для токена «кошка» query совпал с ключами «кошка» и «молоко» сильнее, чем с «пьёт», поэтому веса 0.40 / 0.20 / 0.40 — и выход стал смесью их значений: [6.02, 3.98]. У каждого токена своя картина внимания. Заметьте: это self-attention — query, key и value берутся из одной и той же последовательности, токены смотрят сами на себя.

Почему делим на √d

При большой размерности скалярные произведения становятся большими по модулю, softmax «насыщается» (почти весь вес уходит одному токену), и обучение портится. Деление на √d держит числа в разумном диапазоне. Это маленькая, но важная деталь устойчивости.

Матрично и параллельно

В коде мы шли по токенам в цикле — ради наглядности. На деле всё считается одной матричной операцией softmax(QKᵀ/√d)·V сразу для всех токенов. Именно это и делает трансформер быстрым: вся последовательность обрабатывается параллельно, без пошагового прохода как в RNN.

Итог

  • Веса внимания = softmax от масштабированных скалярных произведений query и key.
  • Выход = взвешенная сумма value с этими весами.
  • Деление на √d удерживает softmax от насыщения и стабилизирует обучение.
  • В self-attention Q, K, V берутся из одной последовательности; всё считается одной матричной операцией.
Проверьте себя
1. Что превращает похожести query·key в веса внимания?
AСортировка
BФункция softmax
CОкругление
DУдаление отрицательных чисел
2. Чему равен выход внимания для токена?
AЗначению (value) самого похожего токена
BВзвешенной сумме value всех токенов с весами внимания
CСреднему всех эмбеддингов
DСумме всех query
3. Зачем в формуле делят на √d (корень из размерности)?
AЧтобы ответ был целым числом
BЧтобы скалярные произведения не разрастались и softmax не насыщался
CЧтобы уменьшить словарь
DЭто не влияет ни на что
4. Чем self-attention отличается по источнику Q, K, V?
AQ, K, V берутся из разных моделей
BQ, K, V вычисляются из одной и той же последовательности — токены смотрят сами на себя
CValue берётся из словаря
DQuery задаёт пользователь
Поддержать проект