Введение
Рекуррентные нейронные сети (RNN) являются мощным инструментом в глубоком обучении для обработки и анализа последовательных данных. RNN в глубоком обучении является ключевым компонентом в обработке таких данных: текст, речь, или временные ряды.
Одна из ключевых особенностей RNN — их способность запоминать предыдущие входные данные из внутренней памяти. Каждый нейрон в слое имеет состояние, которое меняется со временем, позволяя сетям хранить информацию о предыдущих входах.
RNN отличаются от сетей прямого распространения тем, что они имеют связи к самим себе, позволяющие им обрабатывать данные в виде последовательностей. В RNN связи между элементами образуют направленную последовательность, что позволяет сетям использовать свою внутреннюю память для обработки последовательностей определенной длины. Длина последовательности, с которой могут работать разные архитектуры RNN, ограничена или даже статична.
RNN позволяет сохранять информацию о предыдущих входных элементах данных, в них данные со входа поступают в рекуррентный нейронный слой, где нейроны суммируют данные входного слоя и значения, вычисленные на предыдущем шаге этого слоя (предыдущем элементе последовательности). Это позволяет сетям учитывать контекст предыдущих входных данных при обработке текущих данных.
RNN широко используются в задачах, где необходимо обрабатывать последовательные данные, такие как: распознавание речи, обработка естественного языка, прогнозирование временных рядов.
LSTM имеет пару ключевых преимуществ перед обычным RNN. Благодаря своей архитектуре LSTM может частично решать проблему, связанную с исчезающим градиентом из-за большой вложенности вычислений. LSTM более эффективен для обработки последовательных данных.
Существуют различные архитектуры RNN, но в этой статье я рассмотрю только 2 базовых слоя. Простая RNN, в которой каждый нейрон соединен со всеми другими в слое и есть функция активации (гиперболический тангенс). И слой LSTM, в котором есть 4 фильтра, у каждого из которых есть функция активации, и контекстное состояние и скрытое состояние.
Устройство слоя RNN
Этот слой состоит из одного линейного преобразования и одного нелинейного, отличие от линейного слоя в том, что этот слой конкатенирует к входному значению своё предыдущее выходное значение.

