Transformer: энкодер
План сборки
Трансформер (Васвани и др., «Attention Is All You Need», 2017) состоит из энкодера и декодера. Энкодер читает всю последовательность сразу и превращает каждый токен в контекстный вектор — «понимает» текст. Декодер генерирует текст по одному токену (о нём — в итоговом уроке). Почти все детали энкодера мы уже знаем:
Не хватает одного — информации о порядке слов.
Позиционные кодировки
Самовнимание не видит порядок токенов (мы проверили это в прошлом уроке). Решение — добавить к эмбеддингу каждого токена вектор, зависящий от его позиции:
Есть два способа получить PE_p:
- Обучаемые (как в BERT и GPT-2): ещё одна таблица эмбеддингов, только индексом служит позиция 0, 1, \dots, T_{\max} - 1. Просто и хорошо работает, но длиннее T_{\max} модель обрабатывать не умеет.
- Синусоидальные (исходная статья): фиксированные синусы и косинусы разных частот:
Первые компоненты колеблются быстро, последние — очень медленно, как стрелки часов: секундная, минутная, часовая. Вместе они дают каждой позиции уникальный «отпечаток».
import numpy as np
import nanotorch as nt
import vis
pe = nt.positional_encoding(48, 32)
vis.heatmap(pe.T, title="Позиционные кодировки: строки — компоненты, столбцы — позиции", show_values=False, cell=9)
sim = pe @ pe.T
vis.heatmap(sim[:24, :24], title="Скалярные произведения PEₚ · PEᵩ", show_values=False, cell=12)
print("сходство позиции 10 с позициями 8..12:", sim[10, 8:13].round(1))
print("сходство позиции 30 с позициями 28..32:", sim[30, 28:33].round(1))
Скалярное произведение кодировок зависит только от расстояния между позициями: соседние позиции похожи, далёкие — нет, и эта картина одинакова в любом месте последовательности. Кроме того, PE_{p+k} получается из PE_p линейным преобразованием (поворотом в каждой паре синус-косинус), так что модели легко научиться смотреть «на k позиций назад». Современные языковые модели чаще используют развитие этой идеи — поворотные кодировки (RoPE), которые поворачивают запросы и ключи прямо внутри внимания.
Блок энкодера
Блок состоит из двух подслоёв, каждый обёрнут одинаково — Pre-Norm и остаточная связь:
- Многоголовое самовнимание — единственное место, где токены обмениваются информацией.
- FFN (feed-forward network) — двухслойная полносвязная сеть \text{Linear}(d \to 4d) \to \text{GELU} \to \text{Linear}(4d \to d), применяемая к каждому токену отдельно с одними и теми же весами. Внимание собирает информацию, FFN её перерабатывает. Считается, что именно в FFN хранится большая часть «знаний» языковой модели.
- Остаточные связи x + f(x) — как в ResNet: градиент проходит через десятки блоков по «прямой дороге», а каждый подслой учит лишь поправку.
- LayerNorm перед подслоем держит масштаб под контролем; Pre-Norm устойчивее при обучении глубоких моделей (урок про LayerNorm).
Вот как это записано в nanotorch — всего несколько строк:
import inspect
import nanotorch as nt
print(inspect.getsource(nt.TransformerEncoderLayer))
Свой энкодер
Соберём энкодер целиком: эмбеддинги токенов и позиций, несколько блоков, финальная нормализация и линейная «голова», которая для каждого токена выдаёт ответ.
import numpy as np
import nanotorch as nt
class Encoder(nt.Module):
def __init__(self, vocab_size, max_len, d_model, num_heads, num_layers, num_outputs):
self.tok = nt.Embedding(vocab_size, d_model)
self.pos = nt.Embedding(max_len, d_model) # обучаемые позиционные эмбеддинги
self.blocks = [nt.TransformerEncoderLayer(d_model, num_heads, d_ff=2 * d_model) for _ in range(num_layers)]
self.ln = nt.LayerNorm(d_model)
self.head = nt.Linear(d_model, num_outputs)
def forward(self, ids):
T = ids.shape[1]
h = self.tok(ids) + self.pos(np.arange(T)) # (N, T, d_model)
for block in self.blocks:
h = block(h)
return self.head(self.ln(h)) # (N, T, num_outputs)
nt.manual_seed(0)
enc = Encoder(vocab_size=10, max_len=8, d_model=32, num_heads=2, num_layers=2, num_outputs=10)
ids = np.random.default_rng(0).integers(0, 10, size=(4, 8))
print("вход:", ids.shape, "→ выход:", enc(ids).shape)
print("параметров:", enc.num_params())
nt.Module сам находит параметры во вложенных слоях, даже в списке blocks — поэтому оптимизатор получит их все через enc.parameters().
Эксперимент: разворот последовательности
Научим энкодер разворачивать последовательность цифр: вход 3 1 4 1 5 9 2 6 → выход 6 2 9 5 1 4 1 3. Для каждой позиции p ответ — токен на позиции T - 1 - p. Модель должна понять, откуда брать токен, — без информации о позициях это невозможно. Обучим три варианта: без позиционных кодировок, с синусоидальными и с обучаемыми.
import numpy as np
import nanotorch as nt
import vis
T, V, D = 8, 10, 32
rng = np.random.default_rng(0)
def make_data(n):
X = rng.integers(0, V, (n, T))
return X, X[:, ::-1].copy()
X_train, Y_train = make_data(2000)
X_val, Y_val = make_data(500)
class Reverser(nt.Module):
def __init__(self, pos):
self.pos_kind = pos
self.tok = nt.Embedding(V, D)
self.pos = nt.Embedding(T, D)
self.block = nt.TransformerEncoderLayer(D, 2, d_ff=64)
self.ln = nt.LayerNorm(D)
self.head = nt.Linear(D, V)
def forward(self, ids):
h = self.tok(ids)
if self.pos_kind == "обучаемые":
h = h + self.pos(np.arange(T))
elif self.pos_kind == "синусоидальные":
h = h + nt.tensor(nt.positional_encoding(T, D))
return self.head(self.ln(self.block(h)))
for pos in ("нет", "синусоидальные", "обучаемые"):
nt.manual_seed(0)
model = Reverser(pos)
opt = nt.Adam(model.parameters(), lr=0.003)
for epoch in range(4):
perm = rng.permutation(len(X_train))
for i in range(0, len(X_train), 64):
b = perm[i:i + 64]
logits = model(X_train[b]) # (64, T, V)
loss = nt.cross_entropy(logits.reshape(-1, V), Y_train[b].reshape(-1))
opt.zero_grad()
loss.backward()
opt.step()
model.eval()
pred = model(X_val).data.argmax(axis=-1)
print(f"позиционные кодировки: {pos:15} точность по токенам {(pred == Y_val).mean():.1%}")
print("\nвход: ", X_val[0])
print("ответ: ", pred[0])
w = model.block.attn.attn[0] # (головы, T, T)
vis.heatmap(w.mean(axis=0), xlabels=[str(t) for t in X_val[0]], ylabels=[str(t) for t in X_val[0]],
title="Куда смотрит каждая позиция (среднее по головам)", fmt=".1f")
Без позиционных кодировок точность около 30% — это не умение, а угадывание: модель может выдать какой-нибудь токен из входа, и иногда он совпадает с нужным. С кодировками — почти 100%. На карте внимания видна антидиагональ: позиция 0 смотрит на позицию 7, позиция 1 — на 6 и так далее. Модель сама нашла алгоритм разворота, а мы лишь показывали ей примеры.
Как используют энкодеры
Энкодер превращает текст в контекстные векторы, а что с ними делать — зависит от задачи:
- Разметка токенов (как в нашем эксперименте): линейная голова на каждом токене — поиск имён и дат в тексте, части речи.
- Классификация текста: берут вектор специального токена
[CLS]в начале или среднее всех векторов и подают в классификатор — тональность отзыва, спам, тема. - Векторы для поиска: тексты с похожим смыслом получают близкие векторы — на этом построен семантический поиск.
Самый известный энкодер — BERT (Google, 2018): 12–24 блока, обучен на огромном корпусе предсказывать замаскированные слова: «Кот [MASK] на коврике» → «сидел». Для этого нужно понимать контекст с обеих сторон. После такого предобучения BERT дообучают под конкретную задачу на небольшом размеченном наборе — тот же перенос обучения, что и у свёрточных сетей.
Энкодеры используют не только для текста: Vision Transformer режет картинку на квадратики 16 \times 16, считает каждый «токеном» и подаёт в тот же энкодер.
Итог
- Энкодер: эмбеддинги + позиционные кодировки → N блоков → LayerNorm → голова.
- Позиционные кодировки сообщают модели порядок; синусоидальные зависят от расстояния между позициями, обучаемые — просто таблица.
- Блок (Pre-Norm): x + \text{MHA}(\text{LN}(x)), затем x + \text{FFN}(\text{LN}(x)); внимание обменивает информацию между токенами, FFN обрабатывает каждый токен.
- Без позиционных кодировок трансформер не отличает «где», и задачи, зависящие от порядка, не решает.
- BERT — энкодер, предобученный восстанавливать замаскированные слова; энкодеры применяют для классификации, разметки и поиска.
Практика решено 0 из 6
Какая часть блока энкодера за что отвечает?
| самовнимание | полносвязная сеть (FFN) | остаточная связь | LayerNorm | |
|---|---|---|---|---|
| Обмен информацией между токенами | ||||
| Обработка каждого токена по отдельности, одними и теми же весами | ||||
| «Прямая дорога» для градиента через все слои | ||||
| Держит масштаб активаций под контролем |
Как выглядит блок энкодера в варианте Pre-Norm (как в nanotorch и GPT-2)?
Энкодер без позиционных кодировок учат разворачивать последовательность. Что получится?
В оригинальном трансформере d_{\text{model}} = 512, а скрытый слой FFN в 4 раза шире. Сколько весов (без bias) в FFN одного блока?
Синусоидальные позиционные кодировки. Напишите positional_encoding(T, d) — матрицу (T, d):
Чётные столбцы — синусы, нечётные — косинусы с той же частотой. d чётное.
Какие тесты проверяют код (2)
pe = positional_encoding(10, 8) assert pe.shape == (10, 8) assert np.allclose(pe[0, 0::2], 0) and np.allclose(pe[0, 1::2], 1), "Позиция 0: sin 0 = 0, cos 0 = 1" assert np.isclose(pe[1, 0], np.sin(1)) and np.isclose(pe[1, 1], np.cos(1)), "Первая пара столбцов — частота 1" assert np.isclose(pe[3, 2], np.sin(3 / 10000 ** (2 / 8))), "PE[p, 2i] = sin(p / 10000^(2i/d))" import nanotorch as nt assert np.allclose(positional_encoding(20, 16), nt.positional_encoding(20, 16)), "Должно совпасть с nt.positional_encoding"
import numpy as np
def positional_encoding(T, d):
pos = np.arange(T)[:, None]
i = np.arange(d // 2)[None, :]
angle = pos / 10000 ** (2 * i / d)
pe = np.zeros((T, d))
pe[:, 0::2] = np.sin(angle)
pe[:, 1::2] = np.cos(angle)
return pe
Блок энкодера. Напишите encoder_block(x, attn, ffn, ln1, ln2) по схеме Pre-Norm:
сначала x \leftarrow x + \text{attn}(\text{ln1}(x)), затем x \leftarrow x + \text{ffn}(\text{ln2}(x)). Верните x.
Аргументы attn, ffn, ln1, ln2 — функции (или слои nanotorch), принимающие и возвращающие тензор той же формы.
Какие тесты проверяют код (2)
out = encoder_block(1.0, attn=lambda v: 10 * v, ffn=lambda v: 100 * v, ln1=lambda v: v + 1, ln2=lambda v: v - 1)
# x = 1 + 10·(1 + 1) = 21; x = 21 + 100·(21 − 1) = 2021
assert out == 2021, f"Ожидалось 2021, получилось {out}: x ← x + attn(ln1(x)), затем x ← x + ffn(ln2(x))"
import numpy as np
import nanotorch as nt
nt.manual_seed(0)
layer = nt.TransformerEncoderLayer(8, 2, d_ff=16)
x = nt.tensor(np.random.default_rng(0).normal(size=(2, 5, 8)))
out = encoder_block(x, layer.attn, layer.ff, layer.ln1, layer.ln2)
assert np.allclose(out.data, layer(x).data), "Должно совпасть с nt.TransformerEncoderLayer"
def encoder_block(x, attn, ffn, ln1, ln2):
x = x + attn(ln1(x))
x = x + ffn(ln2(x))
return x
Войдите, чтобы отмечать пройденные уроки.