Перейти к содержимому
ПереводыИсследователи и авторы

Крис Ола: как устроены LSTM

Подробный конспект эссе «Understanding LSTM Networks»: зачем рекуррентной сети отдельная ячейка памяти, как работают три гейта, чем отличаются peephole-связи и GRU — с формулами, числами и кодом на NumPy.

  • Обычная рекуррентная сеть (рекуррентная нейросетьрекуррентная нейросетьEN recurrent neural network (RNN)) теоретически может помнить далёкое прошлое, но на практике не учится связывать события, разнесённые на десятки шагов: градиент по пути назад затухает.
  • LSTMLSTM (долгая краткосрочная память)EN long short-term memory (LSTM)RNN с ячейкой памяти c_t и тремя гейтами (Hochreiter, Schmidhuber, 1997). Ячейка обновляется сложением c_t = f·c_{t−1} + i·g — «остаточная связь во времени». добавляет к сети отдельную ячейку памяти CtC_t — «конвейерную ленту», по которой информация едет почти без изменений. Ячейка меняется только сложением и поэлементным умножением.
  • Что стереть, что записать и что показать наружу, решают три гейтагейт (вентиль)EN gate, gatingВектор из чисел от 0 до 1 (через сигмоиду), который поэлементно решает, какую долю сигнала пропустить. Из LSTM гейты перешли в SwiGLU, gated attention и Gated DeltaNet.: сигмоида выдаёт числа от 0 до 1, и они поэлементно умножаются на вектор.
  • Вариантов LSTM много (peephole-связи, связанные гейты, GRUGRU (управляемый рекуррентный блок)EN gated recurrent unit (GRU)Упрощённая LSTM с двумя гейтами и без отдельной ячейки памяти (Cho и соавт., 2014).), и по сравнительным исследованиям они работают примерно одинаково.
  • В конце Ола предсказывает следующий большой шаг — внимание (attention). Через два года из него вырос трансформер.

Эссе короткое и почти без математики сверх уровня урока 3.4.3 «CNN и RNN». Лучше всего читать его сразу после этого урока: уравнения LSTM вы там уже видели, а Ола показывает, что за ними стоит, на картинках. Перед уроком 4.1.5, где вы обучаете char-RNN и LSTM своими руками, полезно перечитать раздел про гейты ещё раз.

Обозначения у Олы чуть отличаются от курса: ячейка у него — CtC_t (в курсе ctc_t), кандидат на запись — C~t\tilde C_t (в курсе gtg_t), умножение по элементам — звёздочка ∗* (в курсе ⊙\odot). Смысл тот же.

Ола начинает с наблюдения о людях: читая текст, мы понимаем каждое слово с учётом предыдущих, а не начинаем думать заново. Обычная полносвязная сеть так не умеет: она видит один вход и забывает его, как только выдала ответ.

Рекуррентная сеть решает это петлёй: кусок сети AA получает вход xtx_t, выдаёт hth_t и передаёт своё состояние самому себе на следующий шаг. Петля выглядит загадочно, пока её не развернуть: тогда RNN — это цепочка одинаковых копий одной и той же сети, каждая передаёт «записку» следующей. Веса у всех копий общие.

flowchart LR
x0["x₀"] --> A0["A"] --> h0["h₀"]
x1["x₁"] --> A1["A"] --> h1["h₁"]
x2["x₂"] --> A2["A"] --> h2["h₂"]
A0 -->|"состояние"| A1 -->|"состояние"| A2
Развёрнутая RNN: одна и та же сеть A на каждом шаге передаёт состояние дальше

Такая цепочка естественно подходит для последовательностей: текста, звука, видео. К 2015 году RNN уже давали сильные результаты в распознавании речи, переводе, языковом моделировании и подписях к картинкам. Почти все эти результаты, подчёркивает Ола, получены не на простой RNN, а на LSTM. За примерами он отсылает к посту Андрея Карпатого «The Unreasonable Effectiveness of Recurrent Neural Networks» — тот самый char-RNN, который вы обучаете в уроке 4.1.5.

Иногда для предсказания хватает пары последних слов. Во фразе «облака плывут по …» следующее слово «небу» очевидно, нужная информация стоит рядом, и простая RNN с этим справляется.

Но бывает иначе. Возьмём текст: «Я родился в Харбине и прожил там двадцать лет. … (ещё три абзаца о работе и переездах) … Поэтому я свободно говорю по-…». Ближайшие слова подсказывают, что дальше будет название языка, но какого — знает только начало текста. Разрыв между нужным фактом и местом, где он нужен, может быть огромным.

Ола пишет, что в теории RNN способна на такие связи: человек мог бы вручную подобрать ей веса для игрушечной задачи. Беда в том, что сама сеть этому не учится. Причины разобрали Хохрайтер (1991, диссертация на немецком) и Бенджио с соавторами (1994).

