动手学深度学习 6.8 长短期记忆(LSTM)

前言

从零开始学习ai文章系列计划是个人在《动手学深度学习》和《磨菇书》两本书的学习中的个人笔记,文章也会以课本中的章节分开,即每个章节一片笔记。我会尽量的把主要内容以及遇到的难点进行记录与解决,如果哪里有错误的欢迎指正。或者不清晰的可以直接查看原文部分。

《动手学深度学习》原文(课本):https://tangshusen.me/Dive-into-DL-PyTorch/#/

《动手学深度学习》代码:https://github.com/ShusenTang/Dive-into-DL-PyTorch

(由于有时候公式太多,可能会直接贴图片)


本节将介绍另一种常用的门控循环神经网络:长短期记忆(long short-term memory,LSTM)[1]。它比门控循环单元的结构稍微复杂一点。

1. 长短期记忆

LSTM 中引入了3个门,即输入门(input gate)遗忘门(forget gate)输出门(output gate),以及与隐藏状态形状相同的记忆细胞(某些文献把记忆细胞当成一种特殊的隐藏状态),从而记录额外的信息。

1.1 输入门、遗忘门和输出门

与门控循环单元中的重置门和更新门一样,如图6.7所示,长短期记忆的门的输入均为当前时间步输入 \(X_t\) 与上一时间步隐藏状态 \(H_{t−1}\) ,输出由激活函数为sigmoid函数的全连接层计算得到。如此一来,这3个门元素的值域均为[0,1]。

具体来说,假设隐藏单元个数为h,给定时间步t的小批量输入 \(X_t \in \mathbb{R}^{n\times d}\) (样本数为n,输入个数为d)和上一时间步隐藏状态 \(H_{t-1} \in \mathbb{R}^{n\times h}\) 。 时间步t的输入门 \(I_{t} \in \mathbb{R}^{n\times h}\) 、 遗忘门 \(F_{t} \in \mathbb{R}^{n\times h}\) 和输出门 \(O_{t} \in \mathbb{R}^{n\times h}\) 分别计算如下:

其中 \(W_{xi},W_{xf},W_{xo} \in \mathbb{R}^{d \times h}\)\(W_{hi},W_{hf},W_{ho} \in \mathbb{R}^{h \times h}\) 是权重参数, \(b_{i},b_{f},b_{o} \in \mathbb{R}^{1 \times h}\) 是偏差参数。

1.2 候选记忆细胞

接下来,长短期记忆需要计算候选记忆细胞 \(\tilde{C_t}\) 。它的计算与上面介绍的3个门类似,但使用了值域在[−1,1]的tanh函数作为激活函数,如图6.8所示。

具体来说,时间步t的候选记忆细胞 \(\tilde{C_t} \in \mathbb{R}^{n\times h}\) 的计算为

其中 \(W_{xc} \in \mathbb{R}^{d \times h}\)\(W_{hc} \in \mathbb{R}^{h \times h}\) 是权重参数, \(b_{c} \in \mathbb{R}^{1 \times h}\) 是偏差参数。

与普通rnn里的 \(H_t\) 更新相似:

1.3 记忆细胞

我们可以通过元素值域在[0,1]的输入门、遗忘门和输出门来控制隐藏状态中信息的流动,这一般也是通过使用按元素乘法(符号为⊙)来实现的。当前时间步记忆细胞 \(C_t \in \mathbb{R}^{n\times h}\) 的计算组合了上一时间步记忆细胞和当前时间步候选记忆细胞的信息,并通过遗忘门和输入门来控制信息的流动:

如图6.9所示,遗忘门控制上一时间步的记忆细胞 \(C_{t-1}\) 中的信息是否传递到当前时间步,而输入门则控制当前时间步的输入 \(X_t\) 通过候选记忆细胞 \(\tilde{C_t}\) 如何流入当前时间步的记忆细胞。

如果遗忘门一直近似1且输入门一直近似0,过去的记忆细胞将一直通过时间保存并传递至当前时间步。这个设计可以应对循环神经网络中的梯度衰减问题,并更好地捕捉时间序列中时间步距离较大的依赖关系。

1.4 隐藏状态

有了记忆细胞以后,接下来我们还可以通过输出门来控制从记忆细胞到隐藏状态 \(H_t \in \mathbb{R}^{n\times h}\) 的信息的流动:

这里的tanh函数确保隐藏状态元素值在-1到1之间。需要注意的是,

当输出门近似1时,记忆细胞信息将传递到隐藏状态供输出层使用;

当输出门近似0时,记忆细胞信息只自己保留。图6.10展示了长短期记忆中隐藏状态的计算。

正常rnn的输出层计算如下图:

完整LSTM过程如下图

2. 小结

我们已经讲完了LSTM的理论内容。接下来我们要简单说明LSTM的设计以及与GRU之间的区别。

在LSTM 里, \(C_t\) 是“专门的记忆仓库”,而且是“加进去”的

① 旧记忆是乘法保留 (f_t * C_{t-1})

\(F_t\) ≈ 1 时,旧记忆几乎“完整保留”

\(F_t\) ≈ 0 时,旧记忆被忘掉

② 新记忆是用加法叠上去的 (+ \(I_t\) * 新内容)

加法不会缩小之前的值,只是在原来的记忆“上面加一层”。

意思是:LSTM 的记忆会一层层往上叠,像盖房子一样,一层层累加,不会被覆盖掉。

再对比 GRU

GRU 的旧记忆永远要被 \(z_t\) 乘一下 (如果想更新的话,这个值就不可能趋于1)

