Перейти к содержимому
Ступень 44.1 Языковое моделирование

Рекуррентная языковая модель и seq2seq на практике

Обучаем посимвольные RNN и LSTM как языковые модели (обрезанный BPTT, перенос состояния, клиппинг) и меряем, насколько далёкий контекст они помнят. Затем собираем encoder–decoder для транслитерации, разбираем teacher forcing, exposure bias, greedy и beam search, BLEU — и своими глазами видим «бутылочное горлышко».

УРОКstage-4/4-1-language-modeling/05-rnn-lstm-language-model.mdx2 ч 30 минУрок 5 из 5
После урока вы сможете
  • Обучить посимвольную RNN и LSTM на длинном тексте с обрезанным BPTT, переносом состояния и клиппингом градиента
  • Измерить, насколько далёкий контекст использует модель, и сравнить RNN, LSTM и MLP из урока 4.1.4
  • Собрать seq2seq на двух LSTM, объяснить teacher forcing и exposure bias и реализовать beam search
  • Посчитать BLEU по n-граммам и показать на опыте, почему качество seq2seq падает с длиной входа

MLP из урока 4.1.4 видит ровно три символа. Рекуррентная сетьрекуррентная нейросетьEN recurrent neural network (RNN) устроена иначе: она читает текст по одному символу и после каждого обновляет скрытое состояниескрытое состояниеEN hidden stateВектор h_t, в котором RNN хранит «память» о прочитанном: h_t = f(h_{t−1}, x_t). Его размер фиксирован, сколько бы токенов ни прошло. — вектор, в котором накоплено всё прочитанное. Окна нет, в принципе модель может учесть сколь угодно далёкий контекст. Вопрос — сколько она помнит на самом деле.

Теорию RNN, LSTM и затухания градиента вы уже разобрали в уроке 3.4.3. Здесь — практика, как у Карпатого в знаменитом посте «The Unreasonable Effectiveness of RNNs» (ссылка в источниках; прочитайте его параллельно, это 20 минут). Обучим RNN и LSTM как языковые модели и измерим их память. Затем соберём seq2seqseq2seq (последовательность в последовательность)EN sequence-to-sequence (seq2seq)Схема «энкодер читает вход, декодер генерирует выход» (Sutskever и др.; Cho и др., 2014). Без внимания всё входное предложение сжимается в один вектор контекста. — модель, которая переводит одну последовательность в другую, — и упрёмся в его главный предел. Ради преодоления этого предела придумали внимание, с которого начнётся модуль 4.4.

Коротко напомним урок 3.4.3. Простая RNN на каждом шаге делает

ht=tanh⁡(Whhht−1+Wxhxt+b),logitst=Whyht+by,h_t = \tanh(W_{hh} h_{t-1} + W_{xh} x_t + b), \qquad \text{logits}_t = W_{hy} h_t + b_y,

то есть смешивает старое состояние с новым символом и по состоянию предсказывает следующий символ. Веса одни на всех шагах. Беда в том, что градиент к шагу t−kt - k проходит через kk умножений на одну и ту же матрицу и затухаетзатухание градиентовEN vanishing gradientsГрадиент первых слоёв — произведение множителей всех слоёв выше; если они меньше 1, он экспоненциально уменьшается с глубиной (0.9⁵⁰ ≈ 0.005), и нижние слои перестают учиться. (или взрывается): в уроке 3.4.3 он падал в 250 раз всего за 9 шагов.

LSTMLSTM (долгая краткосрочная память)EN long short-term memory (LSTM)RNN с ячейкой памяти c_t и тремя гейтами (Hochreiter, Schmidhuber, 1997). Ячейка обновляется сложением c_t = f·c_{t−1} + i·g — «остаточная связь во времени». добавляет ячейку памяти ctc_t, которая обновляется сложением, и три гейта — «вентиля» со значениями от 0 до 1. Гейт забывания решает, что стереть из ячейки, входной — что записать, выходной — что показать наружу. Пока гейт забывания открыт, градиент идёт по ячейке почти без потерь, как по остаточной связи. GRUGRU (управляемый рекуррентный блок)EN gated recurrent unit (GRU)Упрощённая LSTM с двумя гейтами и без отдельной ячейки памяти (Cho и соавт., 2014). — упрощённая LSTM: ячейки нет, гейтов два. Гейт обновления ztz_t решает, какую долю старого состояния оставить, а какую заменить кандидатом h~t\tilde h_t: ht=zt⊙ht−1+(1−zt)⊙h~th_t = z_t \odot h_{t-1} + (1 - z_t) \odot \tilde h_t. Гейт сброса решает, сколько прошлого учитывать при расчёте кандидата. Работает GRU обычно не хуже LSTM, а параметров у неё на четверть меньше.

В PyTorch все три — готовые модули nn.RNN, nn.LSTM, nn.GRU с одинаковым интерфейсом: на вход последовательность векторов, на выход состояние после каждого шага и последнее состояние.

Возьмём русскую прозу этого курса — около 1.6 млн символов (скрипт подготовки — под катом). Модель, обученная на учебнике, будет писать «под учебник», и это наглядно: языковая модель — сжатая статистика своего корпуса.

prepare_corpus.py — тексты уроков → input.txt

Скрипт собирает из MDX-файлов курса обычные абзацы и выбрасывает код, формулы и разметку. Запуск из корня репозитория. Тот же скрипт пригодится в уроке 4.6.2.