Почувствуйте масштаб на числах. Градиент от ошибки на шаге tt к шагу t−kt - k проходит kk раз через одну и ту же матрицу и производную tanh⁡\tanh. Пусть каждый такой переход уменьшает сигнал примерно вдвое. Тогда через 10 шагов останется 0.510≈0.0010.5^{10} \approx 0.001 исходного градиента, а через 50 шагов — 0.550≈9⋅10−160.5^{50} \approx 9 \cdot 10^{-16}, то есть ничего. Это затухание градиентовзатухание градиентовEN vanishing gradientsГрадиент первых слоёв — произведение множителей всех слоёв выше; если они меньше 1, он экспоненциально уменьшается с глубиной (0.9⁵⁰ ≈ 0.005), и нижние слои перестают учиться. из урока 3.4.3.

Повторяющийся блок простой RNN устроен бедно: один слой с tanh⁡\tanh. У LSTM в блоке четыре слоя, которые взаимодействуют особым образом. Ола сразу предупреждает: не пугайтесь общей схемы, сначала разберёмся с обозначениями. На его рисунках:

  • каждая линия несёт целый вектор;
  • жёлтые прямоугольники — обучаемые слои нейросети (сигмоида или tanh⁡\tanh поверх линейного преобразования);
  • розовые кружки — поэлементные операции (сложение, умножение);
  • слияние двух линий — склейка векторов (конкатенация), разветвление — копирование.

Ключ ко всему — состояние ячейки CtC_t, горизонтальная линия вдоль верха схемы. Ола сравнивает её с конвейерной лентой: она проходит через всю цепочку, и на неё действуют лишь мелкие линейные операции. Информации очень легко проехать по ней без изменений.

Менять содержимое ленты можно только через гейты (gates). Гейт — это слой с сигмоидой и поэлементное умножение после него. Сигмоида выдаёт для каждой компоненты число от 0 до 1: «0 — ничего не пропускать, 1 — пропустить всё». У LSTM три гейта, и все они охраняют ленту.

flowchart LR
Cp["C(t−1): старая память"] --> M1(("×"))
F["f(t): гейт забывания"] --> M1
M1 --> S(("+"))
I["i(t): входной гейт"] --> M2(("×"))
G["C̃(t): кандидаты"] --> M2
M2 --> S
S --> C["C(t): новая память"]
C --> T["tanh"] --> M3(("×"))
O["o(t): выходной гейт"] --> M3
M3 --> H["h(t): выход"]
Путь памяти в ячейке LSTM: умножение на гейт забывания, прибавление новой записи, отфильтрованный выход

Все четыре слоя получают на вход одно и то же — склейку прошлого выхода и нового входа [ht−1,xt][h_{t-1}, x_t].

Слой «гейт забывания» (forget gate) смотрит на ht−1h_{t-1} и xtx_t и для каждой компоненты ячейки выдаёт число от 0 до 1:

ft=σ(Wf⋅[ht−1,xt]+bf).f_t = \sigma\big(W_f \cdot [h_{t-1}, x_t] + b_f\big).

Единица значит «оставить полностью», ноль — «стереть». Пример Олы из языковой модели: ячейка может хранить грамматический род текущего подлежащего, чтобы потом выбрать правильное местоимение. Появилось новое подлежащее — старый род пора забыть.

Здесь две части. Сигмоидный «входной гейт» (input gate) решает, какие компоненты обновлять, а слой с tanh⁡\tanh готовит кандидатов — новые значения от −1 до 1:

it=σ(Wi⋅[ht−1,xt]+bi),C~t=tanh⁡(WC⋅[ht−1,xt]+bC).i_t = \sigma\big(W_i \cdot [h_{t-1}, x_t] + b_i\big), \qquad \tilde C_t = \tanh\big(W_C \cdot [h_{t-1}, x_t] + b_C\big).

В примере с подлежащим это запись рода нового подлежащего на место старого.

Предыдущие шаги только решили, что делать; теперь это делается:

Ct=ft∗Ct−1+it∗C~t.C_t = f_t * C_{t-1} + i_t * \tilde C_t.

Читается так: «старую память умножаем на маску забывания и прибавляем кандидатов, умноженных на маску записи».

Посчитаем на числах. Пусть в ячейке две компоненты, Ct−1=(1.0, −1.0)C_{t-1} = (1.0,\ -1.0). Гейт забывания ft=(1, 0)f_t = (1,\ 0): первую компоненту сохраняем, вторую стираем. Входной гейт it=(0, 1)i_t = (0,\ 1): в первую ничего не пишем, во вторую пишем. Кандидаты C~t=(0.5, 0.8)\tilde C_t = (0.5,\ 0.8). Тогда