Работу RNN математически обычно представляют в виде произведения матрицы весов на конкатенацию двух значений, которые определяют внутреннее состояние рекуррентного слоя и входной сигнал на каждом временном шаге. Для простой RNN:
— две объединенные матрицы в одну (два вектора),
— состояние скрытого слоя на шаге t,
— входные данные на шаге t,
— матрица весов рекуррентного слоя и
— матрица смещений (которая составлена из копий вектора-строки смещений), t — время (временной шаг, time). Объединение означает, что каждая строка новой матрицы составляется из строк двух матриц — это горизонтальное объединение. Вертикальное объединение — строки новой матрицы составляются сначала из первой матрицы, потом в эту же матрицу добавляются строки из второй матрицы. Но мы не сможет объединить их вертикально, так как число признаков у них разное.
Обычный слой RNN состоит из одной матрицы весов, вектора смещений и нелинейной функции активации. Сначала происходит объединение входной матрицы и матрицы скрытого состояния, затем они складываются с матрицей смещений, а потом к полученной матрице применяется функция активации, результат сохраняется и передается как выходное значение.
На самом деле в качестве входа и скрытого состояния подразумевается не матрица, а вектор — вектор входных признаков и вектор выходных признаков слоя. Просто гораздо эффективнее выполнять обработку сразу на весь пакет (batch) входных данных, который представляет собой матрицу входных данных, каждая строка которой представляет собой признаки одного примера. Аналогично для матрицы внутреннего состояния слоя, каждая строка этой матрицы — вектор скрытого состояния для одного объекта, который сопоставляется с соответствующей строкой входной матрицы.
Пример реализации слоя RNN
Инициализация класса RNN
class RNN(torch.nn.Module):
def __init__(self, input_size, output_size, activation="tanh"):
super().__init__()
# матрица слоя RNN, на вход подаётся объенинённые входной вектор и скрытый вектор
self.rnn_weights = torch.nn.Linear(input_size + output_size, output_size)
self.output_size = output_size
if activation == "sigmoid":
self.activation = torch.nn.Sigmoid()
elif activation == "tanh":
self.activation = torch.nn.Tanh()
Наследуемся от шаблонного класса Module, он делает всю рутинную работу, такую как добавление параметров слоя. Но это будет работать только для классов, которые реализованы на основе torch.nn, для самодельных это работать не будет.
input_size — число входных нейронов, output_size — число нейронов слоя RNN (выходные нейроны слоя RNN), activation - функция активации внутреннего состояния.
Матрица весов должна умножаться на объединённый входной вектор и вектор скрытого состояния, поэтому нужно создать матрицу соответствующего размера. Чтобы не создавать отдельно матрицу и смещения просто используем линейный слой, в нём они есть.
Метод forward
def forward(self, inp, hidden):
batch_size, seq_len = inp.shape[0], inp.shape[1]
rnn_seq = torch.zeros(size=(batch_size, seq_len, self.output_size))
for seq_idx in range(seq_len):
# горизонтальное соединение векторов
combined = torch.hstack([inp[:, seq_idx, :], hidden[:, 0, :]])
h_z = self.rnn_weights(combined)
rnn_seq[:, seq_idx] = self.activation(h_z)
hidden[:] = rnn_seq[:, seq_idx]
return rnn_seq, hidden
В метод forward передаются входная последовательность и предыдущее скрытое состояние (изначально оно состоит из нулей). Перед обработкой каждого входного элемента последовательности нужно объединить входной вектор и предыдущее выходное состояние слоя RNN.
Создаём тензор rnn_seq из нулей, в который будут записываться выходные состояния слоя для каждого входного элемента входной последовательности. В первой оси массива находятся элементы пакета, во второй — элементы последовательности, в третьей — элементы вектора признаков.
Далее последовательно обрабатываются элементы входной последовательности. Каждый входной вектор соединяется с предыдущим скрытым состоянием слоя RNN, умножается на матрицу весов и прибавляется смещение. Потом применяется функция активации, получается новое выходное состояние (скрытое состояние), записываем его в тензор выходной последовательности.
Использовать и передавать скрытое состояние нужно снаружи, управлять им изнутри не нужно. Так же с использованием скрытых состояний рекуррентных слоёв при реализации класса для модели нейросети.
Эта реализация работает медленнее, чем реализация в torch.nn.RNN, поэтому лучше использовать реализацию из torch, так же и для LSTM.
Устройство слоя LSTM
Слой LSTM (long short-term memory, долгая краткосрочная память) является специализированной архитектурой RNN. Архитектура LSTM отличается от традиционной RNN тем, что она включает в себя специальные компоненты «состояний» и три типа фильтров (gate) состояний, которые фильтруют информацию с предыдущего скрытого и контекстного состояний и с входного состояния. Всего в слое LSTM присутствует 4 подслоя: 3 сигмоидальных и 1 слой гиперболического тангенса (а в обычном RNN только один послой с гиперболическим тангенсом или сигмоидой), они изображены прямоугольниками. Эллипсами изображены поэлементные матричные операции (см. рисунок ниже).
Контекстное состояние ячейки C — это горизонтальная линия на схеме, проходящая через всю цепочку, которая позволяет информации течь через неё, цепочку, с минимальными изменениями. Это ключевой компонент, который позволяет LSTM хранить информацию на протяжении более 10 временных шагов.
Скрытое состояние ячейки H — горизонтальная линия снизу, оно же выходное значение всего слоя.
Фильтр забывания (forget gate) — первый прямоугольник слева с сигмоидой. Определяет, какую информацию можно «забыть» из скрытого состояния ячейки (т. е. занулить). Он возвращает значения от 0 до 1, где 1 означает полностью «запомнить», а 0 — полностью «забыть», то есть степень важности информации.
Слой входного фильтра (input gate) — второй прямоугольник с сигмоидой и тангенсом. Определяет, какая новая информация будет добавлена в состояние ячейки. Этот процесс включает два шага: сигмоидальный слой, определяющий, какие значения следует обновить, и tanh-слой, строящий вектор новых значений-кандидатов (т. е. какая часть входных данных будет «отобрана»).
Слой выходного фильтра (output gate) — третий прямоугольник с сигмоидой. Определяет, какая информация из состояния ячейки будет выведена на выход. Он также использует сигмоидальный слой и tanh-слой (который эллипсом) для вычисления выходного значения H (скрытого состояния).