"""prepare_corpus.py — русская проза из уроков курса → input.txt. Запуск из корня репозитория."""
import pathlib
import re
DOCS = pathlib.Path("site/src/content/docs")
lines = []
for f in sorted(DOCS.rglob("*.mdx")):
if "translations" in f.parts: # переводы первоисточников — не уроки курса, пропускаем
continue
src = re.sub(r"\A---.*?\n---\n", "", f.read_text(encoding="utf-8"), flags=re.S) # front-matter
src = re.sub(r"```.*?```", "", src, flags=re.S) # блоки кода
for s in (line.strip() for line in src.splitlines()):
if not s or s[0] in "<{|$#:" or re.match(r"^\w+:", s): # компоненты, таблицы, «ключ: значение»
continue
s = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", s) # [текст](ссылка) → текст
s = re.sub(r"`[^`]*`|\$[^$]*\$|<[^>]*>|\*\*", "", s) # код, формулы, теги, жирный
if sum("а" <= ch.lower() <= "я" for ch in s) >= 40: # оставляем только строки-прозу
lines.append(s.lstrip("- "))
text = "\n".join(lines)
pathlib.Path("input.txt").write_text(text, encoding="utf-8")
print(f"{len(text):,} символов".replace(",", " "))

Курс растёт, поэтому у вас текста будет больше (при последней проверке — около 2.3 млн символов), и числа ниже разойдутся с вашими; ход обучения и выводы от этого не меняются.

Обучать RNN по всему тексту сразу нельзя: граф из полутора миллионов шагов не поместится ни в какую память, а градиент через него всё равно затухнет. Поэтому применяют обрезанный BPTTобрезанный BPTTEN truncated backpropagation through time (truncated BPTT)Длинный текст режут на окна по T шагов: состояние RNN переносят из окна в окно, а граф отрезают (detach), поэтому градиент течёт назад не дальше T шагов.:

  1. Текст режут на B=32B = 32 параллельные «ленты»: лента 0 — первая тридцать вторая часть текста, лента 1 — вторая, и так далее. Каждая лента читается подряд, от начала до конца.
  2. На каждом шаге обучения из всех лент берут следующие T=64T = 64 символа — батч формы 32×6432 \times 64.
  3. Состояние после окна переносится в следующее окно той же ленты: модель продолжает читать с того места, где остановилась, и прямой проход «помнит» дальше 64 символов.
  4. Но граф перед новым окном отрезают (detach): градиент течёт назад не дальше 64 шагов. Это и есть «обрезанный» BPTT (обратное распространение во времениобратное распространение во времени (BPTT)EN backpropagation through time (BPTT)Backprop по развёрнутой во времени RNN. Градиент к ранним шагам проходит через одну и ту же матрицу много раз, отсюда затухание и взрыв. из урока 3.4.3).
flowchart LR
W1["окно 1<br/>x0…x63"] -->|"h64<br/>detach"| W2["окно 2<br/>x64…x127"] -->|"h128<br/>detach"| W3["окно 3<br/>x128…x191"]
Обрезанный BPTT на одной ленте: значение состояния передаётся из окна в окно, а градиент обрезается (detach), и backprop идёт только внутри окна

Ещё одна обязательная деталь — клиппинг градиентаобрезка градиента (gradient clipping)EN gradient clippingЕсли норма градиента больше порога, градиент масштабируют до порога: clip_grad_norm_(params, 1.0). Стандарт в обучении LLM.: если норма градиента больше порога (у нас 1.0), весь градиент умножается на число меньше единицы. Направление сохраняется, а редкие «взрывы» не ломают обучение. Тот же приём стоит в цикле обучения любой LLM (урок 4.6.3).

"""charlm.py — посимвольная RNN/LSTM-модель с обрезанным BPTT. python charlm.py lstm 1 2000"""
import math
import sys
import time
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(0)
torch.set_num_threads(int(sys.argv[2]) if len(sys.argv) > 2 else 4) # второй аргумент — потоки CPU
device = "cuda" if torch.cuda.is_available() else "cpu"
# ── Данные: символ = токен, как в 4.1.2 ─────────────────────────────────────
RU = "абвгдеёжзийклмнопрстуфхцчшщъыьэюя"
ALLOWED = set(RU + RU.upper() + "0123456789" + " \n.,:;!?-—«»()")
text = "".join(ch for ch in open("input.txt", encoding="utf-8").read() if ch in ALLOWED)
chars = sorted(set(text))
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for ch, i in stoi.items()}
V = len(chars)
data = torch.tensor([stoi[c] for c in text])
n = int(0.9 * len(data))
train_data, val_data = data[:n], data[n:] # последние 10% — валидация
print(f"символов: {len(data):,}".replace(",", " ") + f", словарь: {V}") # 1 572 877 — пробелы вместо запятых
def batchify(d, B):
"""Длинный текст → B параллельных «лент»: лента b — это b-я часть текста целиком."""
L = len(d) // B
return d[: L * B].view(B, L).to(device) # (B, L)
class CharRNN(nn.Module):
def __init__(self, V, cell="lstm", d_emb=64, d_hid=256):
super().__init__()
self.emb = nn.Embedding(V, d_emb) # таблица C из урока 4.1.4
RNN = nn.LSTM if cell == "lstm" else nn.RNN
self.rnn = RNN(d_emb, d_hid, batch_first=True)
self.head = nn.Linear(d_hid, V)
def forward(self, x, state=None):
out, state = self.rnn(self.emb(x), state) # out: (B, T, d_hid) — состояние после каждого символа
return self.head(out), state # логиты следующего символа на каждой позиции
def detach(state):
"""Отрезаем граф: значения состояния переносим в следующее окно, градиент — нет."""
if isinstance(state, tuple): # у LSTM состояние — пара (h, c)
return tuple(s.detach() for s in state)
return state.detach()
@torch.no_grad()
def evaluate(model, d, B=16, T=256):
model.eval()
stream = batchify(d, B)
state, total, count = None, 0.0, 0
for i in range(0, stream.shape[1] - 1 - T, T):
x, y = stream[:, i:i + T], stream[:, i + 1:i + 1 + T]
logits, state = model(x, state) # состояние тянется через весь val-текст
total += F.cross_entropy(logits.reshape(-1, V), y.reshape(-1), reduction="sum").item()
count += y.numel()
model.train()
return total / count
def train(cell, steps=2000, B=32, T=64, lr=3e-3, clip=1.0):
model = CharRNN(V, cell).to(device)
opt = torch.optim.Adam(model.parameters(), lr=lr)
stream = batchify(train_data, B)
pos, state, t0 = 0, None, time.time()
for step in range(1, steps + 1):
if pos + T + 1 > stream.shape[1]: # лента кончилась — начинаем сначала с нулевым состоянием
pos, state = 0, None
x, y = stream[:, pos:pos + T], stream[:, pos + 1:pos + 1 + T]
pos += T
logits, state = model(x, None if state is None else detach(state))
loss = F.cross_entropy(logits.reshape(-1, V), y.reshape(-1))
opt.zero_grad()
loss.backward()
norm = nn.utils.clip_grad_norm_(model.parameters(), clip) # клиппинг градиента (урок 3.4.3)
opt.step()
if step % 500 == 0:
print(f"{cell} шаг {step}: train {loss.item():.3f} норма градиента {norm:.2f} "
f"val {evaluate(model, val_data):.3f} ({time.time() - t0:.0f} с)")
return model
if __name__ == "__main__":
cell = sys.argv[1] if len(sys.argv) > 1 else "lstm"
model = train(cell, steps=int(sys.argv[3]) if len(sys.argv) > 3 else 2000)
torch.save(model.state_dict(), f"{cell}.pt")