Ct=(1⋅1.0+0⋅0.5,  0⋅(−1.0)+1⋅0.8)=(1.0, 0.8).C_t = (1 \cdot 1.0 + 0 \cdot 0.5,\ \ 0 \cdot (-1.0) + 1 \cdot 0.8) = (1.0,\ 0.8).

Первая «ячейка памяти» пережила шаг нетронутой, вторая перезаписана. Настоящие гейты дают не ровно 0 и 1, а промежуточные значения, то есть смешивают старое и новое в нужной пропорции.

Выход hth_t — это отфильтрованная версия ячейки. Сначала сигмоидный «выходной гейт» (output gate) решает, какие части показать. Потом ячейку пропускают через tanh⁡\tanh (чтобы значения легли в диапазон от −1 до 1) и умножают на гейт:

ot=σ(Wo⋅[ht−1,xt]+bo),ht=ot∗tanh⁡(Ct).o_t = \sigma\big(W_o \cdot [h_{t-1}, x_t] + b_o\big), \qquad h_t = o_t * \tanh(C_t).

Пример Олы: модель только что увидела подлежащее, и дальше, возможно, будет глагол. Тогда наружу полезно выдать, единственное или множественное число у подлежащего, чтобы правильно согласовать глагол. При этом в самой ячейке может храниться гораздо больше.

Ола в эссе почти не говорит о градиентах, но именно здесь разгадка (урок 3.4.3 разбирает её подробно). По прямому пути через ленту производная ∂Ct/∂Ct−1=ft\partial C_t / \partial C_{t-1} = f_t — без матриц и без производной tanh⁡\tanh. Если сеть научилась держать ft≈0.99f_t \approx 0.99, то через 50 шагов от градиента останется 0.9950≈0.610.99^{50} \approx 0.61, а при ft≈0.95f_t \approx 0.95 — около 0.080.08. Сравните с 9⋅10−169 \cdot 10^{-16} у простой RNN из раздела 2. Поэтому, по словам Олы, помнить надолго для LSTM — «практически поведение по умолчанию», а не то, чему её приходится мучительно учить.

import numpy as np
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def lstm_step(x, h_prev, C_prev, W, b):
"""Один шаг LSTM в обозначениях Олы.
x: вход (n_in,), h_prev и C_prev: прошлые состояния (n_h,)
W: словарь из четырёх матриц формы (n_h, n_h + n_in), b: словарь сдвигов."""
hx = np.concatenate([h_prev, x]) # склейка [h_{t-1}, x_t]
f = sigmoid(W["f"] @ hx + b["f"]) # гейт забывания: что стереть
i = sigmoid(W["i"] @ hx + b["i"]) # входной гейт: что записать
C_tilde = np.tanh(W["C"] @ hx + b["C"]) # кандидаты на запись
C = f * C_prev + i * C_tilde # новая ячейка: сложение!
o = sigmoid(W["o"] @ hx + b["o"]) # выходной гейт: что показать
h = o * np.tanh(C) # новое скрытое состояние
return h, C
rng = np.random.default_rng(0)
n_in, n_h = 3, 2
W = {k: rng.normal(0, 0.5, size=(n_h, n_h + n_in)) for k in "fiCo"}
b = {k: np.zeros(n_h) for k in "fiCo"}
b["f"] += 3.0 # большой сдвиг гейта забывания: f ≈ 0.95, ячейка почти не забывает
h, C = np.zeros(n_h), np.array([1.0, -1.0]) # в ячейке уже лежит «память»
for t in range(5):
x = rng.normal(size=n_in)
h, C = lstm_step(x, h, C, W, b)
print(f"t={t}: C={np.round(C, 3)}, h={np.round(h, 3)}")

Ожидаемый вывод:

t=0: C=[ 1.437 -0.454], h=[ 0.588 -0.358]
t=1: C=[ 1.232 -0.557], h=[ 0.362 -0.258]
t=2: C=[ 1.198 -0.971], h=[ 0.509 -0.672]
t=3: C=[ 0.932 -1.11 ], h=[ 0.307 -0.297]
t=4: C=[ 0.513 -1.135], h=[ 0.226 -0.297]

Веса здесь случайные, но знаки «памяти» (плюс в первой компоненте, минус во второй) переживают все пять шагов со случайными входами. Сдвиг гейта забывания bf=3b_f = 3 — известный практический приём: в начале обучения ячейка по умолчанию помнит, а забывать сеть учится сама.

Ола честно предупреждает: то, что он описал, — «довольно обычная» LSTM, но почти в каждой статье своя версия. Отличия небольшие, вот главные.

Peephole-связи (Герс и Шмидхубер, 2000). Гейтам разрешают «подглядывать» в саму ячейку: к входу каждого гейта добавляют Ct−1C_{t-1} (а выходному — уже обновлённую CtC_t):

