Нейросетевые модели
~20 мин

RNN и LSTM

Рекуррентные сети, затухание градиентов, LSTM/GRU, BiLSTM.

RNN и LSTM — как нейросети научились помнить

Загрузка интерактивного виджета...

Обычный MLP обрабатывает каждый вход независимо — подал картинку, получил класс. Но что, если данные — это последовательность, где порядок критичен? Предложение «кот съел рыбу» и «рыба съела кота» — одни и те же слова, но совсем разный смысл. Временной ряд цен акций — каждая точка бессмысленна без контекста предыдущих.

Рекуррентные нейросети (RNN) решают именно эту проблему: они обрабатывают данные шаг за шагом, передавая «память» о прошлом на каждый следующий шаг. Это первая архитектура, которая дала нейросетям способность работать с текстом, речью, музыкой и другими последовательностями.

Сегодня RNN уступили место трансформерам в большинстве задач NLP. Но понимание RNN и LSTM — не просто «история»: это фундамент, на котором строились seq2seq, attention, и в конечном итоге сам трансформер. А на собеседованиях RNN/LSTM спрашивают стабильно — и у джуниора, и у мидла.

Большая картина: hidden state как «память»

Аналогия: ты читаешь книгу. В каждый момент ты видишь одно слово (текущий вход x_t), но в голове держишь контекст — кто герои, что произошло, какая интрига (скрытое состояние h_t). Прочитав новое слово, ты обновляешь контекст: может, появился новый персонаж, может, сюжет повернулся. RNN работает точно так же.

Вот что происходит на каждом шаге: 1. RNN получает текущий вход x_t (например, эмбеддинг слова) 2. И предыдущее скрытое состояние h_{t−1} (память о всех прошлых шагах) 3. Из них вычисляет новое скрытое состояние h_t 4. Из h_t можно получить выход y_t (например, предсказание следующего слова) Ключевая идея: одна и та же сеть применяется на каждом шаге. Параметры (матрицы весов) — общие для всех позиций. Это называется развёртка во времени (unrolling).

Развёрнутая RNN по шагам: x1→h1, x2→h2, ..., xT→hT с весами W_hh, W_xh, W_hy
RNN «разворачивается» по времени: одна и та же ячейка применяется на каждом шаге. W_xh — веса входа, W_hh — рекуррентные веса, W_hy — веса выхода. Все W одинаковы на каждом шаге

Vanilla RNN — формула и forward pass пошагово

Vanilla RNN — простейшая рекуррентная архитектура. Обновление скрытого состояния — одна формула:

h_t — новое скрытое состояние, h_{t−1} — предыдущее, x_t — текущий вход, W_hh — рекуррентные веса, W_xh — веса входа, b_h — смещение, tanh — нелинейность (сжимает выход в [−1, +1])

Разберём по шагам: W_xh преобразует текущий вход, W_hh преобразует предыдущую память, всё складывается и проходит через tanh. Результат — новая память, которая содержит информацию и о текущем входе, и обо всём, что было раньше.

Числовой пример: forward pass

Возьмём крошечную RNN с размерностью скрытого состояния d_h = 2 и размерностью входа d_x = 2. Предложение из двух слов: x₁ = [1, 0], x₂ = [0, 1].

import numpy as np

# Параметры (инициализируем вручную для примера)
W_xh = np.array([[0.5, 0.0],
                  [0.0, 0.5]])    # 2×2: вход → скрытое
W_hh = np.array([[0.8, 0.0],
                  [0.0, 0.8]])    # 2×2: скрытое → скрытое
b_h  = np.array([0.0, 0.0])      # смещение

h_0 = np.array([0.0, 0.0])       # начальное состояние — нули

# Шаг 1: x₁ = [1, 0]
z_1 = W_hh @ h_0 + W_xh @ np.array([1, 0]) + b_h   # = [0, 0] + [0.5, 0] = [0.5, 0]
h_1 = np.tanh(z_1)                                    # = [0.462, 0.0]

# Шаг 2: x₂ = [0, 1]
z_2 = W_hh @ h_1 + W_xh @ np.array([0, 1]) + b_h   # = [0.370, 0] + [0, 0.5] = [0.370, 0.5]
h_2 = np.tanh(z_2)                                    # = [0.354, 0.462]

