🔴 Сложный ⏱️ 55 минут

Рекуррентные нейронные сети

📋 Содержание урока

Рекуррентные нейронные сети 🔁

Уроки 335–338 были целиком посвящены одному типу данных — изображениям, и одному типу архитектуры — свёрточным сетям. У этого выбора была причина: изображение — это данные фиксированного размера ($H\times W\times C$), где важна локальная пространственная структура, и свёртка идеально подходит именно под эту структуру. Но большая часть данных, с которыми реально сталкивается специалист по Data Science, устроена совершенно иначе. Предложение на естественном языке может состоять из трёх слов, а может — из тридцати. Аудиозапись длится секунду или час. Временной ряд котировок акции — это поток чисел, который растёт с каждым новым торговым днём и в принципе не имеет заранее известной длины. Ни полносвязная сеть, ни свёрточная сеть не рассчитаны на вход, чей размер неизвестен заранее, — обе архитектуры жёстко фиксируют число входных элементов на этапе проектирования.

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

Рекуррентная нейронная сеть (recurrent neural network, RNN) решает обе проблемы одной и той же идеей — идеей скрытого состояния. Вместо того чтобы разом посмотреть на всю последовательность целиком, RNN обрабатывает её по одному элементу за раз: слово за словом, символ за символом, отсчёт временного ряда за отсчётом. На каждом шаге сеть держит в памяти вектор — скрытое состояние — который суммирует всё, что она «поняла» из уже увиденной части последовательности, и обновляет этот вектор с приходом каждого нового элемента. Веса, которые выполняют это обновление, одни и те же на каждом шаге, — значит, сеть не привязана к конкретной длине входа: она просто повторяет один и тот же шаг столько раз, сколько элементов в последовательности, будь их три или триста.

План сегодняшнего урока такой. Сначала разберём подробно, почему обычная полносвязная или свёрточная сеть плохо справляется с последовательностями переменной длины — не абстрактно, а на конкретных числах. Затем построим саму идею скрытого состояния и рекуррентности с нуля и разберём полный численный прямой проход (forward pass) через RNN на трёх временных шагах. Дальше посмотрим на приём, который называется развёрткой во времени (unrolling), — он превращает RNN в очень глубокую сеть с общими весами на каждом шаге, — и увидим, что обучение такой развёрнутой сети — это прямое расширение backpropagation из урока 330, получившее собственное имя: backpropagation through time (BPTT, обратное распространение ошибки через время). А в конце — разберём проблему, из-за которой обычная («ванильная») RNN сегодня используется на практике довольно редко: проблему затухающего и взрывающегося градиента, которая в RNN проявляется куда острее, чем в обычных глубоких сетях, и подготовит почву для архитектур LSTM и GRU в следующем уроке.

История

Идея сети с обратными связями — то есть с соединениями, по которым сигнал может течь не только «вперёд», от входа к выходу, но и возвращаться назад, влияя на последующие вычисления, — восходит к сети Хопфилда (John Hopfield, 1982 год). Сеть Хопфилда была не совсем тем, что сегодня называют RNN: это была сеть с полностью связанными симметричными весами, которая «релаксировала» к устойчивому состоянию и использовалась в основном как ассоциативная память, а не как инструмент для обработки последовательностей во времени. Тем не менее именно она впервые показала, что архитектура, где сигнал циркулирует по сети, а не проходит через неё за один проход, может быть математически строго описана и обучена.

Архитектуры, куда более похожие на современную RNN, появились в конце 1980-х годов. Майкл Джордан (Michael I. Jordan, 1986 год) предложил сеть, в которой выход сети на предыдущем шаге подавался обратно на вход вместе со следующим элементом последовательности, — простой, но рабочий способ дать сети «память» о недавнем прошлом. Джеффри Элман (Jeffrey Elman, 1990 год) пошёл дальше: в его архитектуре, которую сегодня называют Elman network («сеть Элмана»), обратно на вход подавалось не выходное значение, а именно скрытое состояние — внутреннее представление сети, а не её финальное предсказание. Это различие оказалось принципиальным: скрытое состояние несёт гораздо больше информации, чем один выходной сигнал, и именно конструкция Элмана — вход плюс предыдущее скрытое состояние, порождающие новое скрытое состояние, — стала прямым предком формулы $h_t=f(Wh_{t-1}+Ux_t+b)$, с которой ты будешь работать весь этот урок. В своей знаменитой статье Элман обучил такую сеть предсказывать следующее слово в простых искусственных предложениях и показал, что сеть неявно выучивает нечто похожее на понятие грамматической структуры — не получая никаких явных правил, только последовательность слов одно за другим.

Математический аппарат для обучения таких сетей — то есть способ распространить ошибку не только «вверх» по слоям, как в обычном backpropagation, но и «назад во времени» через развёрнутую последовательность шагов, — формализовал Пол Вербос (Paul Werbos) в конце 1980-х – начале 1990-х годов и дал ему имя, которое используется до сих пор: backpropagation through time. По сути, это было прямое, но не совсем тривиальное расширение алгоритма backpropagation (урок 330, статья Румельхарта, Хинтона и Уильямса 1986 года) на сети с рекуррентными связями. Уже тогда исследователи (в частности, Сепп Хохрайтер в своей дипломной работе 1991 года) обнаружили и описали проблему, из-за которой обычная RNN плохо обучается на длинных последовательностях, — ту самую проблему затухающего градиента, разбору которой посвящена отдельная секция этого урока и которая через несколько лет привела к изобретению LSTM (1997 год, тема следующего урока). Несмотря на все ограничения, простая RNN остаётся фундаментом: понимание того, как она работает и почему именно она перестаёт справляться с длинными зависимостями, — необходимая база для понимания абсолютно всех архитектур для последовательностей, которые появились позже, вплоть до механизма внимания трансформеров.

Проблема последовательностей переменной длины: почему обычная сеть не подходит

Интуиция

Представь, что тебе нужно построить сеть для классификации тональности отзыва — положительный он или отрицательный. Полносвязная сеть требует фиксированного числа входных нейронов, заданного один раз при проектировании архитектуры. Значит, нужно заранее решить: сколько слов из отзыва подавать на вход? Если выбрать, скажем, 20 слов, то отзыв из 5 слов придётся дополнять «пустыми» словами (паддингом) — сеть тратит вычисления на заведомо бесполезные позиции. А отзыв из 50 слов придётся обрубить — сеть просто не увидит вторую половину текста, какой бы важной информации там ни было. Хуже того: слово «отлично» в первой позиции отзыва и то же слово «отлично» в пятнадцатой позиции для полносвязной сети — это два совершенно разных, никак не связанных друг с другом входа, потому что у каждой входной позиции свой отдельный набор весов. Сети приходится заново, с нуля, выучивать, что значит слово «отлично», для каждой возможной позиции в предложении — вместо того чтобы выучить это один раз и применять к любому месту в тексте.

Свёрточная сеть частично решает вторую из этих проблем: один и тот же набор весов фильтра применяется ко всем позициям входа (тот же принцип разделения весов, weight sharing, что ты уже видел в CNN, уроки 335–338), поэтому «отлично» распознаётся одинаково независимо от позиции в предложении. Но первая проблема — фиксированная длина входа — свёрточной сетью решается лишь частично. Свёртка с фиксированным размером ядра (например, 5 слов) захватывает лишь локальный контекст в пределах этого окна; чтобы уловить зависимость между словами, находящимися далеко друг от друга в длинном предложении, нужно либо очень много свёрточных слоёв подряд (наращивая рецептивное поле, как в уроке 336), либо какой-то принципиально другой механизм. А главное — свёрточная сеть тоже физически ограничена: она не может обработать вход произвольной, заранее неизвестной длины одним и тем же набором весов так, чтобы теоретически учесть зависимость между самым первым и самым последним элементом последовательности, какой бы длинной она ни была.