ft=σ(Wf⋅[Ct−1,ht−1,xt]+bf)f_t = \sigma\big(W_f \cdot [C_{t-1}, h_{t-1}, x_t] + b_f\big)

и аналогично для iti_t и oto_t. Часто peephole ставят не на все гейты.

Связанные гейты забывания и записи. Вместо двух независимых решений — одно: забываем ровно там, куда пишем новое, и пишем только туда, где что-то забыли:

Ct=ft∗Ct−1+(1−ft)∗C~t.C_t = f_t * C_{t-1} + (1 - f_t) * \tilde C_t.

GRU (Чо и соавторы, 2014) — более заметная переделка. Гейты забывания и записи слиты в один гейт обновления ztz_t, ячейка и скрытое состояние объединены, добавлен гейт сброса rtr_t:

zt=σ(Wz⋅[ht−1,xt]),rt=σ(Wr⋅[ht−1,xt]),h~t=tanh⁡(W⋅[rt∗ht−1, xt]),ht=(1−zt)∗ht−1+zt∗h~t.\begin{aligned} z_t &= \sigma\big(W_z \cdot [h_{t-1}, x_t]\big), \qquad r_t = \sigma\big(W_r \cdot [h_{t-1}, x_t]\big),\\ \tilde h_t &= \tanh\big(W \cdot [r_t * h_{t-1},\ x_t]\big),\\ h_t &= (1 - z_t) * h_{t-1} + z_t * \tilde h_t. \end{aligned}

Модель проще LSTM, и к 2015 году она быстро набирала популярность.

Есть и другие варианты: Depth Gated RNN (Яо и соавторы, 2015) и совсем иной подход к долгим зависимостям — Clockwork RNN (Коутник и соавторы, 2014), где части сети обновляются с разной частотой.

Какой вариант лучше? Ола ссылается на два исследования. Грефф и соавторы (2015) сравнили популярные варианты и нашли, что они «примерно одинаковы». Йозефович и соавторы (2015) перебрали больше десяти тысяч архитектур RNN и нашли отдельные, которые на некоторых задачах обходят LSTM, но не в целом.

В заключении Ола признаёт, что записанные системой уравнений LSTM выглядят устрашающе, и надеется, что пошаговый разбор сделал их доступнее. А дальше задаёт вопрос: будет ли следующий такой же большой шаг? По его словам, среди исследователей распространено мнение: «Да! Следующий шаг есть, и это внимание». Идея — позволить каждому шагу RNN выбирать, на какую часть большого набора информации смотреть. Например, подписывая картинку, сеть может для каждого слова смотреть на свою область изображения, как в работе Сюй и соавторов (2015, «Show, Attend and Tell»).

Ещё он упоминает Grid LSTM (Калхбреннер и соавторы, 2015) и генеративные модели на RNN (Грегор и соавторы — DRAW; Чанг и соавторы; Байер и Остендорфер, все 2015). Через год Ола вместе с Шаном Картером разовьёт тему внимания в статье для Distill — её полный перевод есть в разделе: «Внимание и дополненные рекуррентные сети». А то, как внимание вытеснило рекуррентность целиком, разобрано в уроках 4.4 и 3.4.3 («Почему победил трансформер»).

  1. Откройте оригинал и пройдите все рисунки серии «LSTM3-focus-…» (по одному на шаг). Для каждого рисунка проговорите вслух, какая формула ему соответствует и какой гейт подсвечен.
  2. Запустите код выше. Поставьте b["f"] = -3.0 (гейт забывания почти закрыт) и посмотрите, как быстро исчезают знаки исходной памяти в CC.
  3. Допишите в lstm_step вариант со связанными гейтами (it=1−fti_t = 1 - f_t). Сколько матриц весов стало? (Ответ: три вместо четырёх.)
  4. Посчитайте число параметров nn.LSTM(input_size=3, hidden_size=2) и nn.GRU с теми же размерами (sum(p.numel() for p in m.parameters())). Сверьте с формулой «4 или 3 набора матриц и сдвигов»; учтите, что PyTorch хранит по два вектора сдвигов на каждый гейт.
  5. В уроке 4.1.5 сравните, насколько далёкий контекст используют RNN и LSTM, — это прямое экспериментальное подтверждение раздела 2.
EnglishРусский
recurrent neural network (RNN)рекуррентная нейросеть
long-term dependenciesдолгие (дальние) зависимости
cell stateсостояние ячейки, ячейка памяти
gateгейт, вентиль
forget / input / output gateгейт забывания / входной / выходной гейт
candidate valuesкандидаты на запись
pointwise operationпоэлементная операция
peephole connectionspeephole-связи («подглядывающие» связи)
coupled gatesсвязанные гейты
gated recurrent unit (GRU)GRU, управляемый рекуррентный блок
update gate / reset gateгейт обновления / гейт сброса
vanishing gradientsзатухание градиентов
attentionвнимание