03 · Внимание: как токены разговаривают друг с другом
Вот задача, которую решает внимание (attention). По устройству из урока 01
каждая позиция должна предсказать свой следующий символ — но символ сам по
себе почти бесполезен как контекст. Букве n в an… нужно знать, что перед
ней была a; второй n в ann… нужно знать, что она — вторая n.
Внимание — это механизм, который позволяет каждой позиции дотянуться назад,
спросить «кто из тех, что были до меня, важен прямо сейчас?» и втянуть ровно
ту информацию, которая ей нужна — одними лишь скалярными произведениями и
софтмаксом. Эта единственная идея — буква «T» в GPT и причина, по которой
эта архитектура захватила мир.
Прежде чем нырнуть. Внимание почти целиком построено из скалярного произведения (dot product) из 00 · Основы §3 — перемножь два списка чисел позиция за позицией, сложи и читай результат как меру похожести — плюс софтмакс (§5), чтобы превратить эти оценки в веса, дающие в сумме 1. Когда урок говорит, что q, k и v — это «обученное линейное отображение эмбеддинга», это значит: возьми вектор токена и умножь на матрицу обученных ручек — одна и та же операция, три разных набора ручек, три разных назначения. Делитель
√d— просто ручка громкости, удерживающая оценки в комфортном диапазоне.
Теория
Self-attention спрашивает для каждого токена-запроса q_i: «насколько внимательно мне слушать значение v_j каждого предыдущего токена?» Рецепт:
- Спроецируй каждый токен в три вектора: запрос (query)
q, ключ (key)k, значение (value)v. Каждый — обученное линейное отображение эмбеддинга токена. - Оцени запрос против каждого ключа:
score[i][j] = (q_i · k_j) / sqrt(d_head). Корень удерживает дисперсию стабильной при росте размерности головы. - Примени причинную маску (causal mask) — токен
iможет видеть только токеныj ≤ i. Прогони (замаскированную) строку через софтмакс, чтобы веса внимания давали в сумме 1. - Возьми взвешенную сумму векторов значений:
output_i = Σ_j softmax(score)[i][j] * v_j.
Весь слой делает это для каждой головы параллельно; каждая голова получает свой срез эмбеддинга и обучает собственные проекции запроса/ключа/значения. Выходы голов конкатенируются и проецируются обратно в размерность эмбеддинга через W_o.
Песочница ниже — лаборатория общения токенов для одной головы и одного выбранного тобой токена-запроса. Смотри, как запрос рассылает лучи оценок к каждому видимому ключу, как стена причинной маски перекрывает будущие токены, как сырые оценки превращаются в веса софтмакса (сумма — 1), и как взвешенные значения стекаются в смеситель, образуя output_i. Несколько вещей, которые картинка показывает явно:
- Оценки и софтмакс — разные стадии.
attention_logits(сырыеq·k/√d) иattention_softmax(веса) снимаются с настоящей модели по отдельности — лучи сначала показывают оценки, а потом меняют подписи на веса. - Замаскированные будущие токены никогда не попадают в софтмакс. Оценку
q_i·k_jдля будущего ключаj > iвычислить можно (и лаборатория показывает стену маски над этими позициями исключительно чтобы правило было видно), но маска применяется до софтмакса — модель никогда не допускает будущий токен к участию, и веса нормируются только поj ≤ i. - Полоски Q/K/V — это метки, а не величины. Три цветные полоски под каждым токеном лишь помечают, какая проекция — запрос, ключ и значение; их длина условна. Реальные числа живут в панели разбора
q·k. - Лучи значений — это взвешенная сумма. Каждое видимое значение стекает в смеситель с меткой
wⱼ·vⱼ; смеситель складывает их вoutput_i = Σ wⱼ·vⱼ. - Многоголовость настоящая, а не для красоты. Каждое кольцо в нижнем ряду показывает собственное софтмакс-распределение этой головы по тем же ключам — попереключай головы (или смотри на мини-столбики), и увидишь, что разные головы фокусируются на разных позициях.
- Это одна выбранная голова.
output_iздесь — выход именно этой головы. Полный слой гоняет все головы параллельно, затем конкатенирует их выходы и проецирует их черезW_o.
Аннотированный код
Блок внимания живёт в src/microgpt_annotated.py, подраздел attention-multihead:
def gpt(token_id, pos_id, keys, values):
# ...embedding + rmsnorm above this point...
for li in range(n_layer):
x_residual = x
x = rmsnorm(x)
q = linear(x, state_dict[f'layer{li}.attn_wq'])
k = linear(x, state_dict[f'layer{li}.attn_wk'])
v = linear(x, state_dict[f'layer{li}.attn_wv'])
keys[li].append(k)
values[li].append(v)
x_attn = []
for h in range(n_head):
hs = h * head_dim
q_h = q[hs:hs+head_dim]
k_h = [ki[hs:hs+head_dim] for ki in keys[li]]
v_h = [vi[hs:hs+head_dim] for vi in values[li]]
attn_logits = [sum(q_h[j] * k_h[t][j] for j in range(head_dim)) / head_dim**0.5
for t in range(len(k_h))]
attn_weights = softmax(attn_logits)
head_out = [sum(attn_weights[t] * v_h[t][j] for t in range(len(v_h)))
for j in range(head_dim)]
x_attn.extend(head_out)
x = linear(x_attn, state_dict[f'layer{li}.attn_wo'])
x = [a + b for a, b in zip(x, x_residual)]
# ...MLP block follows...Заметь: причинная структура встроена прямо в сигнатуру вызова — gpt() вызывается по одному разу на позицию, и KV-кэш (keys, values) растёт на одну строку с каждым вызовом. Порт на TypeScript в src/inference/model.ts вместо этого считает всю последовательность длины T за один вызов с явным циклом j ≤ i — та же математика, другой поток управления.
Песочница
Выбери строку (≤6 символов), голову (0–3) и токен-запрос i. Нажми Play и смотри фазы: токены → Q/K/V → оценки → маска → софтмакс → взвешенная сумма значений → многоголовость. Тумблеры показывают/прячут векторы Q/K/V, сырые оценки, веса софтмакса, замаскированное будущее и обзор всех голов. Кликни кнопку inspect q·k (или подпись луча) — увидишь поразмерный разбор скалярного произведения.
Два цвета несут две стадии, сверху вниз: оранжевые лучи — веса внимания от запроса к каждому видимому ключу (верхняя дорожка), а зелёные лучи — взвешенные значения wⱼ·vⱼ, текущие от каждого чипа Vⱼ вниз в выходной смеситель (нижняя дорожка). Будущий токен (j > i) замаскирован: его чип сереет, и он не получает ни оранжевого ребра, ни зелёного луча.
Попробуй сам.
- Загрузи
anna, выбери запросом последнююaи посмотри на оранжевые веса. Какой из более ранних символов больше всего волнует эту голову? Теперь попереключай головы (0–3) — согласны ли они между собой? (Не должны: каждая голова выучила собственную привычку.)- Поставь запрос на первый символ. В его строке софтмакса ровно один видимый ключ — он сам. Каким обязан быть его вес внимания, даже до того, как ты посмотришь?
- Кликни кнопку inspect q·k и найди, какие размерности скалярного произведения вносят наибольший вклад. Мера похожести — это сумма поразмерных согласий; вот они, одно за другим.
- Понаблюдай за стеной маски. Посеревшие будущие токены не получают оранжевого луча вообще — убедись, что веса по видимому прошлому всё равно дают в сумме 1.