Формула

Проблема фиксированного входа. Полносвязный слой вычисляет $a = f(Wx+b)$, где $x\in\mathbb{R}^n$ — вектор фиксированной размерности $n$, а матрица весов $W$ имеет размер (число выходов) $\times\, n$. Размерность $n$ фиксируется один раз при определении архитектуры и не может меняться от примера к примеру. Для последовательности длины $T$ (переменной от примера к примеру) единственный способ подать её на вход такому слою — зафиксировать некоторое $T_{\max}$ и применять паддинг (для коротких последовательностей) или обрезание (для длинных), теряя информацию в обоих случаях.

Разбор примеров

Пример 1 (численная экономия весов при переходе от «окна» фиксированной длины к рекуррентной обработке). Пусть слова кодируются векторами (эмбеддингами) размерности $50$, и мы хотим обработать окно из $10$ слов, отобразив его в скрытое представление размерности $128$. Полносвязный слой, принимающий на вход все $10$ слов сразу как один плоский вектор размерности $10\times50=500$, потребует $500\times128=64\,000$ весов только в первом слое (без учёта смещений). Рекуррентная сеть с той же входной и скрытой размерностью использует одну-единственную матрицу «вход-в-скрытое» $W_{xh}$ размера $50\times128=6\,400$ весов, применяемую на каждом шаге, — в $64\,000/6\,400=10$ раз меньше. Но разница не только в экономии: полносвязная версия жёстко привязана к окну ровно в $10$ слов и физически не сможет обработать предложение из $11$ слов без изменения архитектуры, тогда как рекуррентная сеть с теми же $6\,400$ весами обработает последовательность любой длины — $3$ слова, $10$ слов или $300$ слов, — просто применяя одну и ту же матрицу нужное число раз подряд.

Пример 2 (почему позиция слова «путает» полносвязную сеть — конкретный числовой эффект). Пусть слово «отлично» кодируется вектором $e=(1{,}0,\ 0{,}5)$ (упрощённый эмбеддинг размерности $2$ для наглядности). В полносвязном слое, обрабатывающем окно из трёх слов $x=(x_1,x_2,x_3)$ (каждое размерности $2$, итого вход размерности $6$), матрица весов $W$ имеет форму (выход)$\times6$, и первые два столбца $W$ отвечают за позицию $x_1$, следующие два — за позицию $x_2$, последние два — за позицию $x_3$. Если слово «отлично» окажется в позиции $x_1$, сработают веса-столбцы $1$–$2$; если то же самое слово окажется в позиции $x_3$, сработают уже столбцы $5$–$6$ — совершенно другие числа, обучаемые независимо. Обучающий сигнал, который «научил» сеть правильно реагировать на «отлично» в позиции $1$, вообще никак не передаётся весам, отвечающим за позицию $3$, — сети приходится выучивать один и тот же факт («это слово положительное») заново для каждой позиции, на которой оно может встретиться, что при ограниченном числе обучающих примеров попросту не успевает произойти для редких комбинаций «слово + позиция».

Пример 3 (временной ряд — тот же эффект вне текста). Пусть нужно предсказать следующее значение временного ряда (например, дневную температуру) по последним $30$ дням наблюдений. Полносвязная сеть с окном в $30$ значений требует ровно $30$ входных чисел на каждом обучающем примере — если реальный ряд наблюдений короче (например, только $10$ дней данных с начала записи метеостанции), окно физически нечем заполнить без паддинга нулями или средним значением, что вносит искажение в самые первые предсказания. А если понадобится учесть более длинную историю — скажем, $60$ дней вместо $30$, потому что обнаружилась сезонность с таким периодом, — архитектуру придётся менять и переобучать с нуля, поскольку сама форма входного слоя жёстко связана с числом $30$. Рекуррентная сеть таких ограничений не имеет: длина «истории», которую она обработает перед тем, как сделать предсказание, определяется не архитектурой, а просто числом раз, которое применяется один и тот же шаг обновления скрытого состояния.

Почему это важно

Понимание того, что именно ломается у полносвязных и свёрточных сетей на последовательностях переменной длины, — не абстрактная теория, а прямое объяснение, зачем вообще нужна новая архитектура, а не очередная модификация уже знакомой. Дело не в том, что CNN «недостаточно мощная» — она отлично справляется с изображениями фиксированного размера именно потому, что пространственная структура изображения этого и требует. Проблема в несовпадении самой формы данных (последовательность произвольной длины, где важен порядок) с формой, которую ожидают полносвязный и свёрточный слои (вектор или тензор фиксированного размера). RNN устраняет это несовпадение не за счёт более мощных весов, а за счёт другого способа применения одних и тех же весов — многократно, шаг за шагом, — и именно этот механизм разбирается в следующем разделе.

Скрытое состояние и идея рекуррентности

Интуиция

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

Ключевая деталь, ради которой всё это работает: функция, которая выполняет это обновление — набор весов $W_{hh}$, $W_{xh}$, $b_h$ и функция активации — одна и та же на каждом шаге, независимо от того, какой это шаг по счёту и сколько всего шагов в последовательности. Сеть не «знает» заранее, сколько раз ей придётся выполнить этот шаг, — она просто выполняет его столько раз, сколько элементов оказалось на входе, каждый раз обновляя одно и то же скрытое состояние. Именно поэтому RNN не привязана к фиксированной длине: длина последовательности превращается из архитектурного параметра (как у полносвязной сети) в переменную времени исполнения — просто число повторений одного и того же шага.

Формула

Обновление скрытого состояния RNN. Пусть $x_t$ — элемент последовательности на шаге $t$ (например, вектор-эмбеддинг слова или отсчёт временного ряда), $h_{t-1}$ — скрытое состояние, накопленное к предыдущему шагу, $h_0$ — начальное скрытое состояние (обычно нулевой вектор). Новое скрытое состояние вычисляется как

$$z_t = W_{hh}\,h_{t-1} + W_{xh}\,x_t + b_h, \qquad h_t = f(z_t)$$

где $f$ — функция активации (чаще всего $\tanh$), а $W_{hh}$, $W_{xh}$, $b_h$ — одни и те же обучаемые параметры на каждом шаге $t=1,\dots,T$. В общепринятых обозначениях (в том числе в оригинальной литературе) эту же формулу часто записывают как $h_t=f(Wh_{t-1}+Ux_t+b)$, где $W\equiv W_{hh}$ и $U\equiv W_{xh}$. Если на шаге $t$ нужен и выход сети, он вычисляется отдельным слоем поверх скрытого состояния: $y_t = W_{hy}\,h_t + b_y$.

Разбор примеров

Пример 1 (полный численный forward pass через RNN на трёх временных шагах). Возьмём простейшую скалярную RNN — одно число в качестве скрытого состояния и одно число в качестве входа на каждом шаге (в реальных задачах и $h_t$, и $x_t$ — векторы, но скалярный случай позволяет проследить вычисления в подробностях, не теряясь в матрицах). Веса: $W_{hh}=0{,}5$, $W_{xh}=0{,}8$, $b_h=0{,}1$; выходной слой $W_{hy}=1{,}0$, $b_y=0$. Начальное скрытое состояние $h_0=0$. Входная последовательность — например, три нормализованных отсчёта временного ряда: $x_1=1{,}0$, $x_2=0{,}5$, $x_3=-0{,}3$.