print(f"h_1 = {h_1}")    # [0.462, 0.0]   — запомнили первое слово
print(f"h_2 = {h_2}")    # [0.354, 0.462] — содержит инфо об обоих словах

Обрати внимание: h₂ = [0.354, 0.462] содержит «след» от первого слова (0.354 — это затухшее 0.462 через W_hh) и информацию о втором слове (0.462). Так RNN накапливает контекст. Но с каждым шагом старая информация умножается на W_hh и проходит через tanh — она экспоненциально затухает. Это и есть корень проблемы.

Проблема vanishing gradients: почему RNN забывает

Представь предложение: «Мария, которая родилась в Париже и выросла в Лондоне, говорит по-французски». Чтобы правильно предсказать «говорит» (единственное число), модель должна помнить «Мария» через 10+ слов. Vanilla RNN с этим не справляется.

Почему? При обучении (BPTT — Backpropagation Through Time) градиент проходит назад по всем шагам. На каждом шаге он умножается на матрицу W_hh и на производную tanh. Производная tanh ∈ (0, 1], и собственные значения W_hh обычно < 1. Через 50 шагов: 0.9⁵⁰ ≈ 0.005 — градиент практически исчезает.

Градиент от h_T до h_1 — произведение T−1 матриц. Если спектральная норма каждого множителя < 1, произведение → 0 (vanishing). Если > 1 — → ∞ (exploding)

Два сценария: • Vanishing gradients (затухание): модель не может обучиться длинным зависимостям. Ранние слова не получают сигнала об ошибке. Это основная проблема. • Exploding gradients (взрыв): градиенты растут экспоненциально, веса «прыгают». Решается просто — gradient clipping (обрезка нормы). А вот vanishing gradient clipping не поможет — нечего обрезать, сигнала нет. На практике vanilla RNN помнит 10-20 токенов. Для одного предложения хватает, для абзаца — уже нет.

Частый вопрос на собесе

Почему vanilla RNN не может запомнить длинные зависимости? Ответ: при BPTT градиент проходит через цепочку умножений на W_hh. Если собственные значения W_hh < 1, градиент экспоненциально затухает. Модель просто не получает обучающий сигнал от далёких позиций. LSTM решает это через cell state — «магистраль», по которой градиент течёт почти без потерь.

LSTM — долгая кратковременная память

LSTM (Long Short-Term Memory, 1997) — архитектура, созданная специально для решения проблемы vanishing gradients. Ключевая идея: добавить отдельную ячейку памяти (cell state, C_t), через которую информация может проходить без изменений на протяжении многих шагов.

Аналогия: cell state — конвейерная лента на заводе. По ней едут детали (информация), и она идёт прямо, без поворотов. Рядом стоят трое рабочих (гейты): • Forget gate (забыть): решает, какие детали убрать с ленты. «Этот факт больше не нужен — выбрасываем» • Input gate (запомнить): решает, какие новые детали положить на ленту. «Это важный факт — кладём» • Output gate (отдать): решает, какую часть содержимого ленты показать на текущем шаге. «Вот что релевантно прямо сейчас»

LSTM ячейка: потоки данных через forget gate, input gate, output gate и cell state
LSTM cell: x_t и h_{t-1} проходят через три гейта (σ) и кандидат (tanh). Cell state C_t — «конвейерная лента» памяти

Формулы LSTM: что делает каждый гейт

Все гейты имеют одинаковую структуру: линейная комбинация [h_{t−1}, x_t] → сигмоида (σ). Сигмоида выдаёт значение от 0 до 1 — это «степень открытия» гейта.

Forget gate: f_t ∈ [0, 1]. Значение 0 = «забудь всё», 1 = «помни всё». На практике обычно 0.9-0.99 — почти всё помнит

Input gate (i_t) + кандидат на запись (C̃_t). i_t решает, сколько записать, C̃_t — что именно записать

Обновление cell state: старая память × forget gate + новая информация × input gate. Символ ⊙ — поэлементное умножение

Output gate (o_t) фильтрует cell state → скрытое состояние h_t, которое идёт на выход и на следующий шаг