clip_grad_norm_ возвращает норму градиента до обрезки — её полезно печатать: если она постоянно намного больше порога, learning rate, скорее всего, велик. Запустим обе модели (аргументы: тип ячейки, число потоков CPU, число шагов):

Окно терминала
python charlm.py rnn 1 2000
python charlm.py lstm 1 2000
символов: 1 572 877, словарь: 87
rnn шаг 500: train 1.749 норма градиента 0.48 val 1.842 (82 с)
rnn шаг 1000: train 1.608 норма градиента 0.48 val 1.714 (174 с)
rnn шаг 1500: train 1.642 норма градиента 0.44 val 1.675 (290 с)
rnn шаг 2000: train 1.628 норма градиента 0.48 val 1.649 (393 с)
символов: 1 572 877, словарь: 87
lstm шаг 500: train 1.730 норма градиента 0.28 val 1.806 (181 с)
lstm шаг 1000: train 1.531 норма градиента 0.25 val 1.649 (410 с)
lstm шаг 1500: train 1.546 норма градиента 0.26 val 1.589 (525 с)
lstm шаг 2000: train 1.515 норма градиента 0.24 val 1.542 (650 с)

Время — с загруженного четырёхъядерного процессора в один поток; у вас будет быстрее. 2000 шагов — это примерно три прохода по тексту. При одинаковом размере состояния (256) LSTM лучше: val 1.542 против 1.649, perplexity e1.542≈4.7e^{1.542} \approx 4.7 против e1.649≈5.2e^{1.649} \approx 5.2 кандидатов на символ. У равномерной модели было бы ln⁡87≈4.47\ln 87 \approx 4.47 нат, то есть 87 кандидатов. Норма градиента ниже порога 1.0 почти всегда — клиппинг здесь страховка от редких выбросов, а не постоянная мера.

Посмотрим, что пишет LSTM (температура 0.8 из урока 4.1.3; код генерации — тот же авторегрессионный цикл, что в 4.1.4, только вместо окна переносится состояние):

(это центрор на медленной половину не рассёзная только текст ) — пишут на сих для больше её в 4 битного подобновкод.
Это - меньше современная целиком. Две код батч, что цель обычно на памяти: смотнтативать валидации ответ.

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

В теории RNN помнит всё прочитанное. Проверим на практике. Возьмём 2000 случайных позиций в валидационном тексте и для каждой дадим модели ровно LL предыдущих символов, начиная с нулевого состояния. Если loss перестаёт падать при росте LL, значит, более далёкий контекст модель уже не использует.

"""memory.py — насколько далёкий контекст использует модель: loss при контексте ровно L символов."""
import sys
sys.argv = ["x"]
exec(open("charlm.py").read().split('if __name__')[0]) # данные и класс CharRNN
torch.set_num_threads(1)
g = torch.Generator().manual_seed(0)
pos = torch.randint(512, len(val_data), (2000,), generator=g) # 2000 случайных позиций в val
models = {}
for cell in ["rnn", "lstm"]:
m = CharRNN(V, cell)
m.load_state_dict(torch.load(f"{cell}.pt"))
models[cell] = m.eval()
print("контекст L " + " ".join(f"{c:>5}" for c in models))
for L in [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]:
x = torch.stack([val_data[p - L:p] for p in pos.tolist()]) # ровно L символов перед позицией
y = val_data[pos]
row = []
for cell, m in models.items():
with torch.no_grad():
logits, _ = m(x) # старт с нулевого состояния
row.append(F.cross_entropy(logits[:, -1], y).item()) # предсказание символа на позиции p
print(f"{L:>10} " + " ".join(f"{v:5.3f}" for v in row))
контекст L rnn lstm
1 4.324 3.375
2 3.277 2.862
4 2.198 2.072
8 1.627 1.539
16 1.605 1.508
32 1.605 1.500
64 1.605 1.496
128 1.605 1.493
256 1.605 1.494
512 1.605 1.494