Шаг 1. $z_1=W_{hh}\,h_0+W_{xh}\,x_1+b_h=0{,}5\cdot0+0{,}8\cdot1{,}0+0{,}1=0{,}9$. После активации $\tanh$: $h_1=\tanh(0{,}9)\approx0{,}7163$. Выход шага: $y_1=W_{hy}\,h_1+b_y=1{,}0\cdot0{,}7163=0{,}7163$.

Шаг 2. $z_2=W_{hh}\,h_1+W_{xh}\,x_2+b_h=0{,}5\cdot0{,}7163+0{,}8\cdot0{,}5+0{,}1=0{,}35815+0{,}4+0{,}1=0{,}85815$. После активации: $h_2=\tanh(0{,}85815)\approx0{,}6954$. Выход: $y_2\approx0{,}6954$.

Шаг 3. $z_3=W_{hh}\,h_2+W_{xh}\,x_3+b_h=0{,}5\cdot0{,}6954+0{,}8\cdot(-0{,}3)+0{,}1=0{,}3477-0{,}24+0{,}1=0{,}2077$. После активации: $h_3=\tanh(0{,}2077)\approx0{,}2048$. Выход: $y_3\approx0{,}2048$.

Обрати внимание: на всех трёх шагах использовались ровно одни и те же три числа $W_{hh}$, $W_{xh}$, $b_h$ — сеть не завела отдельных весов для шага 2 или шага 3, она просто применила один и тот же пересчёт трижды подряд, каждый раз отталкиваясь от скрытого состояния, накопленного на предыдущем шаге.

Пример 2 (скрытое состояние как накопитель — алгебраическое разворачивание зависимости). Развернём $h_2$ через $h_1$, а $h_1$ — через $h_0$, чтобы явно увидеть, что $h_2$ зависит от обоих входов $x_1$ и $x_2$, а не только от последнего: $h_2=\tanh\bigl(W_{hh}\,\tanh(W_{hh}\,h_0+W_{xh}\,x_1+b_h)+W_{xh}\,x_2+b_h\bigr)$. Даже не подставляя конкретные числа, видно структуру: $x_1$ влияет на $h_2$ не напрямую, а опосредованно — через $h_1$, пройдя через одну лишнюю нелинейность $\tanh$ и один лишний множитель $W_{hh}$ по сравнению с влиянием $x_2$. Это ключевое наблюдение объясняет сразу две вещи: почему RNN в принципе способна учитывать сколь угодно давние элементы последовательности (алгебраически $x_1$ входит в формулу для $h_T$ при любом $T\ge1$), и почему это влияние неизбежно «ослабляется» с каждым дополнительным шагом — к этому эффекту мы вернёмся отдельно в разделе про затухающий градиент.

Пример 3 (величина $W_{hh}$ как «настройка длины памяти» — сравнение двух режимов). Рассмотрим два крайних случая одной и той же формулы. Если $W_{hh}=0$, формула сводится к $h_t=\tanh(W_{xh}\,x_t+b_h)$ — скрытое состояние вообще не зависит от $h_{t-1}$, а значит, и от всех более ранних входов: сеть в каждый момент «видит» только текущий элемент, у неё нет памяти вовсе, и она в этом смысле эквивалентна обычному полносвязному слою, применённому независимо к каждой позиции. Теперь возьмём $W_{hh}$, близкое к $1$ (скажем, $W_{hh}=0{,}95$), и малую $|W_{xh}\,x_t|$: тогда $h_t\approx\tanh(h_{t-1}+\text{малая добавка})$ — новое состояние почти повторяет предыдущее, лишь слегка корректируясь, во многом похоже на экспоненциальное скользящее среднее, которое медленно «забывает» старые значения. Величина $W_{hh}$ — это, грубо говоря, «ручка настройки» того, насколько сильно прошлое влияет на настоящее: чем она ближе к $0$, тем короче эффективная память сети, чем ближе к $1$ (но не больше), тем длиннее.

Почему это важно

Формула $h_t=f(Wh_{t-1}+Ux_t+b)$ — это, по сути, вся архитектура RNN в одной строке: единственное, что меняется от шага к шагу, — это значения $h_{t-1}$ и $x_t$, тогда как сами веса $W$, $U$, $b$ остаются неизменными. Именно это и делает RNN сетью для последовательностей произвольной длины: длина последовательности превращается в число повторений одной и той же операции, а не в размер архитектуры. Пример 3 уже намекает на цену этой гибкости — способность «помнить» далёкое прошлое определяется тем, насколько сильно $W_{hh}$ усиливает или ослабляет сигнал на каждом шаге, а эта же самая величина будет играть решающую роль в разделе про затухающий и взрывающийся градиент дальше в этом уроке.

Развёртка во времени: RNN как очень глубокая сеть с общими весами

Интуиция

Формула $h_t=f(Wh_{t-1}+Ux_t+b)$ описывает один шаг, но чтобы обучить сеть, нужно представить себе всю последовательность вычислений целиком — от $h_0$ до $h_T$ — как единый граф. Если явно выписать все шаги подряд: $h_1$ вычисляется из $h_0$ и $x_1$, $h_2$ — из $h_1$ и $x_2$, и так далее вплоть до $h_T$, — получается ровно такая же структура, как обычная глубокая сеть из урока 330: цепочка вычислений, где выход одного «слоя» становится входом следующего. Разница лишь в одной, но принципиальной детали: в обычной глубокой сети у каждого слоя свои собственные, независимые веса, а здесь каждый «слой» (то есть каждый временной шаг) использует буквально те же самые числа $W$, $U$, $b$, что и все остальные. Эта процедура — явное представление RNN в виде такой цепочки — называется развёрткой во времени (unrolling, или unfolding): RNN длины $T$ развёртывается в сеть глубины $T$ с общими (tied) весами на каждом уровне.

Формула

Развёрнутая (unrolled) во времени RNN. Для входной последовательности длины $T$ развёрнутый граф вычислений — это цепочка

$$h_0 \;\xrightarrow{W,U,b}\; h_1 \;\xrightarrow{W,U,b}\; h_2 \;\xrightarrow{W,U,b}\; \cdots \;\xrightarrow{W,U,b}\; h_T$$

где над каждой стрелкой стоит применение одной и той же функции $h_t=f(Wh_{t-1}+Ux_t+b)$ с одними и теми же значениями $W$, $U$, $b$. По структуре это неотличимо от обычной сети глубины $T$ (как в уроке 330), но с дополнительным ограничением: веса всех $T$ «слоёв» жёстко связаны (tied weights) — это буквально одни и те же переменные в памяти, а не $T$ независимых копий.

Разбор примеров

Пример 1 (число параметров не растёт с длиной последовательности — прямое следствие общих весов). Пусть скрытая размерность $h=128$, входная размерность $x=50$. Число параметров RNN — это $W_{hh}$ ($128\times128=16\,384$), $W_{xh}$ ($50\times128=6\,400$) и $b_h$ ($128$), итого $16\,384+6\,400+128=22\,912$ параметров. Это число — константа, совершенно не зависящая от того, обрабатывает ли сеть последовательность длины $5$ или длины $5\,000$: развёртка просто применяет эти же $22\,912$ чисел нужное число раз. Для сравнения — гипотетическая «неразделяющая веса» версия, где на каждом из $T=20$ шагов стоят независимые, необученные совместно веса, потребовала бы $20\times22\,912=458\,240$ параметров — примерно в $20$ раз больше, — и притом такая архитектура намертво привязана к длине ровно $20$: подай ей последовательность из $21$ элемента, и попросту нечем будет обработать двадцать первый шаг.