这就像每天上班路上:

• 你拿着一个文件袋(=旧记忆)

• 每次过安检(=网络层)时,都必须把文件袋压缩一点(乘 \(z\)

• 久而久之,文件就越来越薄,直到什么都不剩

举例:

如果 z=0.9,看起来很大,但 0.9^50 ≈ 0.005。即经过 50 步后,记忆只剩 0.5%。

这就是“信息容易被稀释掉”的本质。

总的来说,LSTM中,对于长期记忆的更新,每次只需要加上当前信息即可。

而对于GRU来说,更新记忆,必须要通过乘法稀释过去记忆。

倘若我们将GRU中的重置门和更新门去除,用遗忘门和输入门替换源码的更新门会怎样呢。

很明显这实际上解决了我们旧的GRU学东西必须丢东西的问题。

但是其更新公式相对于原本LSTM来说。求导多了一个附加项。

首先查看LSTM中的求导

改进版GRU更新公式如图:

虽然我想试图理解这种变化会造成什么影响,从而无法达到LSTM的效果。但是还是没能找到决定性的结果。

只能从个人感觉上来解释:每一步的求导所需处理内容更多了,导致训练更不稳定,训练更难,导致效果更差。

3. 读取数据集

下面我们开始实现并展示长短期记忆。和前几节中的实验一样,这里依然使用周杰伦歌词数据集来训练模型作词。

1
2
3
4
5
6
7
8
9
10
11
import numpy as np
import torch
from torch import nn, optim
import torch.nn.functional as F

import sys
sys.path.append("..")
import d2lzh_pytorch as d2l
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

(corpus_indices, char_to_idx, idx_to_char, vocab_size) = d2l.load_data_jay_lyrics()

4. 从零开始实现

我们先介绍如何从零开始实现长短期记忆。

4.1 初始化模型参数

下面的代码对模型参数进行初始化。超参数num_hiddens定义了隐藏单元的个数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size

def get_params():
def _one(shape):
ts = torch.tensor(np.random.normal(0, 0.01, size=shape), device=device, dtype=torch.float32)
return torch.nn.Parameter(ts, requires_grad=True)
def _three():
return (_one((num_inputs, num_hiddens)),
_one((num_hiddens, num_hiddens)),
torch.nn.Parameter(torch.zeros(num_hiddens, device=device, dtype=torch.float32), requires_grad=True))

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 = _one((num_hiddens, num_outputs))
b_q = torch.nn.Parameter(torch.zeros(num_outputs, device=device, dtype=torch.float32), requires_grad=True)
return nn.ParameterList([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])

5. 定义模型

初始化函数中,长短期记忆的隐藏状态需要返回额外的形状为 (批量大小, 隐藏单元个数) 的值为0的记忆细胞。

1
2
3
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))

下面根据长短期记忆的计算表达式定义模型。需要注意的是,只有隐藏状态会传递到输出层,而记忆细胞不参与输出层的计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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(torch.matmul(X, W_xi) + torch.matmul(H, W_hi) + b_i)
F = torch.sigmoid(torch.matmul(X, W_xf) + torch.matmul(H, W_hf) + b_f)
O = torch.sigmoid(torch.matmul(X, W_xo) + torch.matmul(H, W_ho) + b_o)
C_tilda = torch.tanh(torch.matmul(X, W_xc) + torch.matmul(H, W_hc) + b_c)
C = F * C + I * C_tilda
H = O * C.tanh()
Y = torch.matmul(H, W_hq) + b_q
outputs.append(Y)
return outputs, (H, C)

从上一节GRU我们注意到我们的网络并未使用nn的模板,而是直接定义了parameter参数,然后进行计算输出结果,这些参数能正常进行求导,从而获得偏导数。

5.1 训练模型并创作歌词

同上一节一样,我们在训练模型时只使用相邻采样。设置好超参数后,我们将训练模型并根据前缀“分开”和“不分开”分别创作长度为50个字符的一段歌词。

1
2
3

num_epochs, num_steps, batch_size, lr, clipping_theta = 160, 35, 32, 1e2, 1e-2
pred_period, pred_len, prefixes = 40, 50, ['分开', '不分开']

我们每过40个迭代周期便根据当前训练的模型创作一段歌词。

1
2
3
4
5
d2l.train_and_predict_rnn(lstm, get_params, init_lstm_state, num_hiddens,
vocab_size, device, corpus_indices, idx_to_char,
char_to_idx, False, num_epochs, num_steps, lr,
clipping_theta, batch_size, pred_period, pred_len,
prefixes)

6. 简洁实现

我们可以直接调用rnn模块中的LSTM类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import numpy as np
import torch
from torch import nn, optim
import torch.nn.functional as F

import sys
sys.path.append("..")
import d2lzh_pytorch as d2l
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')


(corpus_indices, char_to_idx, idx_to_char, vocab_size) = d2l.load_data_jay_lyrics()

num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size

num_epochs, num_steps, batch_size, clipping_theta = 160, 35, 32, 1e-2
pred_period, pred_len, prefixes = 40, 50, ['分开', '不分开']

lr = 1e-2 # 注意调整学习率
lstm_layer = nn.LSTM(input_size=vocab_size, hidden_size=num_hiddens)
model = d2l.RNNModel(lstm_layer, vocab_size)
d2l.train_and_predict_rnn_pytorch(model, num_hiddens, vocab_size, device,
corpus_indices, idx_to_char, char_to_idx,
num_epochs, num_steps, lr, clipping_theta,
batch_size, pred_period, pred_len, prefixes)