Как читать:

  • Почти вся польза — в последних восьми символах. И у RNN, и у LSTM loss резко падает до L=8L = 8: внутри слова и на стыке соседних слов предсказывать проще всего.
  • RNN забывает за 10–16 символов. После L=16L = 16 её loss не меняется вообще: что было 20 символов назад, на предсказание уже не влияет. Это затухание из урока 3.4.3, только измеренное на настоящей задаче.
  • LSTM пользуется контекстом до 64–128 символов. Её loss продолжает понемногу падать до L=128L = 128 (с 1.508 при L=16L = 16 до 1.493): ячейка с гейтами проносит информацию дальше, хотя выигрыш от далёкого контекста уже невелик. Дальше 128 улучшений нет, хотя обучалась модель с переносом состояния.
  • Для сравнения: MLP из урока 4.1.4 видел ровно 3 символа и выше этого потолка подняться не мог в принципе.

Почему LSTM не помнит всю сотню тысяч прочитанных символов? Во-первых, всё прочитанное нужно уместить в два вектора по 256 чисел. Во-вторых, градиент при обучении шёл назад только на 64 шага (обрезанный BPTT), и учиться пользоваться зависимостями длиннее окна модели почти не на чем. Оба ограничения снимает внимание: каждый токен смотрит прямо на нужное место в прошлом, и путь градиента до него — один шаг.

Seq2seq: из одной последовательности в другую

Заголовок раздела «Seq2seq: из одной последовательности в другую»

Языковая модель продолжает текст. Многие задачи устроены иначе: дан вход, нужен выход другой длины — перевод, пересказ, распознавание речи. Возьмём игрушечный, но настоящий пример — транслитерацию по правилам загранпаспорта РФ (стандарт ICAO Doc 9303): «щука» → «shchuka», «юля» → «iulia», «хорошо» → «khorosho». Длина выхода не совпадает с длиной входа (щ → shch, ь → ничего), поэтому символ-в-символ не сопоставить.

Схема encoder–decoderэнкодер–декодерEN encoder-decoder architectureДвунаправленный энкодер читает вход, причинный декодер генерирует выход и смотрит на энкодер через cross-attention (исходный Transformer, T5, Whisper). из урока 4.4.1:

  1. Энкодер — LSTM, которая читает кириллическую строку. Её последнее состояние (h,c)(h, c) — «смысл» всего входа.
  2. Декодер — вторая LSTM, условная языковая модель: она начинает с состояния энкодера и генерирует латинские буквы по одной, пока не выдаст </s>.
flowchart LR
E["Энкодер (LSTM)<br/>читает: щ, у, к, а"] ==>|"одно состояние (h, c)<br/>2 × 128 чисел"| D["Декодер (LSTM)<br/>пишет: s, h, c, h, u, k, a, конец"]
Seq2seq для транслитерации: весь вход проходит к декодеру через одно состояние фиксированного размера

Как учить декодер? Можно позволить ему генерировать и штрафовать за ошибки, но в начале обучения он выдаёт мусор, и после первой ошибки все следующие шаги учатся на бессмысленном префиксе. Поэтому почти всегда применяют teacher forcingteacher forcing (обучение на верном префиксе)EN teacher forcingПри обучении модель на каждом шаге видит настоящий текст, а не свои предсказания. Поэтому все позиции можно обучать параллельно.: на вход декодеру подают правильный префикс, сдвинутый на один шаг.

Шаг12345678
Вход декодера<s>shchuka
Цельshchuka</s>

Все восемь шагов считаются одним проходом LSTM, loss — средняя cross-entropy по ним, ровно как в языковой модели. Плата — смещение экспозициисмещение экспозиции (exposure bias)EN exposure biasПри обучении модель видит только «правильные» префиксы, а при генерации — свои собственные, с ошибками. Отсюда накопление ошибок.: при обучении декодер ни разу не видел собственных ошибок, а на инференсе он читает свои же предсказания. Ошибся в одной букве — и следующий шаг идёт с префиксом, какого он никогда не видел. Ниже мы это измерим.