Пример 2 (развёрнутый граф — конкретно та цепочка, что уже была вычислена). Три шага из численного примера предыдущего раздела ($h_0=0\to h_1\approx0{,}7163\to h_2\approx0{,}6954\to h_3\approx0{,}2048$) — это в точности развёрнутый граф RNN глубины $3$. На каждой из трёх стрелок применялись одинаковые $W_{hh}=0{,}5$, $W_{xh}=0{,}8$, $b_h=0{,}1$ — единственное, что менялось от шага к шагу, это конкретные входные значения $x_t$ и накопленное скрытое состояние $h_{t-1}$. Если бы вместо RNN была обычная глубокая сеть из трёх слоёв с независимыми весами, потребовалось бы отдельно задать и впоследствии отдельно обучить три разных набора чисел вместо одного используемого трижды.

Пример 3 (почему развёртка — не просто удобная картинка, а буквальный граф вычислений для backward pass). Развёрнутый граф — это ровно тот вычислительный граф, о котором шла речь в контексте топологической сортировки в уроке 330: узлы — это промежуточные значения ($z_1, h_1, z_2, h_2, \dots$), рёбра — зависимости между ними. Чтобы посчитать градиент любого веса, backward pass обязан пройти по этому графу в обратном порядке — от $h_T$ к $h_0$, — точно так же, как обычный backpropagation идёт от последнего слоя к первому в обычной глубокой сети. Единственное отличие в том, что узлы $W_{hh}$, $W_{xh}$, $b_h$ в этом графе — это не отдельные узлы для каждого шага, а один и тот же узел, к которому от каждого шага $t=1,\dots,T$ идёт отдельное ребро использования, — а значит, по правилу суммирования вкладов по нескольким путям (то самое правило из урока 330, разобранное там на примере ветвящегося графа), градиент по $W_{hh}$ должен собрать и сложить вклады со всех $T$ шагов сразу. Именно эта деталь и превращает обычный backpropagation в отдельный, специально названный алгоритм — backpropagation through time, разбору которого посвящён следующий раздел.

Почему это важно

Развёртка во времени — это не вычислительный трюк, придуманный отдельно от математики RNN, а единственный способ вообще увидеть, что происходит с сетью на протяжении всей последовательности, и подготовить почву для вычисления градиентов. Она показывает главное: с точки зрения вычислительного графа рекуррентная сеть — это очень глубокая сеть (глубиной, равной длине последовательности), а значит, ей потенциально грозят все те же проблемы, что и любой глубокой сети, — включая затухающий градиент из урока 330. Но, как ты увидишь в последнем содержательном разделе этого урока, в RNN эта проблема стоит гораздо острее ровно из-за той особенности, которую подчеркнул пример 3: одни и те же веса используются на каждом шаге, поэтому при обратном проходе градиент умножается не на разные числа, а буквально на одну и ту же матрицу снова и снова.

Обучение через backpropagation through time (BPTT)

Интуиция

Backpropagation through time — это не новый алгоритм, придуманный специально для RNN, а backpropagation из урока 330, применённый к развёрнутому графу вычислений, разобранному в предыдущем разделе. Всё, что ты уже знаешь про backward pass — движение от выхода к входу, вычисление $\delta$-сигналов ошибки на каждом узле, использование цепного правила, — работает без изменений. Добавляется ровно одна новая деталь, и она прямо следует из того, что веса $W_{hh}$, $W_{xh}$, $b_h$ используются на каждом шаге: раз один и тот же вес влияет на итоговую ошибку через $T$ разных путей (через шаг $1$, через шаг $2$, ..., через шаг $T$), правило суммирования вкладов по нескольким путям цепного правила (урок 330, пример с ветвящимся графом) требует сложить вклады со всех этих путей, чтобы получить итоговый градиент.

Формула

Backpropagation through time. Обозначим $\delta_t=\dfrac{\partial L}{\partial h_t}$ — полный градиент функции потерь по скрытому состоянию шага $t$ (аналог $\delta^{(l)}$ из обычного backward pass, урок 330). Если функция потерь считается на каждом шаге отдельно (локальный вклад $g_t=\dfrac{\partial L_t}{\partial h_t}$, как в языковом моделировании, где предсказание делается на каждом шаге), то, двигаясь от последнего шага $T$ к первому:

$$\delta_T = g_T, \qquad \delta_t = g_t + W_{hh}^\top\bigl(\delta_{t+1}\odot f'(z_{t+1})\bigr), \quad t=T-1,\dots,1$$

Градиент по общим весам — сумма вкладов со всех временных шагов, где эти веса использовались:

$$\frac{\partial L}{\partial W_{hh}} = \sum_{t=1}^{T} \bigl(\delta_t\odot f'(z_t)\bigr)\,h_{t-1}^\top, \qquad \frac{\partial L}{\partial W_{xh}} = \sum_{t=1}^{T} \bigl(\delta_t\odot f'(z_t)\bigr)\,x_t^\top$$

Разбор примеров

Пример 1 (полный численный BPTT для сети из предыдущего раздела, потеря только на последнем шаге). Продолжим ровно тот пример: $h_0=0$, $h_1\approx0{,}7163$, $h_2\approx0{,}6954$, $h_3\approx0{,}2048$, веса $W_{hh}=0{,}5$, $W_{xh}=0{,}8$, $W_{hy}=1{,}0$. Пусть функция потерь считается только на последнем шаге: $L=\tfrac12(y_3-y_{3,\text{true}})^2$ с целевым значением $y_{3,\text{true}}=0{,}5$.

Шаг назад 1. $\dfrac{\partial L}{\partial y_3}=y_3-y_{3,\text{true}}=0{,}2048-0{,}5=-0{,}2952$. Поскольку $y_3=W_{hy}\,h_3$, получаем $\delta_3=\dfrac{\partial L}{\partial h_3}=-0{,}2952\cdot1{,}0=-0{,}2952$.

Шаг назад 2. Производные $\tanh'(z)=1-\tanh(z)^2=1-h^2$: $f'(z_3)=1-0{,}2048^2\approx0{,}9581$, $f'(z_2)=1-0{,}6954^2\approx0{,}5164$, $f'(z_1)=1-0{,}7163^2\approx0{,}4869$. Так как на шагах $1$ и $2$ своей отдельной потери нет ($g_1=g_2=0$): $\delta_2=W_{hh}\cdot\bigl(\delta_3\cdot f'(z_3)\bigr)=0{,}5\cdot(-0{,}2952\cdot0{,}9581)=0{,}5\cdot(-0{,}2828)\approx-0{,}1414$. Аналогично $\delta_1=W_{hh}\cdot\bigl(\delta_2\cdot f'(z_2)\bigr)=0{,}5\cdot(-0{,}1414\cdot0{,}5164)=0{,}5\cdot(-0{,}0730)\approx-0{,}0365$.

Шаг назад 3 — сбор градиента по $W_{hh}$. Локальный вклад каждого шага — $(\delta_t\cdot f'(z_t))\cdot h_{t-1}$: на шаге $3$ — $(-0{,}2952\cdot0{,}9581)\cdot h_2=(-0{,}2828)\cdot0{,}6954\approx-0{,}1967$; на шаге $2$ — $(-0{,}1414\cdot0{,}5164)\cdot h_1=(-0{,}0730)\cdot0{,}7163\approx-0{,}0523$; на шаге $1$ — $(-0{,}0365\cdot0{,}4869)\cdot h_0=(-0{,}0178)\cdot0=0$ (обнуляется, потому что $h_0=0$). Итоговый градиент — сумма всех трёх вкладов: $\dfrac{\partial L}{\partial W_{hh}}\approx-0{,}1967-0{,}0523+0=-0{,}2490$.

Обрати внимание на масштаб самих $\delta_t$ по мере движения назад: $\delta_3\approx-0{,}2952$, $\delta_2\approx-0{,}1414$, $\delta_1\approx-0{,}0365$ — сигнал ошибки почти вдвое ослабевает на каждом шаге назад. Это первый, ещё «мягкий» численный признак проблемы, которая станет главной темой следующего раздела.

Пример 2 (та же сеть, но потеря считается на каждом шаге — типичная ситуация в языковом моделировании). Предположим теперь, что у каждого шага есть собственный локальный вклад в градиент — например, потому что модель предсказывает символ на каждом шаге, а не только в самом конце: $g_3=-0{,}2952$ (как и раньше), $g_2=-0{,}08$, $g_1=0{,}05$. Тогда, по формуле рекурсии, $\delta_3=g_3=-0{,}2952$ (без изменений, для последнего шага нет «будущего»). Дальше: $\delta_2=g_2+W_{hh}\cdot(\delta_3\cdot f'(z_3))=-0{,}08+0{,}5\cdot(-0{,}2828)=-0{,}08-0{,}1414=-0{,}2214$. И $\delta_1=g_1+W_{hh}\cdot(\delta_2\cdot f'(z_2))=0{,}05+0{,}5\cdot(-0{,}2214\cdot0{,}5164)=0{,}05+0{,}5\cdot(-0{,}1143)=0{,}05-0{,}0572\approx-0{,}0072$. Сравни с примером 1: там $\delta_2\approx-0{,}1414$ целиком приходил «из будущего» (с шага $3$); здесь $\delta_2\approx-0{,}2214$ — заметно больше по модулю, потому что к пришедшему из будущего сигналу добавился собственный локальный вклад шага $2$. Именно так и устроено обучение языковых моделей на символах или словах: ошибка накапливается не в одной точке в конце последовательности, а формируется на каждом шаге, и BPTT обязан честно просуммировать вклады «сверху» (из будущих шагов) и «снизу» (собственная ошибка предсказания на этом шаге) для каждого $\delta_t$.

Пример 3 (усечённый BPTT — практический приём для очень длинных последовательностей). Для последовательности длиной, скажем, $10\,000$ символов (целая глава книги) выполнять BPTT через всю историю целиком практически бессмысленно и дорого: нужно хранить в памяти все $10\,000$ промежуточных активаций $h_t$ для backward pass, а градиент от шага $10\,000$ до шага $1$ (как будет явно показано в следующем разделе) всё равно затухает до пренебрежимо малых значений задолго до того, как дойдёт до начала. На практике применяется усечённый BPTT (truncated BPTT): последовательность режется на короткие фрагменты фиксированной длины (например, $k=25$ или $k=50$ шагов), градиент считается и обновление весов происходит только в пределах одного фрагмента, а скрытое состояние в конце фрагмента передаётся в начало следующего как обычное число, «отрезанное» от графа вычислений — операцией .detach() в PyTorch, — чтобы backward pass не пытался пройти сквозь всю прошлую историю целиком:

import torch
import torch.nn as nn

rnn = nn.RNN(input_size=50, hidden_size=128, batch_first=True)
h = torch.zeros(1, batch_size, 128)  # начальное скрытое состояние

for chunk in sequence_chunks:  # последовательность разбита на куски длины k
    h = h.detach()  # обрываем граф вычислений — градиент не пойдёт дальше назад
    output, h = rnn(chunk, h)
    loss = criterion(output, target_chunk)
    loss.backward()  # BPTT только в пределах текущего чанка длины k
    optimizer.step()
    optimizer.zero_grad()

Такой приём не меняет саму идею BPTT — это по-прежнему backpropagation по развёрнутому графу с суммированием по путям, — но ограничивает глубину, на которую распространяется градиент, экономя и память, и время, без практической потери качества именно потому, что дальше усечённого окна градиент всё равно был бы близок к нулю.

Почему это важно

BPTT наглядно демонстрирует, что за громким отдельным названием скрывается прямое применение уже знакомой тебе математики: те же $\delta$, та же цепочка производных, то же правило суммирования вкладов по путям — просто применённые к графу, развёрнутому во времени, а не по слоям архитектуры. Понимание этого избавляет от ощущения, что RNN требует какого-то принципиально нового аппарата обучения, — она использует ровно тот backpropagation, который был разобран в уроке 330, только с одной содержательной оговоркой: одни и те же веса используются многократно, и их градиент — это сумма вкладов от каждого использования. Именно эта многократность одних и тех же весов — не разных матриц на разных слоях, а буквально одной и той же матрицы, применённой снова и снова, — и есть ключ к следующему, финальному содержательному разделу урока.

Проблема затухающих и взрывающихся градиентов в RNN

Интуиция

В обычной глубокой сети (урок 330) градиент, проходя обратно через $L$ слоёв, перемножается на $L$ разных матриц (или чисел) — производные каждого слоя, вообще говоря, отличаются друг от друга, и есть шанс, что одни множители частично компенсируют другие. В RNN всё устроено гораздо более жёстко: по формуле рекурсии из предыдущего раздела, чтобы градиент дошёл от шага $T$ до шага $1$, ему приходится пройти через умножение на одну и ту же матрицу $W_{hh}$ (точнее, на $W_{hh}$, каждый раз домноженную на производную активации $f'(z_t)$) ровно $T-1$ раз подряд. Это не «разные препятствия на пути», как в обычной глубокой сети, — это буквально возведение одного и того же множителя в степень. А любое число, отличное от $1$, возведённое в достаточно большую степень, стремится либо к нулю (если оно меньше единицы по модулю), либо к бесконечности (если больше) — экспоненциально, а не линейно. Именно поэтому проблема затухающего и взрывающегося градиента, которая в обычных глубоких сетях уже была серьёзной (урок 330, ResNet — урок 337), в RNN проявляется систематически острее: как только эффективный множитель $|W_{hh}\cdot f'(z_t)|$ хоть немного отклоняется от $1$, при достаточно длинной последовательности градиент неизбежно либо обнуляется, либо взрывается — вопрос лишь в том, сколько шагов для этого понадобится.

Формула

Экспоненциальное затухание/взрыв градиента в RNN. Пусть $m$ — типичное значение множителя $W_{hh}\cdot f'(z_t)$ (в скалярном случае) на одном шаге назад. Тогда градиент, дошедший от шага $T$ до шага $T-k$ через $k$ шагов обратного распространения, масштабируется примерно как

$$\frac{\partial h_T}{\partial h_{T-k}} \approx m^{k}$$

При $|m|<1$ это выражение экспоненциально стремится к $0$ с ростом $k$ (затухающий градиент); при $|m|>1$ — экспоненциально растёт (взрывающийся градиент). Для векторного скрытого состояния роль $|m|$ играет спектральный радиус матрицы $W_{hh}$ (наибольшее по модулю собственное значение) — та же экспоненциальная зависимость сохраняется, только вместо скалярного возведения в степень происходит возведение в степень матрицы, что для несимметричных матриц ведёт себя ещё менее предсказуемо.

Разбор примеров

Пример 1 (численное затухание — типичный случай для $\tanh$-активации). Пусть $W_{hh}=0{,}5$, а типичное значение $\tanh'(z_t)$ на рабочих значениях активации близко к $0{,}7$ (для сравнения: в нашем численном примере $\tanh'(z_2)\approx0{,}5164$, $\tanh'(z_1)\approx0{,}4869$ — то есть даже немного ниже). Возьмём для оценки эффективный множитель $m=0{,}5\cdot0{,}7=0{,}35$. После $8$ шагов обратного распространения: $m^8=0{,}35^8$. Считаем по шагам: $0{,}35^2=0{,}1225$; $0{,}35^4=0{,}1225^2\approx0{,}01501$; $0{,}35^8=(0{,}01501)^2\approx0{,}000225$. Это означает, что вклад в градиент от элемента последовательности, отстоящего на $8$ шагов назад, ослаблен примерно в $4\,440$ раз по сравнению с исходным сигналом — сеть фактически не получает почти никакого обучающего сигнала о том, как этот далёкий элемент повлиял на итоговую ошибку.

Пример 2 (численный взрыв — обратный случай). Теперь возьмём $W_{hh}=1{,}3$ с эффективным множителем (например, для входов, где $\tanh'(z_t)$ близко к своему максимуму $1$, то есть $z_t$ близко к нулю) $m=1{,}3$. После $15$ шагов: $m^{15}=1{,}3^{15}$. Считаем: $1{,}3^2=1{,}69$; $1{,}3^4=1{,}69^2\approx2{,}8561$; $1{,}3^8\approx2{,}8561^2\approx8{,}1573$; $1{,}3^{15}=1{,}3^8\cdot1{,}3^4\cdot1{,}3^2\cdot1{,}3\approx8{,}1573\cdot2{,}8561\cdot1{,}69\cdot1{,}3\approx51{,}2$. Градиент, прошедший через $15$ шагов, оказывается почти в $51$ раз больше исходного сигнала — при таком масштабе на практике веса обновляются огромным, разрушительным по величине шагом, что часто приводит к численному переполнению и значению NaN в функции потерь уже через несколько итераций обучения.

Пример 3 (масштаб проблемы на реалистичной длине последовательности и практическое следствие). Возьмём умеренно «мягкий» случай затухания — эффективный множитель $m=0{,}9$ (заметно ближе к $1$, чем в примере 1) — и последовательность длиной $100$ шагов, типичную для короткого абзаца текста. Даже при таком щадящем множителе: $0{,}9^{100}$. Логарифмируя: $100\cdot\ln(0{,}9)\approx100\cdot(-0{,}1054)=-10{,}54$, значит $0{,}9^{100}\approx e^{-10{,}54}\approx0{,}0000266$ — градиент ослаблен более чем в $37\,000$ раз. На практике это означает, что обычная RNN способна выучивать зависимости между элементами последовательности, отстоящими друг от друга максимум на $10$–$20$ шагов, — за пределами этого диапазона обучающий сигнал становится статистически неотличим от шума. Именно поэтому torch.nn.RNN — базовый строительный блок для последовательностей, концептуально абсолютно необходимый для понимания всего, что будет дальше в курсе, — почти никогда не выбирается для реальных задач напрямую: как только нужны зависимости длиннее пары десятков шагов (а это фактически любая осмысленная задача с текстом или длинным временным рядом), простая RNN систематически проигрывает архитектурам со специальными механизмами защиты градиента — LSTM и GRU, о которых пойдёт речь в следующем уроке, — или трансформерам, где длинные зависимости обрабатываются принципиально другим механизмом внимания.

Почему это важно

Разница между затуханием градиента в обычной глубокой сети и в RNN — не количественная, а структурная: в обычной сети каждый слой перемножается со своей собственной, отдельно обучаемой матрицей, и хотя бы теоретически проблема смягчается инициализацией весов или архитектурными приёмами вроде skip connections (урок 337). В RNN же одна и та же матрица $W_{hh}$ буквально возводится в степень, равную длине последовательности, — и никакая инициализация не спасёт от экспоненциального роста или затухания на достаточно длинной последовательности, если спектральный радиус этой матрицы хоть немного отличается от $1$. Отсюда — два разных по серьёзности практических следствия. Взрывающийся градиент лечится относительно дёшево — обрезанием нормы градиента (gradient clipping, разбирается в заданиях этого урока): его можно обнаружить и заблокировать в момент обновления весов. Затухающий градиент вылечить обрезанием невозможно в принципе — обрезание работает с уже посчитанным, слишком большим градиентом, а не с градиентом, ставшим слишком маленьким. Именно затухание, а не взрыв, — главная причина, по которой векторная RNN практически не используется в задачах с длинными зависимостями, и именно оно решается принципиально другим архитектурным приёмом в следующем уроке.

Практика: 30 заданий

Базовые задания (1–10)

Задание 1: Скрытое состояние $h_{\text{prev}}=0$, вход $x=1$, веса $W_{xh}=0{,}6$, $W_{hh}=0{,}4$, $b_h=0{,}2$. Найди $z=W_{hh}\,h_{\text{prev}}+W_{xh}\,x+b_h$ и $h=\tanh(z)$.


Задание 2: Входная размерность $x=30$, скрытая размерность $h=64$. Найди число параметров матриц $W_{hh}$ и $W_{xh}$ (без смещений) и их сумму.


Задание 3 (машинное обучение): Почему полносвязная сеть с фиксированным числом входов не может напрямую обработать предложения разной длины без паддинга или обрезания?


Задание 4: Продолжи численный пример урока: $h_0=0$, $W_{hh}=0{,}5$, $W_{xh}=0{,}8$, $b_h=0{,}1$, $x_1=1{,}0$. Найди $z_1$ и $h_1$.


Задание 5: Продолжая задание 4, при $h_1\approx0{,}7163$, $x_2=0{,}5$ и тех же весах найди $z_2$ и $h_2$.


Задание 6: Продолжая задания 4–5, при $h_2\approx0{,}6954$, $x_3=-0{,}3$ найди $z_3$ и $h_3$.


Задание 7 (машинное обучение): Если $W_{hh}=0$, во что вырождается формула $h_t=\tanh(W_{hh}\,h_{t-1}+W_{xh}\,x_t+b_h)$? Что это означает для «памяти» сети?


Задание 8: Используя $\tanh'(z)=1-\tanh(z)^2$ и $h_1=\tanh(z_1)\approx0{,}7163$ из задания 4, найди $\tanh'(z_1)$.


Задание 9: RNN развёрнута на $T=15$ шагов. Если бы веса НЕ были общими (каждый шаг — независимый набор из $5\,000$ параметров), сколько всего параметров потребовалось бы против $5\,000$ у настоящей RNN с общими весами?


Задание 10: Найди $W_{hh}^{10}$ при $W_{hh}=1{,}2$ (без учёта производной активации) — как ранний числовой сигнал взрывающегося градиента.

Средние задания (11–20)

Задание 11: Используя $\delta_3=-0{,}2952$, $\tanh'(z_3)\approx0{,}9581$, $W_{hh}=0{,}5$, найди $\delta_2=W_{hh}\cdot\bigl(\delta_3\cdot\tanh'(z_3)\bigr)$.


Задание 12: Используя $\delta_2\approx-0{,}1414$, $\tanh'(z_2)\approx0{,}5164$, $W_{hh}=0{,}5$, найди $\delta_1$.


Задание 13: Найди вклад шага $t=3$ в градиент $\partial L/\partial W_{hh}$, используя $\delta_3\cdot\tanh'(z_3)\approx-0{,}2828$ и $h_2\approx0{,}6954$.


Задание 14: Найди вклад шага $t=2$ в градиент $\partial L/\partial W_{hh}$, используя $\delta_2\cdot\tanh'(z_2)\approx-0{,}0730$ и $h_1\approx0{,}7163$.


Задание 15: Сложи вклады из заданий 13 и 14 и вклад шага $t=1$ (равный $0$, так как $h_0=0$), чтобы получить итоговый $\partial L/\partial W_{hh}$.


Задание 16 (машинное обучение): Почему градиент по общему весу $W_{hh}$ нужно суммировать по всем временным шагам, а не брать вклад только последнего шага?


Задание 17: RNN с $h=128$, $x=50$, выходным слоем размерности $20$. Найди общее число параметров: $W_{hh}$, $W_{xh}$, $b_h$, $W_{hy}$, $b_y$.


Задание 18: Найди $0{,}35^8$ — эффективное затухание градиента при множителе $m=0{,}35$ за $8$ шагов.


Задание 19: Найди $1{,}3^{15}$ — эффективный рост градиента при множителе $m=1{,}3$ за $15$ шагов.


Задание 20: Реализуй на PyTorch один шаг RNN-ячейки вручную (без nn.RNN) — функцию, вычисляющую новое скрытое состояние по формуле $h_t=\tanh(W_{hh}h_{t-1}+W_{xh}x_t+b_h)$.

import torch

def rnn_step(h_prev, x_t, W_hh, W_xh, b_h):
    # твой код здесь
    pass

Продвинутые задания (21–30)

Задание 21 (машинное обучение): Почему проблема затухающего градиента в vanilla RNN обычно острее, чем в обычной глубокой сети той же глубины?


Задание 22: Пусть потеря считается на каждом шаге: локальные вклады $g_1=0{,}05$, $g_2=-0{,}08$, $g_3=-0{,}2952$; $W_{hh}=0{,}5$; $\tanh'(z_3)\approx0{,}9581$, $\tanh'(z_2)\approx0{,}5164$. Найди $\delta_3$, $\delta_2$, $\delta_1$ по формуле $\delta_t=g_t+W_{hh}\cdot(\delta_{t+1}\cdot\tanh'(z_{t+1}))$.


Задание 23: Используя результаты задания 22 и $\tanh'(z_1)\approx0{,}4869$, $h_2\approx0{,}6954$, $h_1\approx0{,}7163$, $h_0=0$, найди полный градиент $\partial L/\partial W_{hh}$ как сумму вкладов по всем трём шагам.


Задание 24 (машинное обучение): Для векторного скрытого состояния (не скаляра) как обобщается условие $|W_{hh}|<1$ или $|W_{hh}|>1$, определяющее затухание или взрыв градиента?


Задание 25: Норма градиента $\|g\|=15{,}0$, порог обрезания (gradient clipping) $=5{,}0$. Найди коэффициент масштабирования и итоговую норму после обрезания по правилу $g'=g\cdot\dfrac{\text{порог}}{\|g\|}$ (если $\|g\|>\text{порог}$).


Задание 26: Последовательность длиной $500$ шагов обучается усечённым BPTT с окном $k=25$. Сколько отдельных фрагментов (чанков) потребуется, чтобы пройти всю последовательность один раз?


Задание 27: Дополни скелет цикла обучения усечённым BPTT на PyTorch — добавь обрыв графа вычислений между чанками.

import torch
import torch.nn as nn

rnn = nn.RNN(input_size=50, hidden_size=128, batch_first=True)
h = torch.zeros(1, batch_size, 128)

for chunk, target_chunk in sequence_chunks:
    # твой код здесь: обрыв графа, forward, backward, шаг оптимизатора
    pass

Задание 28 (машинное обучение): Почему torch.nn.RNN (простая, «ванильная» RNN) редко используется в реальных проектах сегодня, несмотря на свою концептуальную важность?


Задание 29: RNN с $h=256$, $x=100$ (только $W_{hh}$, $W_{xh}$, $b_h$, без выходного слоя). Найди число параметров однонаправленной версии и во сколько раз больше потребуется для двунаправленной (bidirectional) версии, где вперёд и назад идут два независимых набора таких весов.


Задание 30 (машинное обучение): Последовательность длиной $200$ шагов (длинный абзац текста) обрабатывается vanilla RNN для классификации по последнему скрытому состоянию $h_{200}$. Сможет ли предсказание эффективно учитывать информацию из самых первых слов текста? Обоснуй через экспоненциальное затухание и назови, чем эта проблема решается.

Частые ошибки

  • Путают скрытое состояние с выходом сети. $h_t$ и $y_t$ на шаге $t$ — это разные величины: $h_t$ — внутренняя «память» произвольной размерности, а $y_t$ — то, что сеть выдаёт наружу через отдельный выходной слой поверх $h_t$; они не обязаны совпадать ни по смыслу, ни по размерности.

  • Забывают, что веса — общие для всех временных шагов. $W_{hh}$, $W_{xh}$, $b_h$ — это одни и те же числа на каждом шаге, а не отдельные, независимо обучаемые наборы весов для каждого $t$ — эта ошибка проявляется, например, при ручной реализации RNN, когда вместо переиспользования одного и того же слоя случайно создаётся отдельный nn.Linear для каждого шага цикла.

  • Забывают про смысл начального скрытого состояния $h_0$. Чаще всего это просто нулевой вектор, но для некоторых задач (например, продолжения генерации с сохранённым контекстом) в $h_0$ явно передаётся состояние, накопленное на предыдущем фрагменте текста.

  • Путают глубину по слоям с глубиной по времени. Число слоёв RNN, поставленных друг на друга (параметр num_layers в torch.nn.RNN, стек из нескольких рекуррентных слоёв), и глубина в смысле развёртки во времени (число шагов последовательности) — это два разных, независимых измерения, и увеличение одного не заменяет и не усиливает другое.

  • Обучают vanilla RNN на очень длинных последовательностях без защиты градиента. Без усечённого BPTT и без обрезания градиента итог почти всегда один и тот же: либо NaN в функции потерь из-за взрыва градиента, либо сеть, которая формально обучается, но полностью игнорирует дальние зависимости из-за затухания.

  • Путают проблему деградации глубоких сетей с затухающим градиентом в RNN, как будто это одна и та же вещь. Механизм в RNN (урок 337 разбирал деградацию ResNet) острее и специфичнее именно потому, что градиент проходит через многократное умножение на одну и ту же матрицу, а не через разные веса разных слоёв.

  • Ожидают от torch.nn.RNN производительности, сравнимой с LSTM или GRU, на задачах с длинными зависимостями. И списывают плохой результат на «неудачные гиперпараметры», вместо того чтобы понять: сама архитектура vanilla RNN структурно ограничена длиной эффективной памяти, и никакой подбор learning rate это ограничение не снимет.

Главное запомнить

  • RNN нужна там, где вход — последовательность переменной, заранее неизвестной длины, а порядок элементов принципиально важен: текст, речь, временные ряды.

  • Скрытое состояние $h_t$ — это «память» сети, обновляемая на каждом шаге по формуле $h_t=f(Wh_{t-1}+Ux_t+b)$.

  • Одни и те же веса $W$, $U$, $b$ используются на каждом шаге последовательности (weight sharing по времени) — именно поэтому RNN не привязана к фиксированной длине входа.

  • Развёртка во времени (unrolling) превращает RNN в очень глубокую сеть с $T$ «слоями», где $T$ — длина последовательности, но с общими весами на каждом уровне.

  • Backpropagation through time (BPTT) — это backpropagation из урока 330, применённый к развёрнутому графу, с ключевым дополнением: градиент по общим весам суммируется по вкладам от всех временных шагов.

  • В RNN одна и та же матрица $W_{hh}$ перемножается сама на себя (в связке с производными активации) $T-1$ раз при обратном проходе через $T$ шагов — отсюда экспоненциальное, а не постепенное затухание или взрыв градиента.

  • Спектральный радиус матрицы $W_{hh}$ (в скалярном случае — просто её величина) определяет, будет градиент затухать ($<1$) или взрываться ($>1$) на длинных последовательностях.

  • Gradient clipping частично лечит взрывающийся градиент; от затухающего градиента у vanilla RNN практических средств защиты нет.

  • Именно поэтому на практике почти всегда используют LSTM или GRU вместо простой RNN — эти архитектуры решают проблему затухающего градиента специальными механизмами гейтов, разбор которых — тема следующего урока.

  • torch.nn.RNN концептуально необходим для понимания LSTM, GRU и даже механизма внимания трансформеров, хотя сам по себе сегодня выбирается для реальных задач редко.

Связь с темами курса

Этот урок — прямое продолжение backpropagation из урока 330, а не отдельная, независимая тема. Цепное правило, вычисление $\delta$-сигналов ошибки, правило суммирования вкладов по нескольким путям для переменной, влияющей на результат разными способами, — весь этот аппарат был построен там на примере обычной глубокой сети и здесь применяется без единого изменения, только к другому графу вычислений — развёрнутому во времени. Более того, сама проблема затухающего градиента впервые обсуждалась именно в уроке 330 как общее свойство глубоких сетей; сегодняшний урок показывает частный, но особенно суровый случай этой проблемы — случай, когда через все слои (то есть через все временные шаги) проходит буквально одна и та же матрица весов, а не разные матрицы, как в обычной сети. Понимание разницы между «градиент через разные слои» и «градиент через одну и ту же матрицу, применённую многократно» — ключевой мост между уроком 330 и сегодняшним материалом.

С уроками 335–338 связь другого рода — это смена самого типа данных, а не продолжение той же архитектурной линии. Всё, что было построено для CNN — свёртка, weight sharing по пространству, рецептивное поле, — работало потому, что изображение имеет фиксированный размер и локальную пространственную структуру. RNN решает структурно похожую задачу (переиспользование одних и тех же весов, чтобы не обучать их заново для каждой позиции) для принципиально другого типа данных — последовательности, где «позиция» — это не координата в пространстве, а момент времени, и где, в отличие от изображения, общее число позиций заранее неизвестно.

Наконец, этот урок сознательно оставляет открытым вопрос, на который отвечает следующий: как обучать RNN на длинных последовательностях, если простая архитектура структурно обречена на затухание градиента? Ответ — LSTM (Long Short-Term Memory) и GRU (Gated Recurrent Unit), урок 340, — не отменяет ничего из сегодняшнего материала, а добавляет к формуле обновления скрытого состояния управляемые «гейты» (ворота), которые позволяют градиенту течь назад по времени почти без искажения, — идея, по духу поразительно похожая на skip connection в ResNet (урок 337), только примененная не к глубине сети, а к глубине во времени.

Интересные факты

  • Термин «рекуррентная нейронная сеть» в широком смысле восходит к сети Хопфилда (1982 год), но архитектура с обучаемым скрытым состоянием и явным механизмом обновления $h_t=f(Wh_{t-1}+Ux_t+b)$, знакомая нам сегодня, оформилась позже — в работах Джордана (1986) и особенно Элмана (1990), чья сеть до сих пор так и называется — Elman network (сеть Элмана).

  • Сеть Элмана 1990 года обучалась предсказывать следующее слово в искусственно сгенерированных предложениях простого языка и, судя по анализу скрытых состояний после обучения, начинала неявно группировать слова по грамматическим категориям (существительные, глаголы) — просто по статистике их появления в последовательности, без единого явного грамматического правила, заданного вручную.

  • В 2015 году исследователь Андрей Карпаты (Andrej Karpathy) опубликовал знаменитый пост «The Unreasonable Effectiveness of Recurrent Neural Networks» («Неразумная эффективность рекуррентных нейронных сетей»), где рекуррентная сеть, обучаемая посимвольно, генерировала правдоподобно выглядящий текст в стиле Шекспира и даже фрагменты, синтаксически похожие на исходный код ядра Linux — не понимая языка программирования, а лишь выучив статистику того, какой символ обычно следует за какой последовательностью предыдущих символов.

  • Несмотря на то что трансформеры почти полностью вытеснили рекуррентные сети из современных языковых моделей, идея передачи компактного состояния от шага к шагу пережила своеобразное возрождение в 2020-х годах в архитектурах вроде state space models (моделей пространства состояний) — включая нашумевшую Mamba (2023 год), — рекуррентность как принцип не исчезла, она лишь временно уступила трансформерам по эффективности параллельного обучения на современном железе.

Лайфхаки

  • При самостоятельной реализации RNN начинай с крошечной синтетической последовательности из $3$–$5$ шагов и прогоняй forward и backward pass вручную, как в этом уроке, — это надёжнее всего вскрывает ошибки в размерностях матриц ещё до того, как код запущен на реальных данных.

  • Используй gradient clipping (torch.nn.utils.clip_grad_norm_) буквально всегда при обучении RNN, LSTM или GRU — это несколько строк кода, которые почти бесплатно защищают от NaN в функции потерь из-за взрывающегося градиента.

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

  • Если задача требует запоминать зависимости дальше $10$–$20$ шагов — не трать время на подбор гиперпараметров vanilla RNN, сразу переходи к LSTM или GRU (следующий урок) либо к архитектуре с механизмом внимания.

  • Инициализируй $W_{hh}$ близко к ортогональной матрице (orthogonal initialization) вместо стандартной случайной инициализации — ортогональная матрица по построению имеет спектральный радиус, равный $1$, что частично смягчает и затухание, и взрыв градиента на старте обучения.

  • Логируй норму градиента по шагам обучения — резкий скачок этой нормы почти всегда сигнализирует о надвигающемся взрыве градиента ещё до того, как в логах появится первое значение NaN, и даёт шанс остановить обучение и разобраться в причине заранее.

Рекуррентная сеть — самая близкая к человеческой интуиции архитектура из всех, что встречались в этом курсе: она читает последовательность так же, как читаешь её ты сам, — шаг за шагом, удерживая в голове сжатое понимание всего, что было раньше. Именно эта простая идея — «помнить, обновляя», а не «видеть всё сразу» — легла в основу всей современной обработки языка, речи и временных рядов, и понимание того, где она даёт сбой из-за затухающего градиента, — необходимый шаг к тому, чтобы по-настоящему оценить, насколько элегантно эту проблему решают LSTM и GRU в следующем уроке.

Понял тему? Закрепи в боте! 🚀

Попрактикуйся на задачах и получи персональные рекомендации от AI

💪 Начать тренировку
💬 Есть вопрос? Спроси бота!