9.2 Долгая краткосрочная память (LSTM)
Долгое время у моделей со скрытыми переменными существовала проблема: сложно одновременно сохранять долгосрочную информацию и реагировать на короткие входные данные. Одним из самых ранних решений стала долгая краткосрочная память (long short-term memory, LSTM) (Hochreiter and Schmidhuber, 1997). У неё много общих свойств с управляемым рекуррентным блоком (раздел 9.1). Любопытно, что архитектура LSTM немного сложнее, чем у GRU, однако появилась она почти на двадцать лет раньше.
9.2.1 Ячейка памяти с вентилями
Архитектура LSTM, по-видимому, вдохновлена логическими вентилями компьютеров. LSTM вводит ячейку памяти (memory cell), или просто ячейку (cell). Некоторые авторы считают ячейку памяти особым видом скрытого состояния: у неё та же форма, что и у скрытого состояния, но предназначена она для хранения дополнительной информации. Чтобы управлять ячейкой памяти, нужно несколько вентилей. Один из них выводит содержимое ячейки наружу — это выходной вентиль (output gate). Другой решает, когда данные записываются в ячейку, — это входной вентиль (input gate). Нам также нужен механизм, который сбрасывает содержимое ячейки; им управляет вентиль забывания (forget gate). Мотивация та же, что и у управляемого рекуррентного блока: специальный механизм решает, когда запоминать входные данные или игнорировать их. Посмотрим, как это работает на практике.
9.2.1.1 Входной вентиль, вентиль забывания и выходной вентиль
Как и в управляемом рекуррентном блоке, входом для вентилей служат входные данные текущего временного шага и скрытое состояние предыдущего шага (рис. 9.2.1). Эти данные обрабатываются тремя полносвязными слоями с сигмоидной функцией активации, которые вычисляют значения входного вентиля, вентиля забывания и выходного вентиля. Поэтому значения всех трёх вентилей лежат в интервале .
Рис. 9.2.1. Входной вентиль, вентиль забывания и выходной вентиль в модели долгой краткосрочной памяти
Уточним математическую запись LSTM. Пусть есть скрытых элементов, размер батча равен , а число входов — . Тогда вход — это , а скрытое состояние предыдущего шага — . Соответственно, вентили на временном шаге определяются так: входной вентиль — , вентиль забывания — , выходной вентиль — . Их значения вычисляются по формулам:
где и — весовые параметры, а — параметры смещения.
9.2.1.2 Кандидат ячейки памяти
Поскольку работа вентилей пока не определена, сначала введём кандидат ячейки памяти (candidate memory cell) . Его вычисление похоже на вычисление трёх вентилей, но в качестве функции активации используется , и значения лежат в интервале . Уравнение на временном шаге имеет вид:
где и — весовые параметры, а — параметр смещения.
Кандидат ячейки памяти показан на рис. 9.2.2.
Рис. 9.2.2. Кандидат ячейки памяти в модели долгой краткосрочной памяти
9.2.1.3 Ячейка памяти
В управляемом рекуррентном блоке есть механизм, который контролирует запись новых данных и забывание старых. В LSTM есть похожая пара вентилей: входной вентиль определяет, сколько новых данных из нужно учесть, а вентиль забывания — сколько содержимого прошлой ячейки памяти сохранить. С помощью поэлементного умножения получаем:
Если вентиль забывания всегда равен , а входной вентиль — , то прошлая ячейка памяти сохраняется во времени и передаётся на текущий временной шаг. Такая конструкция введена, чтобы смягчить проблему затухания градиентов и лучше улавливать зависимости на больших расстояниях в последовательности.
Схема вычисления ячейки памяти показана на рис. 9.2.3.
Рис. 9.2.3. Вычисление ячейки памяти в модели долгой краткосрочной памяти
9.2.1.4 Скрытое состояние
Наконец, нужно определить, как вычисляется скрытое состояние . Здесь вступает в действие выходной вентиль. В LSTM скрытое состояние — это управляемая версия от ячейки памяти. Это гарантирует, что значения всегда лежат в интервале :
Когда выходной вентиль близок к , вся информация из памяти передаётся в блок предсказаний. Когда же выходной вентиль близок к , вся информация остаётся внутри ячейки памяти, а скрытое состояние не обновляется.
На рис. 9.2.4 показана схема движения данных.
Рис. 9.2.4. Вычисление скрытого состояния в модели долгой краткосрочной памяти
9.2.2 Реализация с нуля
Теперь реализуем LSTM с нуля. Как и в эксперименте из раздела 8.5, сначала загрузим набор данных «Машина времени»:
MXNet:
from d2l import mxnet as d2l
from mxnet import np, npx
from mxnet.gluon import rnn
npx.set_np()
batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
PyTorch:
from d2l import torch as d2l
import torch
from torch import nn
batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
TensorFlow:
from d2l import tensorflow as d2l
import tensorflow as tf
batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
PaddlePaddle:
from d2l import paddle as d2l
import warnings
warnings.filterwarnings("ignore")
import paddle
from paddle import nn
import paddle.nn.functional as Function
batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
9.2.2.1 Инициализация параметров модели
Далее определим и инициализируем параметры модели. Как и раньше, гиперпараметр num_hiddens задаёт число скрытых элементов. Веса инициализируем из нормального распределения со стандартным отклонением , а параметры смещения полагаем равными .
MXNet:
def get_lstm_params(vocab_size, num_hiddens, device):
num_inputs = num_outputs = vocab_size
def normal(shape):
return np.random.normal(scale=0.01, size=shape, ctx=device)
def three():
return (normal((num_inputs, num_hiddens)),
normal((num_hiddens, num_hiddens)),
np.zeros(num_hiddens, ctx=device))
W_xi, W_hi, b_i = three() # Параметры входного вентиля
W_xf, W_hf, b_f = three() # Параметры вентиля забывания
W_xo, W_ho, b_o = three() # Параметры выходного вентиля
W_xc, W_hc, b_c = three() # Параметры кандидата ячейки памяти
# Параметры выходного слоя
W_hq = normal((num_hiddens, num_outputs))
b_q = np.zeros(num_outputs, ctx=device)
# Прикрепляем градиенты
params = [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc,
b_c, W_hq, b_q]
for param in params:
param.attach_grad()
return params
PyTorch:
def get_lstm_params(vocab_size, num_hiddens, device):
num_inputs = num_outputs = vocab_size
def normal(shape):
return torch.randn(size=shape, device=device)*0.01
def three():
return (normal((num_inputs, num_hiddens)),
normal((num_hiddens, num_hiddens)),
d2l.zeros(num_hiddens, device=device))
W_xi, W_hi, b_i = three() # Параметры входного вентиля
W_xf, W_hf, b_f = three() # Параметры вентиля забывания
W_xo, W_ho, b_o = three() # Параметры выходного вентиля
W_xc, W_hc, b_c = three() # Параметры кандидата ячейки памяти
# Параметры выходного слоя
W_hq = normal((num_hiddens, num_outputs))
b_q = d2l.zeros(num_outputs, device=device)
# Прикрепляем градиенты
params = [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc,
b_c, W_hq, b_q]
for param in params:
param.requires_grad_(True)
return params
TensorFlow:
def get_lstm_params(vocab_size, num_hiddens):
num_inputs = num_outputs = vocab_size
def normal(shape):
return tf.Variable(tf.random.normal(shape=shape, stddev=0.01,
mean=0, dtype=tf.float32))
def three():
return (normal((num_inputs, num_hiddens)),
normal((num_hiddens, num_hiddens)),
tf.Variable(tf.zeros(num_hiddens), dtype=tf.float32))
W_xi, W_hi, b_i = three() # Параметры входного вентиля
W_xf, W_hf, b_f = three() # Параметры вентиля забывания
W_xo, W_ho, b_o = three() # Параметры выходного вентиля
W_xc, W_hc, b_c = three() # Параметры кандидата ячейки памяти
# Параметры выходного слоя
W_hq = normal((num_hiddens, num_outputs))
b_q = tf.Variable(tf.zeros(num_outputs), dtype=tf.float32)
# Прикрепляем градиенты
params = [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc,
b_c, W_hq, b_q]
return params
PaddlePaddle:
def get_lstm_params(vocab_size, num_hiddens):
num_inputs = num_outputs = vocab_size
def normal(shape):
return paddle.randn(shape=shape)*0.01
def three():
return (normal((num_inputs, num_hiddens)),
normal((num_hiddens, num_hiddens)),
d2l.zeros([num_hiddens]))
W_xi, W_hi, b_i = three() # Параметры входного вентиля
W_xf, W_hf, b_f = three() # Параметры вентиля забывания
W_xo, W_ho, b_o = three() # Параметры выходного вентиля
W_xc, W_hc, b_c = three() # Параметры кандидата ячейки памяти
# Параметры выходного слоя
W_hq = normal((num_hiddens, num_outputs))
b_q = d2l.zeros([num_outputs])
# Прикрепляем градиенты
params = [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc,
b_c, W_hq, b_q]
for param in params:
param.stop_gradient = False
return params
9.2.2.2 Определение модели
В функции инициализации состояние LSTM должно возвращать дополнительную ячейку памяти: её значения равны 0, а форма — (размер батча, число скрытых элементов). Поэтому инициализация состояния выглядит так.
MXNet:
def init_lstm_state(batch_size, num_hiddens, device):
return (np.zeros((batch_size, num_hiddens), ctx=device),
np.zeros((batch_size, num_hiddens), ctx=device))
PyTorch:
def init_lstm_state(batch_size, num_hiddens, device):
return (torch.zeros((batch_size, num_hiddens), device=device),
torch.zeros((batch_size, num_hiddens), device=device))
TensorFlow:
def init_lstm_state(batch_size, num_hiddens):
return (tf.zeros(shape=(batch_size, num_hiddens)),
tf.zeros(shape=(batch_size, num_hiddens)))
PaddlePaddle:
def init_lstm_state(batch_size, num_hiddens):
return (paddle.zeros([batch_size, num_hiddens]),
paddle.zeros([batch_size, num_hiddens]))
Сама модель определена так, как мы обсуждали выше: три вентиля и дополнительная ячейка памяти. Обратите внимание, что в выходной слой передаётся только скрытое состояние, а ячейка памяти напрямую в вычислении выхода не участвует.
MXNet:
def lstm(inputs, state, params):
[W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c,
W_hq, b_q] = params
(H, C) = state
outputs = []
for X in inputs:
I = npx.sigmoid(np.dot(X, W_xi) + np.dot(H, W_hi) + b_i)
F = npx.sigmoid(np.dot(X, W_xf) + np.dot(H, W_hf) + b_f)
O = npx.sigmoid(np.dot(X, W_xo) + np.dot(H, W_ho) + b_o)
C_tilda = np.tanh(np.dot(X, W_xc) + np.dot(H, W_hc) + b_c)
C = F * C + I * C_tilda
H = O * np.tanh(C)
Y = np.dot(H, W_hq) + b_q
outputs.append(Y)
return np.concatenate(outputs, axis=0), (H, C)
PyTorch:
def lstm(inputs, state, params):
[W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c,
W_hq, b_q] = params
(H, C) = state
outputs = []
for X in inputs:
I = torch.sigmoid((X @ W_xi) + (H @ W_hi) + b_i)
F = torch.sigmoid((X @ W_xf) + (H @ W_hf) + b_f)
O = torch.sigmoid((X @ W_xo) + (H @ W_ho) + b_o)
C_tilda = torch.tanh((X @ W_xc) + (H @ W_hc) + b_c)
C = F * C + I * C_tilda
H = O * torch.tanh(C)
Y = (H @ W_hq) + b_q
outputs.append(Y)
return torch.cat(outputs, dim=0), (H, C)
TensorFlow:
def lstm(inputs, state, params):
W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c, W_hq, b_q = params
(H, C) = state
outputs = []
for X in inputs:
X=tf.reshape(X,[-1,W_xi.shape[0]])
I = tf.sigmoid(tf.matmul(X, W_xi) + tf.matmul(H, W_hi) + b_i)
F = tf.sigmoid(tf.matmul(X, W_xf) + tf.matmul(H, W_hf) + b_f)
O = tf.sigmoid(tf.matmul(X, W_xo) + tf.matmul(H, W_ho) + b_o)
C_tilda = tf.tanh(tf.matmul(X, W_xc) + tf.matmul(H, W_hc) + b_c)
C = F * C + I * C_tilda
H = O * tf.tanh(C)
Y = tf.matmul(H, W_hq) + b_q
outputs.append(Y)
return tf.concat(outputs, axis=0), (H,C)
PaddlePaddle:
def lstm(inputs, state, params):
[W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c,
W_hq, b_q] = params
(H, C) = state
outputs = []
for X in inputs:
I = Function.sigmoid((X @ W_xi) + (H @ W_hi) + b_i)
F = Function.sigmoid((X @ W_xf) + (H @ W_hf) + b_f)
O = Function.sigmoid((X @ W_xo) + (H @ W_ho) + b_o)
C_tilda = paddle.tanh((X @ W_xc) + (H @ W_hc) + b_c)
C = F * C + I * C_tilda
H = O * paddle.tanh(C)
Y = (H @ W_hq) + b_q
outputs.append(Y)
return paddle.concat(outputs, axis=0), (H, C)
9.2.2.3 Обучение и предсказание
Обучим LSTM, создав экземпляр класса RNNModelScratch, который мы ввели в разделе 8.5, так же как делали это для управляемого рекуррентного блока в разделе 9.1.
MXNet, PyTorch:
vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu()
num_epochs, lr = 500, 1
model = d2l.RNNModelScratch(len(vocab), num_hiddens, device, get_lstm_params,
init_lstm_state, lstm)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
TensorFlow:
vocab_size, num_hiddens, device_name = len(vocab), 256, d2l.try_gpu()._device_name
num_epochs, lr = 500, 1
strategy = tf.distribute.OneDeviceStrategy(device_name)
with strategy.scope():
model = d2l.RNNModelScratch(len(vocab), num_hiddens, init_lstm_state, lstm, get_lstm_params)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, strategy)
PaddlePaddle:
vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu()
num_epochs, lr = 500, 1.0
model = d2l.RNNModelScratch(len(vocab), num_hiddens, get_lstm_params,
init_lstm_state, lstm)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
9.2.3 Краткая реализация
С помощью высокоуровневого API можно напрямую создать модель LSTM. Высокоуровневый API инкапсулирует все детали конфигурации, описанные выше. Этот код работает значительно быстрее, потому что многие детали обрабатываются скомпилированными операторами, а не кодом на Python.
MXNet:
lstm_layer = rnn.LSTM(num_hiddens)
model = d2l.RNNModel(lstm_layer, len(vocab))
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
PyTorch:
num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens)
model = d2l.RNNModel(lstm_layer, len(vocab))
model = model.to(device)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
TensorFlow:
lstm_cell = tf.keras.layers.LSTMCell(num_hiddens,
kernel_initializer='glorot_uniform')
lstm_layer = tf.keras.layers.RNN(lstm_cell, time_major=True,
return_sequences=True, return_state=True)
device_name = d2l.try_gpu()._device_name
strategy = tf.distribute.OneDeviceStrategy(device_name)
with strategy.scope():
model = d2l.RNNModel(lstm_layer, vocab_size=len(vocab))
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, strategy)
PaddlePaddle:
num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens, time_major=True)
model = d2l.RNNModel(lstm_layer, len(vocab))
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
Долгая краткосрочная память — типичный пример авторегрессионной модели со скрытыми переменными и важным управлением состоянием. За годы было предложено много её вариантов, например многослойные модели, остаточные связи и различные виды регуляризации. Однако из-за длинных зависимостей в последовательностях обучение LSTM и других последовательных моделей, например управляемых рекуррентных блоков, остаётся достаточно затратным. В дальнейших главах мы рассмотрим более совершенные альтернативы, например Transformer.
Резюме
- В долгой краткосрочной памяти три типа вентилей: входной, забывания и выходной.
- Скрытый слой LSTM выдаёт «скрытое состояние» и «ячейку памяти». Только скрытое состояние передаётся в выходной слой, а ячейка памяти остаётся внутренней информацией.
- LSTM помогает смягчить проблемы затухающих и взрывных градиентов.
Упражнения
- Настройте гиперпараметры и проанализируйте их влияние на время работы, перплексию и порядок выходных символов.
- Как изменить модель, чтобы она генерировала слова, а не последовательности символов?
- При заданном размере скрытого слоя сравните вычислительные затраты управляемого рекуррентного блока, LSTM и обычной рекуррентной нейронной сети. Особое внимание уделите затратам на обучение и на вывод.
- Кандидат ячейки памяти уже ограничен интервалом благодаря функции . Зачем скрытому состоянию снова использовать , чтобы выход также оставался в интервале ?
- Реализуйте модель LSTM, которая делает прогнозы по временному ряду, а не по последовательности символов.
MXNet: Обсуждение
PyTorch: Обсуждение
PaddlePaddle: Обсуждение
Литература
- Hochreiter, S., & Schmidhuber, J. (1997). Long short-term memory. Neural Computation, 9(8), 1735–1780.