"""seq2seq.py — транслитерация «кириллица → латиница» на encoder–decoder из двух LSTM. python seq2seq.py"""
import random
import re
import sys
import time
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(0)
random.seed(0)
torch.set_num_threads(1)
device = "cuda" if torch.cuda.is_available() else "cpu"
# ── Данные: правила загранпаспорта РФ (ICAO Doc 9303) ───────────────────────
TABLE = {"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh", "з": "z",
"и": "i", "й": "i", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", "р": "r",
"с": "s", "т": "t", "у": "u", "ф": "f", "х": "kh", "ц": "ts", "ч": "ch", "ш": "sh",
"щ": "shch", "ъ": "ie", "ы": "y", "ь": "", "э": "e", "ю": "iu", "я": "ia", " ": " "}
def translit(s):
return "".join(TABLE[c] for c in s)
words = sorted(set(re.findall(r"[а-яё]{2,}", open("input.txt", encoding="utf-8").read().lower())))
random.shuffle(words)
cut = int(0.9 * len(words))
train_words, val_words = words[:cut], words[cut:] # слова валидации модель не видела ни разу
def make_pairs(pool, n):
"""Фразы из 1–4 случайных слов: длина входа от 2 до ~50 символов."""
pairs = []
for _ in range(n):
src = " ".join(random.choice(pool) for _ in range(random.randint(1, 4)))
pairs.append((src, translit(src)))
return pairs
train_pairs, val_pairs = make_pairs(train_words, 60000), make_pairs(val_words, 2000)
print(f"слов: {len(words)}, пример: {train_pairs[0]}")
PAD, BOS, EOS = 0, 1, 2
src_itos = ["<pad>"] + sorted(set(TABLE))
tgt_itos = ["<pad>", "<s>", "</s>"] + sorted(set("".join(TABLE.values())))
src_stoi = {c: i for i, c in enumerate(src_itos)}
tgt_stoi = {c: i for i, c in enumerate(tgt_itos)}
def batch(pairs):
"""Список пар → тензоры с дополнением нулями (PAD) до самой длинной строки в батче."""
src = [[src_stoi[c] for c in s] for s, _ in pairs]
tgt = [[BOS] + [tgt_stoi[c] for c in t] + [EOS] for _, t in pairs]
S, T = max(map(len, src)), max(map(len, tgt))
src_t = torch.tensor([x + [PAD] * (S - len(x)) for x in src], device=device)
tgt_t = torch.tensor([y + [PAD] * (T - len(y)) for y in tgt], device=device)
return src_t, torch.tensor([len(x) for x in src]), tgt_t
class Seq2Seq(nn.Module):
def __init__(self, d_emb=32, d_hid=128):
super().__init__()
self.src_emb = nn.Embedding(len(src_itos), d_emb, padding_idx=PAD)
self.tgt_emb = nn.Embedding(len(tgt_itos), d_emb, padding_idx=PAD)
self.encoder = nn.LSTM(d_emb, d_hid, batch_first=True)
self.decoder = nn.LSTM(d_emb, d_hid, batch_first=True)
self.head = nn.Linear(d_hid, len(tgt_itos))
def encode(self, src, src_len):
packed = nn.utils.rnn.pack_padded_sequence(self.src_emb(src), src_len, batch_first=True,
enforce_sorted=False) # PAD не попадает в LSTM
_, state = self.encoder(packed)
return state # (h, c) последнего шага: ВЕСЬ вход сжат в 2 × 128 чисел
def forward(self, src, src_len, tgt_in):
out, _ = self.decoder(self.tgt_emb(tgt_in), self.encode(src, src_len))
return self.head(out) # логиты следующей латинской буквы на каждой позиции
model = Seq2Seq().to(device)
opt = torch.optim.Adam(model.parameters(), lr=3e-3)
steps, B, t0 = int(sys.argv[1]) if len(sys.argv) > 1 else 6000, 64, time.time()
for step in range(1, steps + 1):
src, src_len, tgt = batch(random.sample(train_pairs, B))
logits = model(src, src_len, tgt[:, :-1]) # teacher forcing: на вход — правильный префикс
loss = F.cross_entropy(logits.reshape(-1, logits.shape[-1]), tgt[:, 1:].reshape(-1), ignore_index=PAD)
opt.zero_grad()
loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
if step % 1000 == 0:
print(f"шаг {step}: loss {loss.item():.4f} ({time.time() - t0:.0f} с)")
if steps: # eval_seq2seq.py запускает этот файл с steps = 0
torch.save(model.state_dict(), "seq2seq.pt")

Детали, которые стоит заметить:

  • Слова валидации модель не видела: словарь разбит на 90% слов для обучения и 10% для проверки, фразы собраны отдельно из каждой части.
  • PAD и pack_padded_sequence. Фразы разной длины дополняются нулями до самой длинной в батче. Чтобы энкодер не «дочитывал» заполнитель, батч упаковывают, и LSTM останавливается на настоящем конце каждой строки. В loss позиции PAD исключаются через ignore_index.
  • Бутылочное горлышко прямо в коде. encode возвращает только последнее состояние — два вектора по 128 чисел. Всё, что декодер знает о входе, проходит через них.
слов: 20861, пример: ('рекуррентная противоположность будут пульте', 'rekurrentnaia protivopolozhnost budut pulte')
шаг 1000: loss 0.9789 (212 с)
шаг 2000: loss 0.7940 (367 с)
шаг 3000: loss 0.6167 (500 с)
шаг 4000: loss 0.5364 (622 с)
шаг 5000: loss 0.4572 (730 с)
шаг 6000: loss 0.4736 (840 с)

Генерировать перевод можно жадно (жадное декодированиежадное декодирование (greedy)EN greedy decodingНа каждом шаге берётся самый вероятный токен (argmax). Детерминировано, но склонно к повторам.): на каждом шаге брать самую вероятную букву. Но самая вероятная буква сейчас не обязательно ведёт к самой вероятной строке целиком. Пример из задания ниже: игрушечная модель транслитерирует «юля», и первой буквой с вероятностью 0.55 считает «y», с 0.45 — «i».

ПутьВероятности по шагамИтог
greedy: y → u → l → i → a → </s>0.55 · 0.5 · 1 · 0.4 · 0.6 · 10.066 — «yulia»
i → u → l → i → a → </s>0.45 · 0.95 · 1 · 0.9 · 1 · 10.385 — «iulia»

Жадный выбор «y» на первом шаге выглядел лучше, но дальше модель в нём не уверена, и итог в шесть раз хуже. лучевой поисклучевой поиск (beam search)EN beam searchДержит B лучших частичных последовательностей вместо одной. Хорош для перевода, плох для свободной генерации. держит BB лучших частичных последовательностей: на каждом шаге продолжает каждую всеми буквами, считает сумму log-вероятностей и снова оставляет BB лучших. Уже при B=2B = 2 путь через «i» не выбрасывается на первом шаге и в итоге побеждает. При B=1B = 1 получается greedy, при бесконечном BB — полный перебор.

Тонкость: каждая новая буква умножает вероятность на число меньше единицы, поэтому короткие гипотезы имеют преимущество. Против этого score делят на длину в степени α\alpha: score=log⁡P/Lα\text{score} = \log P / L^\alpha — нормировка по длиненормировка по длине (в beam search)EN length normalizationДеление суммарной log-вероятности гипотезы на L^α, где L — её длина. Без этого beam search предпочитает короткие ответы: каждый новый токен умножает вероятность на число меньше единицы.. При α=0\alpha = 0 нормировки нет, при α=1\alpha = 1 сравнивается средняя log-вероятность на токен. В уроке 4.1.3 мы говорили, что для свободной генерации beam search плох: он тянет к шаблонам и повторам. Там, где правильный ответ почти однозначен, — перевод, транслитерация, распознавание речи — он как раз полезен.