Математическая запись (S — stacked):
объединение:
forget gate:
input gate:
context state:
output gate:
hidden state:
— объединенные матрицы,
— входные данные на шаге t,
— контекстное состояние слоя на шаге t,
— внутреннее состояние слоя и также выходное значение слоя на шаге t.
Фильтр — совокупность линейный и нелинейных преобразований. Матричное произведение — это линейное преобразование, а функция активации — нелинейное преобразование.
Объединённые и предыдущее состояние скрытого слоя
подаются на вход фильтров.
Фильтр забывания решает, какую информацию из предыдущего контекстного состояния ячейки сохранить или забыть.
Фильтр входа ппределяет, какая новая информация будет добавлена в контекстное состояние ячейки.
Новое контекстное состояние вычисляется на основе результатов фильтров забывания и входа.
Выходной фильтр определяет, какая информация из текущего состояния ячейки будет выведена на выход, формируя выходное состояние .
Сначала входное значение конкатенируется со скрытым состоянием, затем получившаяся матрица матрично умножается на каждую из четырех матриц весов, прибавляются их смещения и каждый из результатов проходит через функцию активации. Потом применяются 4 операции поэлементного сложения или умножения и операция гиперболического тангенса. В результате получаются выходное значение (скрытое состояние) и контекстное состояние.
Контекстное состояние C передается дальше, немного изменившись. Внутреннее состояние H сильно изменяется. Нелинейные преобразования состояний как бы фильтруют информацию, которая содержится в этих двух состояниях слоя. Эти фильтры работают на основе матриц весов, которые изменяются во время обучения слоя на данных.
Реализация аналогично. Пример реализации есть.
Источник про LSTM и изображений.
Как происходит обучение рекуррентного слоя
Если не можете понять, как происходит обучение, то можете посмотреть пункт «пример использования цепного правила дифференцирования» в моей статье про производную и так же изучить, как происходит автоматическое дифференцирование — у меня также есть статья.
Обучение рекуррентного слоя происходит аналогично тому, как оно происходит для обычного линейного слоя, только градиент вычисляется не на основе одного входного и выходного значений, а на основе нескольких входных и нескольких выходных значений. Градиент вычисляется от каждого выходного значения на каждом элементе последовательности.

