Рекуррентная языковая модель и seq2seq на практике
Обучаем посимвольные RNN и LSTM как языковые модели (обрезанный BPTT, перенос состояния, клиппинг) и меряем, насколько далёкий контекст они помнят. Затем собираем encoder–decoder для транслитерации, разбираем teacher forcing, exposure bias, greedy и beam search, BLEU — и своими глазами видим «бутылочное горлышко».
Зачем это нужно
Заголовок раздела «Зачем это нужно»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.
RNN, LSTM и GRU: что нужно помнить
Заголовок раздела «RNN, LSTM и GRU: что нужно помнить»Коротко напомним урок 3.4.3. Простая RNN на каждом шаге делает
то есть смешивает старое состояние с новым символом и по состоянию предсказывает следующий символ. Веса одни на всех шагах. Беда в том, что градиент к шагу проходит через умножений на одну и ту же матрицу и затухаетзатухание градиентов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 — «остаточная связь во времени». добавляет ячейку памяти , которая обновляется сложением, и три гейта — «вентиля» со значениями от 0 до 1. Гейт забывания решает, что стереть из ячейки, входной — что записать, выходной — что показать наружу. Пока гейт забывания открыт, градиент идёт по ячейке почти без потерь, как по остаточной связи. GRUGRU (управляемый рекуррентный блок)EN gated recurrent unit (GRU)Упрощённая LSTM с двумя гейтами и без отдельной ячейки памяти (Cho и соавт., 2014). — упрощённая LSTM: ячейки нет, гейтов два. Гейт обновления решает, какую долю старого состояния оставить, а какую заменить кандидатом : . Гейт сброса решает, сколько прошлого учитывать при расчёте кандидата. Работает GRU обычно не хуже LSTM, а параметров у неё на четверть меньше.
В PyTorch все три — готовые модули nn.RNN, nn.LSTM, nn.GRU с одинаковым интерфейсом: на вход последовательность
векторов, на выход состояние после каждого шага и последнее состояние.
Char-RNN: языковая модель на длинном тексте
Заголовок раздела «Char-RNN: языковая модель на длинном тексте»Данные и обрезанный BPTT
Заголовок раздела «Данные и обрезанный BPTT»Возьмём русскую прозу этого курса — около 1.6 млн символов (скрипт подготовки — под катом). Модель, обученная на учебнике, будет писать «под учебник», и это наглядно: языковая модель — сжатая статистика своего корпуса.
prepare_corpus.py — тексты уроков → input.txt
Скрипт собирает из MDX-файлов курса обычные абзацы и выбрасывает код, формулы и разметку. Запуск из корня репозитория. Тот же скрипт пригодится в уроке 4.6.2.
"""prepare_corpus.py — русская проза из уроков курса → input.txt. Запуск из корня репозитория."""import pathlibimport 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 шагов.:
- Текст режут на параллельные «ленты»: лента 0 — первая тридцать вторая часть текста, лента 1 — вторая, и так далее. Каждая лента читается подряд, от начала до конца.
- На каждом шаге обучения из всех лент берут следующие символа — батч формы .
- Состояние после окна переносится в следующее окно той же ленты: модель продолжает читать с того места, где остановилась, и прямой проход «помнит» дальше 64 символов.
- Но граф перед новым окном отрезают (
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"]
Ещё одна обязательная деталь — клиппинг градиентаобрезка градиента (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 mathimport sysimport time
import torchimport torch.nn as nnimport torch.nn.functional as F
torch.manual_seed(0)torch.set_num_threads(int(sys.argv[2]) if len(sys.argv) > 2 else 4) # второй аргумент — потоки CPUdevice = "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 2000python charlm.py lstm 1 2000символов: 1 572 877, словарь: 87rnn шаг 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, словарь: 87lstm шаг 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 против кандидатов на символ. У равномерной модели было бы нат, то есть 87 кандидатов. Норма градиента ниже порога 1.0 почти всегда — клиппинг здесь страховка от редких выбросов, а не постоянная мера.
Посмотрим, что пишет LSTM (температура 0.8 из урока 4.1.3; код генерации — тот же авторегрессионный цикл, что в 4.1.4, только вместо окна переносится состояние):
(это центрор на медленной половину не рассёзная только текст ) — пишут на сих для больше её в 4 битного подобновкод.Это - меньше современная целиком. Две код батч, что цель обычно на памяти: смотнтативать валидации ответ.Почти все слова настоящие или похожи на настоящие, есть пунктуация и даже закрытая скобка, словарь явно «из учебника» («батч», «валидации», «4 битного»). Связного смысла нет: для этого нужна модель в тысячи раз больше.
Насколько далеко помнит модель
Заголовок раздела «Насколько далеко помнит модель»В теории RNN помнит всё прочитанное. Проверим на практике. Возьмём 2000 случайных позиций в валидационном тексте и для каждой дадим модели ровно предыдущих символов, начиная с нулевого состояния. Если loss перестаёт падать при росте , значит, более далёкий контекст модель уже не использует.
"""memory.py — насколько далёкий контекст использует модель: loss при контексте ровно L символов."""import syssys.argv = ["x"]exec(open("charlm.py").read().split('if __name__')[0]) # данные и класс CharRNNtorch.set_num_threads(1)
g = torch.Generator().manual_seed(0)pos = torch.randint(512, len(val_data), (2000,), generator=g) # 2000 случайных позиций в valmodels = {}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 резко падает до : внутри слова и на стыке соседних слов предсказывать проще всего.
- RNN забывает за 10–16 символов. После её loss не меняется вообще: что было 20 символов назад, на предсказание уже не влияет. Это затухание из урока 3.4.3, только измеренное на настоящей задаче.
- LSTM пользуется контекстом до 64–128 символов. Её loss продолжает понемногу падать до (с 1.508 при до 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:
- Энкодер — LSTM, которая читает кириллическую строку. Её последнее состояние — «смысл» всего входа.
- Декодер — вторая LSTM, условная языковая модель: она начинает с состояния энкодера и генерирует латинские
буквы по одной, пока не выдаст
</s>.
flowchart LR E["Энкодер (LSTM)<br/>читает: щ, у, к, а"] ==>|"одно состояние (h, c)<br/>2 × 128 чисел"| D["Декодер (LSTM)<br/>пишет: s, h, c, h, u, k, a, конец"]
Teacher forcing и exposure bias
Заголовок раздела «Teacher forcing и exposure bias»Как учить декодер? Можно позволить ему генерировать и штрафовать за ошибки, но в начале обучения он выдаёт мусор, и после первой ошибки все следующие шаги учатся на бессмысленном префиксе. Поэтому почти всегда применяют teacher forcingteacher forcing (обучение на верном префиксе)EN teacher forcingПри обучении модель на каждом шаге видит настоящий текст, а не свои предсказания. Поэтому все позиции можно обучать параллельно.: на вход декодеру подают правильный префикс, сдвинутый на один шаг.
| Шаг | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Вход декодера | <s> | s | h | c | h | u | k | a |
| Цель | s | h | c | h | u | k | a | </s> |
Все восемь шагов считаются одним проходом LSTM, loss — средняя cross-entropy по ним, ровно как в языковой модели. Плата — смещение экспозициисмещение экспозиции (exposure bias)EN exposure biasПри обучении модель видит только «правильные» префиксы, а при генерации — свои собственные, с ошибками. Отсюда накопление ошибок.: при обучении декодер ни разу не видел собственных ошибок, а на инференсе он читает свои же предсказания. Ошибся в одной букве — и следующий шаг идёт с префиксом, какого он никогда не видел. Ниже мы это измерим.
Код: модель и обучение
Заголовок раздела «Код: модель и обучение»"""seq2seq.py — транслитерация «кириллица → латиница» на encoder–decoder из двух LSTM. python seq2seq.py"""import randomimport reimport sysimport time
import torchimport torch.nn as nnimport 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, 2src_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 и beam search
Заголовок раздела «Greedy и beam search»Генерировать перевод можно жадно (жадное декодированиежадное декодирование (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 · 1 | 0.066 — «yulia» |
i → u → l → i → a → </s> | 0.45 · 0.95 · 1 · 0.9 · 1 · 1 | 0.385 — «iulia» |
Жадный выбор «y» на первом шаге выглядел лучше, но дальше модель в нём не уверена, и итог в шесть раз хуже. лучевой поисклучевой поиск (beam search)EN beam searchДержит B лучших частичных последовательностей вместо одной. Хорош для перевода, плох для свободной генерации. держит лучших частичных последовательностей: на каждом шаге продолжает каждую всеми буквами, считает сумму log-вероятностей и снова оставляет лучших. Уже при путь через «i» не выбрасывается на первом шаге и в итоге побеждает. При получается greedy, при бесконечном — полный перебор.
Тонкость: каждая новая буква умножает вероятность на число меньше единицы, поэтому короткие гипотезы имеют преимущество. Против этого score делят на длину в степени : — нормировка по длиненормировка по длине (в beam search)EN length normalizationДеление суммарной log-вероятности гипотезы на L^α, где L — её длина. Без этого beam search предпочитает короткие ответы: каждый новый токен умножает вероятность на число меньше единицы.. При нормировки нет, при сравнивается средняя log-вероятность на токен. В уроке 4.1.3 мы говорили, что для свободной генерации beam search плох: он тянет к шаблонам и повторам. Там, где правильный ответ почти однозначен, — перевод, транслитерация, распознавание речи — он как раз полезен.
BLEU: насколько ответ похож на эталон
Заголовок раздела «BLEU: насколько ответ похож на эталон»Точность «совпало целиком или нет» слишком строга: «iulia» и «yulia» различаются одной буквой, а штраф одинаковый с полностью неверным ответом. В машинном переводе стандартная метрика — BLEUBLEUEN BLEU (bilingual evaluation understudy)Метрика машинного перевода (Papineni и соавт., 2002): среднее геометрическое обрезанных точностей n-грамм (n = 1…4), умноженное на штраф за краткость. От 0 до 1, в статьях — от 0 до 100. (Papineni и соавторы, 2002). Её идея: посчитать, какая доля n-грамм ответа встречается в эталоне.
- Обрезанная точность : сколько n-грамм ответа есть в эталоне, причём каждая засчитывается не больше раз, чем встречается там. Без обрезки ответ «the the the the» получил бы идеальную точность униграмм против эталона «the cat».
- Среднее геометрическое — чтобы учитывались и отдельные слова, и их порядок.
- Штраф за краткость , где — длина эталона, — длина ответа: иначе выгодно выдавать одно надёжное слово.
Пример посимвольно: ответ «yulia», эталон «iulia». Униграммы: из y, u, l, i, a в эталоне есть четыре — . Биграммы yu, ul, li, ia: совпали три — . Триграммы — , 4-граммы — . Длины равны, . . В статьях BLEU умножают на 100 и считают по словам целого корпуса; разница в 1–2 пункта уже заметна (урок 4.4.1). В d2l-zh вместо среднего геометрического с равными весами взят вариант с весами — смысл тот же.
Результат: точность падает с длиной входа
Заголовок раздела «Результат: точность падает с длиной входа»"""eval_seq2seq.py — greedy и beam search, точность по длине входа, teacher forcing против свободной генерации."""import syssys.argv = ["x", "0"] # обучение не запускаем: steps = 0exec(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:] != PADtf_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.
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)
После тестов поэкспериментируйте:
- Найдите в
TABLEнаименьшую вероятность «i» на первом шаге, при которой даже greedy выбирает «iulia». - Составьте модель, в которой beam search с ошибается, а с — нет.
- Посчитайте BLEU для «iuliia» против «iulia». Почему штраф за краткость здесь не срабатывает, а точность всё равно меньше 1?
Проверь себя
Заголовок раздела «Проверь себя»- Char-RNN учат обрезанным BPTT: текст режут на параллельные ленты, состояние переносят из окна в окно (
detachобрывает градиент), норму градиента обрезают. На корпусе курса LSTM дала val 1.54 против 1.65 у простой RNN. - Опыт с контекстом длины : RNN перестаёт использовать символы дальше ~16 назад, LSTM — дальше 64–128. MLP видел 3.
- Seq2seq: энкодер сжимает вход в одно состояние, декодер — условная языковая модель. Учат с teacher forcing, на инференсе мешает exposure bias.
- Beam search держит лучших гипотез и находит более вероятные ответы, чем greedy; нормировка по длине убирает перекос в пользу коротких. BLEU — обрезанная точность n-грамм, среднее геометрическое и штраф за краткость.
- Бутылочное горлышко на опыте: транслитерация верна в 83% фраз до 10 символов, в 4% — от 21 до 30 и ни разу для более длинных, потому что весь вход сжат в 2 × 128 чисел. Лекарство — внимание к состояниям энкодера (урок 4.4.1).
Дальше: модуль 4.2 «Токенизация» — откуда берутся токены и почему символ — не лучшая единица для языковой модели — Зачем нужны токены.