Точность «совпало целиком или нет» слишком строга: «iulia» и «yulia» различаются одной буквой, а штраф одинаковый с полностью неверным ответом. В машинном переводе стандартная метрика — BLEUBLEUEN BLEU (bilingual evaluation understudy)Метрика машинного перевода (Papineni и соавт., 2002): среднее геометрическое обрезанных точностей n-грамм (n = 1…4), умноженное на штраф за краткость. От 0 до 1, в статьях — от 0 до 100. (Papineni и соавторы, 2002). Её идея: посчитать, какая доля n-грамм ответа встречается в эталоне.

  1. Обрезанная точность pnp_n: сколько n-грамм ответа есть в эталоне, причём каждая засчитывается не больше раз, чем встречается там. Без обрезки ответ «the the the the» получил бы идеальную точность униграмм против эталона «the cat».
  2. Среднее геометрическое p1,…,p4p_1, \dots, p_4 — чтобы учитывались и отдельные слова, и их порядок.
  3. Штраф за краткость BP=min⁡(1,e1−r/c)\text{BP} = \min\big(1, e^{1 - r/c}\big), где rr — длина эталона, cc — длина ответа: иначе выгодно выдавать одно надёжное слово.
BLEU=BP⋅exp⁡(14∑n=14log⁡pn).\text{BLEU} = \text{BP} \cdot \exp\Big(\frac{1}{4}\sum_{n=1}^{4} \log p_n\Big).

Пример посимвольно: ответ «yulia», эталон «iulia». Униграммы: из y, u, l, i, a в эталоне есть четыре — p1=4/5p_1 = 4/5. Биграммы yu, ul, li, ia: совпали три — p2=3/4p_2 = 3/4. Триграммы — 2/32/3, 4-граммы — 1/21/2. Длины равны, BP=1\text{BP} = 1. BLEU=(0.8⋅0.75⋅0.667⋅0.5)1/4=0.21/4≈0.67\text{BLEU} = (0.8 \cdot 0.75 \cdot 0.667 \cdot 0.5)^{1/4} = 0.2^{1/4} \approx 0.67. В статьях BLEU умножают на 100 и считают по словам целого корпуса; разница в 1–2 пункта уже заметна (урок 4.4.1). В d2l-zh вместо среднего геометрического с равными весами взят вариант с весами pn1/2np_n^{1/2^n} — смысл тот же.

Результат: точность падает с длиной входа

Заголовок раздела «Результат: точность падает с длиной входа»
"""eval_seq2seq.py — greedy и beam search, точность по длине входа, teacher forcing против свободной генерации."""
import sys
sys.argv = ["x", "0"] # обучение не запускаем: steps = 0
exec(open("seq2seq.py").read())
model.load_state_dict(torch.load("seq2seq.pt"))
model.eval()
@torch.no_grad()
def greedy(src_text, max_len=80):
src, src_len, _ = batch([(src_text, "")])
state = model.encode(src, src_len)
tok, out = torch.tensor([[BOS]], device=device), []
for _ in range(max_len):
o, state = model.decoder(model.tgt_emb(tok), state)
nxt = model.head(o[:, -1]).argmax(-1).item() # самый вероятный символ
if nxt == EOS:
break
out.append(tgt_itos[nxt])
tok = torch.tensor([[nxt]], device=device)
return "".join(out)
@torch.no_grad()
def beam_search(src_text, beam=5, max_len=80):
src, src_len, _ = batch([(src_text, "")])
state = model.encode(src, src_len)
beams = [(0.0, [BOS], state)] # (сумма log-вероятностей, токены, состояние LSTM)
finished = []
for _ in range(max_len):
cand = []
for score, toks, st in beams:
o, st2 = model.decoder(model.tgt_emb(torch.tensor([[toks[-1]]], device=device)), st)
logp = F.log_softmax(model.head(o[:, -1]), -1)[0]
top = logp.topk(beam)
for lp, ix in zip(top.values.tolist(), top.indices.tolist()):
cand.append((score + lp, toks + [ix], st2))
cand.sort(key=lambda c: c[0], reverse=True)
beams = []
for c in cand[:beam]: # оставляем beam лучших продолжений
(finished if c[1][-1] == EOS else beams).append(c)
if not beams:
break
best = max(finished + beams, key=lambda c: c[0])
return "".join(tgt_itos[i] for i in best[1][1:] if i not in (BOS, EOS))
bins = [(1, 10), (11, 20), (21, 30), (31, 60)]
for name, decode in [("greedy", greedy), ("beam=5", beam_search)]:
pairs = val_pairs[:500] # одни и те же 500 фраз для обоих способов
hits = {b: [0, 0] for b in bins}
for src_text, tgt_text in pairs:
b = next(b for b in bins if b[0] <= len(src_text) <= b[1])
hits[b][0] += decode(src_text) == tgt_text
hits[b][1] += 1
total = sum(h[0] for h in hits.values()) / sum(h[1] for h in hits.values())
print(f"{name}: точность {total:.1%} | " + " ".join(
f"{lo}–{hi}: {h[0] / max(h[1], 1):.0%}" for (lo, hi), h in hits.items()))
# exposure bias: та же модель, но с правильным префиксом на входе (как при обучении)
src, src_len, tgt = batch(val_pairs[:500])
with torch.no_grad():
pred = model(src, src_len, tgt[:, :-1]).argmax(-1)
mask = tgt[:, 1:] != PAD
tf_acc = (pred == tgt[:, 1:])[mask].float().mean().item()
free = [greedy(s) for s, _ in val_pairs[:500]]
char_acc = sum(sum(a == b for a, b in zip(p, t)) for p, (_, t) in zip(free, val_pairs[:500])) / \
sum(len(t) for _, t in val_pairs[:500])
print(f"посимвольная точность: с правильным префиксом {tf_acc:.1%}, со своим префиксом {char_acc:.1%}")
for s, t in val_pairs[:3]:
print(f"{s!r} → {greedy(s)!r} (надо {t!r})")
слов: 20861, пример: ('рекуррентная противоположность будут пульте', 'rekurrentnaia protivopolozhnost budut pulte')
greedy: точность 26.8% | 1–10: 83% 11–20: 34% 21–30: 4% 31–60: 0%
beam=5: точность 33.0% | 1–10: 83% 11–20: 51% 21–30: 11% 31–60: 0%
посимвольная точность: с правильным префиксом 82.7%, со своим префиксом 48.3%
'средние маркеры замаскированные' → 'sredniu ekstragen vygodnye mak' (надо 'srednie markery zamaskirovannye')
'вероятное шаблонные невиданная' → 'veroiatnoe shanov blizhnomu nagra' (надо 'veroiatnoe shablonnye nevidannaia')
'задаются' → 'zadiaiutsa' (надо 'zadaiutsia')