LSTM ячейка: forget gate, input gate, output gate и cell state
Forget gate решает, что забыть. Input gate — что запомнить. Output gate — что отдать наружу

Числовой пример: LSTM по шагам

Представим: модель читает предложение и на позиции «Мария» забирает факт «субъект = женский род». Через 10 слов встречает глагол и должна выбрать правильное окончание. Упростим до конкретных чисел (скаляры вместо векторов для ясности):

# Упрощённый пример: скалярный LSTM
import numpy as np

def sigmoid(x): return 1 / (1 + np.exp(-x))

# Состояние после прочтения "Мария":
C_prev = 0.9    # cell state хранит "субъект = жен. род" (высокое значение)
h_prev = 0.7    # скрытое состояние

# Теперь читаем 5 "неважных" слов ("которая родилась в Париже и")
# Для каждого: forget gate ≈ 0.95 (почти всё помним), input gate ≈ 0.1 (мало нового)

C = C_prev
for step in range(5):
    f_t = 0.95    # forget gate: помним почти всё
    i_t = 0.1     # input gate: мало нового
    C_cand = 0.2  # кандидат: слабый сигнал
    C = f_t * C + i_t * C_cand       # обновляем cell state
    print(f"Шаг {step+1}: C = {C:.3f}")

# Шаг 1: C = 0.875
# Шаг 2: C = 0.851
# Шаг 3: C = 0.829
# Шаг 4: C = 0.807
# Шаг 5: C = 0.787

# Через 5 шагов C = 0.787 — всё ещё высокое! (было 0.9)
# В vanilla RNN: h * 0.8^5 = 0.9 * 0.328 = 0.295 — почти потеряно

# Теперь встречаем глагол — output gate открывается:
o_t = 0.9  # output gate: "сейчас это важно!"
h_t = o_t * np.tanh(C)   # h_t = 0.9 * tanh(0.787) = 0.9 * 0.655 = 0.590
print(f"\nНа глаголе: h_t = {h_t:.3f} — информация о 'Мария' сохранилась!")

Ключевой момент: cell state сохранил 87% информации (0.787 / 0.9) через 5 шагов, тогда как vanilla RNN потерял бы ~67%. На 50 шагах разница драматическая: LSTM с f_t = 0.95 сохранит 0.95⁵⁰ ≈ 7.7% (достаточно для сигнала), а RNN с множителем 0.8 — 0.8⁵⁰ ≈ 0.001% (полная потеря).

Почему LSTM решает vanishing gradient?

В формуле C_t = f_t ⊙ C_{t-1} + i_t ⊙ C̃_t градиент ∂C_t/∂C_{t-1} = f_t. Если forget gate близок к 1 (что обычно так для важной информации), градиент проходит почти без потерь через любое количество шагов. Это «gradient highway» — прямая магистраль для градиентов, аналогичная skip-connections в ResNet.

GRU — упрощённый LSTM

GRU (Gated Recurrent Unit, 2014) — упрощённая версия LSTM. Основные отличия: • 2 гейта вместо 3: update gate (z_t) объединяет роли forget и input, reset gate (r_t) контролирует, сколько прошлой памяти использовать при вычислении кандидата • Нет отдельного cell state — только скрытое состояние h_t • Меньше параметров — быстрее обучается, меньше переобучается на малых данных

z_t — update gate (что оставить от старого vs взять от нового), r_t — reset gate (сколько прошлого использовать для кандидата)

Скрытое состояние — взвешенная смесь: (1−z_t) × старое + z_t × новое. Если z_t ≈ 0, информация проходит без изменений

LSTM vs GRU — когда что выбирать? • Данных много + последовательности длинные → LSTM (отдельный cell state лучше хранит долгосрочную память) • Данных мало + нужна скорость → GRU (меньше параметров, быстрее обучается) • На практике разница в качестве часто незначительна. Cho et al. (2014) и множество сравнений показали: ни одна архитектура не доминирует всегда. Дефолтный выбор для новой задачи — GRU, если нет причин для LSTM.

Bidirectional RNN — читаем в обе стороны