Сначала происходит прямое распространение. На вход подается первый элемент последовательности (красным цветом на рисунке), он обрабатывается рекуррентным слоем, в нем создаются новые операнды (элементы в графе вычислений) — скрытые состояния слоя (на рисунке они передаются вправо, зеленым цветом). В создании скрытого состояния участвует предыдущее скрытое состояние (которое инициализируется нулями) и текущее входное значение. Далее новое скрытое состояние передается как выходное значение текущего слоя, это значение передается следующему слою в нейронной сети (синим цветом). Следующим слоем может быть еще один рекуррентный слой или линейный слой (синим). Последний слой нейросети выдаёт значение, которое является выходным значением на текущем шаге последовательности (синие прямоугольники на рисунке).
Далее на вход всей нейросети подаётся следующий элемент последовательности (красный прямоугольник), повторяется аналогичная процедура для всех остальных элементов последовательности. Каждое выходное значение (синим цветом) всей нейросети участвует в функции ошибки для своего шага последовательности. После последнего слоя в модели происходит обработка выхода всей нейросети в функции ошибки и от получившегося операнда (loss) запускается обратное распространение через метод backward(). Этот метод нужно запустить от каждого выходного элемента последовательности. Для операнда ошибки у каждого обучаемого операнда (матрицы весов и смещения) будет вычисляться значение градиента и оно будет суммироваться после обратного распространения от каждого выходного элемента последовательности, участвовавшего в вычислении функции ошибки.
Каждая матрица весов в RNN или в LSTM являетя операндом, который участвует в получении выходного значения нейросети на каждой шаге последовательности. Во время обратного распространения каждый операнд, участвующий в получении выходного значения нейросети, получает градиент и прибавляет его к общему значению градиента текущей итерации обучения (поэтому их нужно обнулять в каждой новой итерации обучения, чтобы они не накапливались). Обратное распространение происходит для каждого выходного значения нейросети и для каждого операнда вычисляется градиент для каждого из этих выходных значений. Затем полученное значение градиента для каждого операнда используется для градиентного спуска.
В реализации автоматического дифференцирования есть различные нюансы, которые скорее всего делают работу autograd в torch не такой, как я её здесь описал.
Пример использования слоя RNN
В этом примере будет предсказываться (генерироваться) последовательность значений функции sin. На вход нейросети подаётся последовательность из 10 значений функции sin. Затем нейросеть должна сгенерировать следующие 5 значений функции sin. Класс для модели выглядит следующим образом.
Определение класса для модели нейросети
class RNNModel(torch.nn.Module):
def __init__(self, input_size, hidden_size, output_size):
torch.nn.Module.__init__(self)
# self.rnn_text = torch.nn.RNN(input_size, hidden_size, batch_first=True)
self.rnn = RNN(input_size, hidden_size)
self.hidden_size = hidden_size
self.linear = torch.nn.Linear(hidden_size, output_size)
def forward(self, inp, hidden):
rnn_out, hidden = self.rnn(inp, hidden)
linear_out = self.linear(rnn_out)
return linear_out, hidden
def init_hidden(self, batch_size):
return torch.zeros(batch_size, 1, self.hidden_size)
Так же, как и при создании слоя, нужно наследоваться от Module. Нейросеть состоит из 3-х слоёв: входной, скрытый рекуррентный и выходной. У выходного слоя нет функции активации. Рекуррентный слой обрабатывает входную последовательность, а выходной слой осуществляет линейное преобразование скрытых состояний рекуррентного слоя, таким образом на выходе получаются сгенерированные значения функции sin (предсказанная последовательность).
Снаружи модели нужно инициализировать скрытое состояние и передать его в метод forward вместе с входной последовательностью. В методе forward обрабатывается входная последовательность слоем RNN, получается последовательность скрытых состояний. Каждое скрытое состояние обрабатывается линейным слоем.
Далее создаётся набор данных, модель, функция ошибки, оптимизатор и другие вспомогательные переменные.
Основные переменные и объекты
x_list = torch.linspace(start=0, end=100, steps=100) # список значений "x"
y_list = torch.sin(x_list) # последовательность значений зависимости "синус"
inp_seq_len = 10 # длина входной последовательности
out_seq_len = 5 # длина выходной последовательности
features_num = 1 # число входных признаков - значение функции "синус"
y_list = y_list.reshape(-1, features_num) # изменяем форму массива с 1- на 2-мерную
input_seqs, target_seqs = create_data(y_list, inp_seq_len, out_seq_len)
model = RNNModel(input_size=features_num, hidden_size=30, output_size=features_num)
criterion = torch.nn.MSELoss()
optimizer = torch.optim.Adam(params=model.parameters(), lr=0.001)
Создаём последовательность входных значений для функции sin из 100 элементов, затем значения самой функции. Количество признаков = 1, так как у функции sin есть только одно значение (как входное, так и выходное). Указываем длину входной и выходной последовательности — это последовательность значений функции sin. Изменяем форму массива — нужно представить последовательность чисел в виде последовательности векторов с одним элементом (в качестве этих элементов могут быть буквы, закодированные методом one-hot-encoding).
Создаём набор данных.
Создание набора данных
def create_data(y_list, inp_seq_len, out_seq_len=1):
inp_data_num = y_list.shape[0] - inp_seq_len - out_seq_len + 1
features_num = y_list.shape[1]
input_seqs = torch.zeros(size=(inp_data_num, inp_seq_len, features_num))
target_seqs = torch.zeros(size=(inp_data_num, out_seq_len, features_num))
for i in range(inp_data_num):
# последовательность из векторов длиной seq_len, каждый вектор - одно число
inp_seq = y_list[i: i + inp_seq_len, :] # shape = (10, 1)
input_seqs[i] = inp_seq
# последовательность векторов, которую нужно предсказать на основе inp_seq,
# каждый вектор - одно число, предсказываем последовательность из 1 вектора
target_seq = y_list[i + inp_seq_len: i + inp_seq_len + out_seq_len, :]
target_seqs[i] = target_seq
return input_seqs, target_seqs
Создаём тензоры, в которых будут храниться данные. Потом заполняем их.
Делаем сначала входную последовательность из inp_seq_len элементов, потом целевую последовательность из out_seq_len элементов. Так делаем inp_data_num раз — это общее число примеров.
Далее обучение, здесь показано как выглядит одна итерация обучения.
Итерация обучения
for inp_seq, target_seq in zip(input_seqs, target_seqs):
optimizer.zero_grad() # обнуляем градиенты
inp_seq = inp_seq.reshape(shape=(batch_size, inp_seq_len, features_num))
target_seq = target_seq.reshape(shape=(batch_size, out_seq_len, features_num))
# получаем предсказание и скрытое состояние модели
hidden = model.init_hidden(batch_size)
_, hidden = model.forward(inp_seq, hidden)
zero_inp = torch.zeros(size=(batch_size, out_seq_len, features_num))
y_pred, _ = model.forward(zero_inp, hidden)
# вычисляем ошибку
loss = criterion(y_pred, target_seq)
total_loss += loss.item()
loss.backward() # обратное распространение ошибки
optimizer.step() # обновление весов
Работаем с каждой парой (входная последовательность, целевая последовательность). Обнуляем оптимизатор от градиентов с предыдущей итерации обучения. Форматируем данные, добавляем им ось, в которой должны храниться элементы пакета (batch_size).
Сначала обрабатываем моделью входную последовательность, получаем скрытое состояние рекуррентного слоя, которое получается после обработки 10-ти элементов последовательности. Далее создаем последовательность из нулей. Обрабатываем последовательность из нулей и полученным скрытым состоянием. На выходе получается 5 сгенерированных элементов функции sin. Последовательность из нулей нужна для того, чтобы работать чисто с рекуррентным слоем, без входных элементов, т. е. таким образом рекуррентный слой будет генерировать данные лишь на основе полученного скрытого состояния. А благодаря тому, что мы заранее задали число выходных элементов последовательности (т. е. мы даём модели нулевые входные элементы, которые никак не влияют на генерацию выходных элементов), нам не нужно для этого делать дополнительную логику внутри модели, внутри метода forward, которая будет отвечать за длину генерации.
Далее вычисляется функция ошибки, делается обратное распространение (вычисление градиентов) и обновление весов оптимизатором (градиентным спуском) — специальная формула для обновления весов на основе градиентов, в интернете можно найти формулу для каждого оптимизатора, в данном случае это оптимизатор «adam».
Далее можно проверить качество генерации на тестовых данных (в этом примере я не разделял на тестовые и обучающие данные). Также нужно учитывать диапазон значений аргумента функции sin, в обучающих и в тестовых данных диапазоны должны примерно совпадать, иначе модель не обучится корректно. Т. к. значения аргумента могут быть любыми, а значения функции только от -1 до 1. Либо можно нормализовать значения аргумента через период. Хотя в этом примере значение аргумента не используется, это я привёл для общего развития.
Заключение
Я рассказал о строении слоёв RNN и LSTM, показал, какие операции в них происходят в виде математической записи. Показал пример реализации слоя с помощью torch. Рассказал как происходит обучение рекуррентного слоя. Сделал пример реализации модели с использованием рекуррентного слоя для генерации последовательности значений функции sin.
Это учебный пример для новичков, думаю этого достаточно, чтобы начать понимать, как работать с рекуррентными слоями, чтобы понимать, как работает обучение нейронных сетей с рекуррентными слоями, обучение рекуррентных слоёв.
Я занимаюсь проектом «Теория цифрового интеллекта», основная цель проекта — создание теории, в которой будут описываться различные принципы интеллекта, приглашаю в сообщество. Канал проекта в телеграме. Страничка проекта (содержание).