Время обучения — 14 минут на загруженном четырёхъядерном процессоре; на RTX 4080 счёт пойдёт на минуты (оценка). В валидационных 500 фразах 98 коротких (до 10 символов), 140 — от 11 до 20, 117 — от 21 до 30 и 145 длиннее. Как читать:

  • Бутылочное горлышко. Фразы до 10 символов модель транслитерирует целиком правильно в 83% случаев, от 11 до 20 — в 34%, от 21 до 30 — в 4%, длиннее — ни разу. Правила одни и те же для любой длины, меняется только объём, который надо протащить через 2 × 128 чисел. Посмотрите на первый пример: «средние маркеры замаскированные» → «sredniu ekstragen vygodnye mak». Начало верное, а дальше декодер «забыл» вход и продолжает как языковая модель — выдумывает правдоподобные латинские слова. Ровно эту картину видели на переводе Bahdanau и соавторы (бутылочное горлышкобутылочное горлышко seq2seqEN seq2seq bottleneck (fixed-size context vector)В seq2seq без внимания энкодер сжимает весь вход в одно состояние фиксированного размера. Короткий вход в нём помещается, длинный — нет, и качество падает с длиной. Лечится вниманием к состояниям энкодера (Bahdanau, 2014)., урок 4.4.1).
  • Beam search помогает, но не лечит. На тех же 500 фразах beam ширины 5 поднимает точность с 26.8% до 33.0%, сильнее всего на средних длинах (34% → 51%). Короткие и так хороши, а на длинных искать нечего: правильного ответа нет среди вероятных.
  • Exposure bias в цифрах. С правильным префиксом модель угадывает следующую букву в 82.7% случаев. Когда она читает свои же предсказания, по позициям совпадает только 48.3% букв. Часть разницы — ошибки, которые тянут за собой следующие, часть — сдвиги: пропустила букву, и все дальнейшие позиции уже не совпадают. BLEU по n-граммам к таким сдвигам мягче, чем сравнение по позициям.

Реализуйте greedy-декодирование, beam search с нормировкой по длине и BLEU поверх игрушечной «модели» транслитерации. Тесты сверяют beam search с полным перебором на случайных моделях, проверяют нормировку по длине и пример из d2l-zh для BLEU.

Beam search и BLEU8 тестов
import math
from collections import Counter

EOS = 0  # номер токена «конец последовательности» — всегда 0


def make_step(table, vocab):
    """Игрушечная «модель»: словарь «префикс → {символ: вероятность}» превращается в функцию step.

    step(prefix) получает кортеж номеров уже сгенерированных токенов и возвращает список
    log-вероятностей следующего токена — по одному числу на каждый токен словаря (−inf, если вероятность 0).
    Для префикса, которого нет в таблице, модель уверенно говорит «конец»: P(EOS) = 1.
    """
    def step(prefix):
        key = "".join(vocab[i] for i in prefix)
        dist = table.get(key, {vocab[EOS]: 1.0})
        return [math.log(dist[ch]) if dist.get(ch, 0) > 0 else -math.inf for ch in vocab]
    return step


# «Модель» транслитерации имени «юля». По правилам загранпаспорта правильно — «iulia».
VOCAB = ["</s>", "a", "i", "l", "o", "u", "y"]
TABLE = {
    "": {"y": 0.55, "i": 0.45},
    "y": {"u": 0.5, "o": 0.3, "a": 0.2},
    "i": {"u": 0.95, "o": 0.05},
    "yu": {"l": 1.0}, "yo": {"l": 1.0}, "ya": {"l": 1.0}, "iu": {"l": 1.0}, "io": {"l": 1.0},
    "yul": {"i": 0.4, "a": 0.35, "y": 0.25},
    "iul": {"i": 0.9, "y": 0.1},
    "yuli": {"a": 0.6, "</s>": 0.4},
    "yuly": {"a": 1.0},
    "iuli": {"a": 1.0},
    "iuly": {"a": 1.0},
}
STEP = make_step(TABLE, VOCAB)


def decode(tokens, vocab=VOCAB):
    return "".join(vocab[i] for i in tokens)