Обычная RNN читает слева направо: для слова на позиции t она знает только контекст слева [x₁, …, x_t]. Но часто контекст справа не менее важен.

Пример: «Я пошёл в банк, чтобы снять деньги» vs «Я сел на банк у реки». Значение слова «банк» определяется тем, что идёт после него. Однонаправленная RNN, дойдя до «банк», ещё не видела «деньги» или «реки» — и не может разрешить неоднозначность.

BiLSTM (Bidirectional LSTM) запускает два LSTM параллельно: один читает слева направо (→), другой — справа налево (←). На каждой позиции их скрытые состояния конкатенируются: h_t = [h_t→ ; h_t←]. Теперь каждый токен имеет полный контекст предложения.

BiLSTM: два LSTM — прямой и обратный — объединяют скрытые состояния
BiLSTM: для каждого токена доступен полный контекст предложения. Размерность выхода — 2 × d_hidden

⚠️ Важное ограничение: BiLSTM нельзя использовать для генерации текста (авторегрессионные задачи). Если модель генерирует слово за словом, она не может «подсмотреть» в будущее — будущее ещё не создано. BiLSTM — для задач понимания: NER, PoS-tagging, классификация предложений, экстракция отношений.

Почему трансформер победил RNN

К 2018-2019 годам трансформеры практически вытеснили RNN из NLP. Две главные причины:

  • Параллелизм. RNN обрабатывает токены строго последовательно: нельзя вычислить h_5, пока не готов h_4. Трансформер обрабатывает все токены одновременно — self-attention параллелится идеально. На GPU с тысячами ядер это разница в порядки: обучение GPT-3 на RNN заняло бы годы вместо месяцев.
  • Длинные зависимости. В RNN сигнал от первого слова до сотого проходит 99 шагов, затухая на каждом. В трансформере — один шаг attention, прямое соединение между любыми двумя токенами. Информация не теряется, даже если зависимость длиной в тысячи токенов.

Есть и третья причина, менее очевидная: масштабируемость. Трансформеры идеально ложатся на кластеры GPU, и scaling laws показали: чем больше модель и данных — тем лучше. RNN масштабировать значительно сложнее из-за последовательной природы.

Где RNN ещё живы

Несмотря на доминирование трансформеров, RNN не исчезли полностью. Есть ниши, где их свойства — преимущество:

  • Edge-устройства (телефоны, IoT). LSTM с 256 скрытых размерностей — ~500K параметров, помещается в 2 МБ. Для клавиатурного предиктора или детектора wake word ("Окей, Гугл") это идеально. Трансформер — избыточен.
  • Стриминговые задачи. ASR (распознавание речи) в реальном времени: аудио приходит чанками, нужно выдавать транскрипцию с минимальной задержкой. RNN обрабатывает чанк и обновляет состояние — O(1) на токен. Трансформер пересчитывает attention по всей истории — O(n) или нужен KV-cache.
  • Управление роботами. Принятие решений в реальном времени: простая RNN с частотой 100+ Hz на микроконтроллере. Трансформер не влезает.
  • Гибридные модели. xLSTM (2024), Mamba (selective state spaces) — переосмысление идей RNN с линейной сложностью и конкурентным качеством для длинных контекстов.

Практический пример: LSTM на PyTorch

import torch
import torch.nn as nn

# Двуслойный BiLSTM для классификации текста
class TextClassifier(nn.Module):
    def __init__(self, vocab_size, emb_dim=128, hidden_dim=64, num_classes=3):
        super().__init__()
        self.emb = nn.Embedding(vocab_size, emb_dim)
        self.lstm = nn.LSTM(
            input_size=emb_dim,
            hidden_size=hidden_dim,
            num_layers=2,
            bidirectional=True,     # BiLSTM
            batch_first=True,
            dropout=0.3,            # dropout между слоями
        )
        # hidden_dim * 2 из-за bidirectional
        self.fc = nn.Linear(hidden_dim * 2, num_classes)

    def forward(self, x):
        # x: [batch, seq_len] — id токенов
        emb = self.emb(x)                  # [batch, seq_len, emb_dim]
        output, (h_n, c_n) = self.lstm(emb)  # output: [batch, seq_len, hidden*2]

        # Берём последние hidden states обоих направлений
        # h_n: [num_layers*2, batch, hidden]
        h_forward = h_n[-2]   # последний слой, прямое направление
        h_backward = h_n[-1]  # последний слой, обратное направление
        h_cat = torch.cat([h_forward, h_backward], dim=-1)  # [batch, hidden*2]

        return self.fc(h_cat)  # [batch, num_classes]