def greedy_decode(step, max_len=20):
    """Жадное декодирование: на каждом шаге берём токен с максимальной log-вероятностью.

    Начинаем с пустого префикса (). Если выбран EOS — останавливаемся (EOS в токены НЕ добавляем,
    но его log-вероятность входит в сумму). Если за max_len шагов EOS не выпал — останавливаемся так.
    Возвращает пару (список токенов, сумма log-вероятностей).
    """
    # TODO: ваш код
    ...


def beam_search(step, beam_size=3, max_len=20, alpha=0.0):
    """Лучевой поиск. Возвращает список гипотез (tokens, logprob, score), отсортированный по score (лучшие первыми).

    Алгоритм:
      beams = [((), 0.0)] — живые гипотезы (кортеж токенов, сумма log-вероятностей); finished = [].
      На каждом шаге (не больше max_len):
        - каждую живую гипотезу продолжаем КАЖДЫМ токеном словаря: logprob + step(prefix)[v];
          токены с log-вероятностью −inf пропускаем;
        - продолжение токеном EOS — законченная гипотеза (prefix без EOS), остальные — живые (prefix + (v,));
        - из ВСЕХ кандидатов берём beam_size лучших по logprob; законченные среди них уходят в finished,
          живые становятся новыми beams. Если живых не осталось — стоп.
      Живые гипотезы, оставшиеся после max_len шагов, тоже добавьте в finished.
    score = logprob / L ** alpha, где L — число сгенерированных токенов (EOS считается, если он был).
    При alpha = 0 score = logprob.
    """
    # TODO: ваш код
    ...


def modified_precision(pred, ref, n):
    """«Обрезанная» точность n-грамм: доля n-грамм предсказания, которые есть в эталоне.

    Каждая n-грамма засчитывается не больше раз, чем она встречается в эталоне (pred "aaaa", ref "ab", n=1 → 1/4).
    pred и ref — строки или списки токенов. Если в pred нет ни одной n-граммы, верните 0.0.
    """
    # TODO: ваш код
    ...


def bleu(pred, ref, max_n=4):
    """BLEU = BP · exp(среднее по n = 1..max_n от log p_n), p_n = modified_precision(pred, ref, n).

    BP (штраф за краткость) = 1, если len(pred) > len(ref), иначе exp(1 − len(ref) / len(pred)).
    Если pred пустой или какая-то p_n = 0, BLEU = 0.0.
    """
    # TODO: ваш код
    ...


# ---------- Кнопка «Запустить» ----------
def demo():
    toks, lp = greedy_decode(STEP)
    print(f"greedy:  {decode(toks)!r:9}  P = {math.exp(lp):.4f}")
    for b in (2, 5):
        best = beam_search(STEP, beam_size=b)[0]
        print(f"beam={b}:  {decode(best[0])!r:9}  P = {math.exp(best[1]):.4f}")
    ref = "iulia"
    print("BLEU greedy:", round(bleu(decode(toks), ref), 3), "  BLEU beam:", round(bleu(decode(beam_search(STEP, 2)[0][0]), ref), 3))


try:
    demo()
except Exception as e:  # пока функции не дописаны
    print("Допишите функции:", type(e).__name__, e)

После тестов поэкспериментируйте:

  1. Найдите в TABLE наименьшую вероятность «i» на первом шаге, при которой даже greedy выбирает «iulia».
  2. Составьте модель, в которой beam search с B=2B = 2 ошибается, а с B=3B = 3 — нет.
  3. Посчитайте BLEU для «iuliia» против «iulia». Почему штраф за краткость здесь не срабатывает, а точность всё равно меньше 1?
Проверь себя6 вопросов
1 Текст из 1 415 589 символов режут на B=32B = 32 ленты и берут окна по T=64T = 64. Сколько полных окон помещается в одной ленте (вход и цель сдвинуты на один символ)?
2 Что делает detach состояния между окнами в обрезанном BPTT?
3 Loss RNN перестаёт меняться после контекста L=16L = 16, а у LSTM продолжает понемногу падать до L=128L = 128. Что это значит?
4 Игрушечная модель: первый шаг — «a» с вероятностью 0.6, «b» — 0.4. После «a» лучшее продолжение имеет вероятность 0.5, после «b» — 0.9; затем в обоих случаях конец с вероятностью 1. Какова вероятность ответа beam search с B=2B = 2? Округлите до двух знаков.
5 Что верно про teacher forcing и exposure bias? Выберите все верные варианты.Можно выбрать несколько ответов
6 Ответ «AB», эталон «ABCDEF», BLEU по n=1,2n = 1, 2. Обе точности равны 1. Чему равен BLEU? Округлите до трёх знаков.
  • Char-RNN учат обрезанным BPTT: текст режут на параллельные ленты, состояние переносят из окна в окно (detach обрывает градиент), норму градиента обрезают. На корпусе курса LSTM дала val 1.54 против 1.65 у простой RNN.
  • Опыт с контекстом длины LL: RNN перестаёт использовать символы дальше ~16 назад, LSTM — дальше 64–128. MLP видел 3.
  • Seq2seq: энкодер сжимает вход в одно состояние, декодер — условная языковая модель. Учат с teacher forcing, на инференсе мешает exposure bias.
  • Beam search держит BB лучших гипотез и находит более вероятные ответы, чем greedy; нормировка по длине убирает перекос в пользу коротких. BLEU — обрезанная точность n-грамм, среднее геометрическое и штраф за краткость.
  • Бутылочное горлышко на опыте: транслитерация верна в 83% фраз до 10 символов, в 4% — от 21 до 30 и ни разу для более длинных, потому что весь вход сжат в 2 × 128 чисел. Лекарство — внимание к состояниям энкодера (урок 4.4.1).

Дальше: модуль 4.2 «Токенизация» — откуда берутся токены и почему символ — не лучшая единица для языковой модели — Зачем нужны токены.