# Пример использования
model = TextClassifier(vocab_size=10000)
tokens = torch.randint(0, 10000, (4, 50))  # батч из 4 текстов по 50 токенов
logits = model(tokens)
print(logits.shape)  # torch.Size([4, 3])

🎯 На собеседовании

Junior

Зачем нужна RNN, если есть MLP? MLP не умеет работать с последовательностями — обрабатывает каждый вход независимо. RNN передаёт скрытое состояние (память) с шага на шаг. • Что такое vanishing gradient в RNN? Градиент при обратном проходе проходит через цепочку умножений и экспоненциально затухает. Модель не может обучиться зависимостям длиннее 10-20 шагов. • Зачем LSTM, если есть vanilla RNN? LSTM добавляет cell state + 3 гейта. Cell state — «конвейерная лента», по которой информация проходит без затухания. Forget gate ≈ 1 → градиент сохраняется.

Middle

Объясни 3 гейта LSTM. Forget (f_t): что забыть из старой памяти. Input (i_t): что записать нового. Output (o_t): что отдать как выход. Все три — σ([h_{t-1}, x_t]), выход ∈ [0,1]. Cell state: C_t = f_t ⊙ C_{t-1} + i_t ⊙ C̃_t. • LSTM vs GRU? GRU: 2 гейта (update + reset), нет cell state, меньше параметров, быстрее. Качество сравнимо. GRU — дефолт, LSTM — если длинные последовательности и много данных. • Почему BiLSTM нельзя для генерации? BiLSTM видит будущее (обратное направление). При генерации будущее ещё не существует. BiLSTM — для задач понимания (NER, classification). • Как gradient clipping помогает RNN? Решает только exploding gradients: обрезает норму градиента. Vanishing gradients НЕ решает — нужен LSTM/GRU.

Senior

Почему трансформер победил LSTM? (1) Параллелизм — RNN строго последовательна, трансформер обрабатывает все токены одновременно. (2) Прямой доступ — attention связывает любые два токена за 1 шаг. (3) Масштабируемость — scaling laws работают на трансформерах. • Где RNN лучше трансформера? Edge (мало памяти), стриминг (O(1) на токен vs пересчёт attention), real-time системы. xLSTM (2024) конкурирует с трансформерами на длинных контекстах. • Как инициализировать forget gate bias? bias_f = 1.0-2.0 (Jozefowicz et al., 2015). Без этого forget gate ≈ 0.5 после случайной инициализации → модель «забывает» с самого начала, обучение деградирует. • BPTT vs truncated BPTT? Полный BPTT — через всю последовательность (O(T) памяти). Truncated — градиент обрезают через k шагов. Экономит память, но теряет зависимости длиннее k.

Собираем всё вместе

RNN — первая архитектура, которая дала нейросетям память. Vanilla RNN передаёт скрытое состояние с шага на шаг, но из-за vanishing gradients забывает длинные зависимости. LSTM решает это с помощью cell state (конвейерная лента для долгосрочной памяти) и трёх гейтов (forget, input, output), которые контролируют поток информации. GRU — упрощённая версия с двумя гейтами и сравнимым качеством. BiLSTM добавляет контекст справа, но только для задач понимания.

Если запомнить одну вещь из этой ноды: LSTM хранит долгосрочную память в cell state, а гейты решают, что забыть, что записать и что показать. Именно поэтому LSTM может обучиться зависимостям на сотнях шагов, где vanilla RNN бессильна.

Дальше на роадмапе: Seq2Seq и Attention — как LSTM стали основой для машинного перевода и как attention механизм позволил решить проблему «бутылочного горлышка» энкодера. А затем Transformer — архитектура, которая заменила RNN, взяв лучшее от attention и выбросив рекуррентность.