长短期记忆(LSTM)
本节将介绍另一种常用的门控循环神经网络:长短期记忆(long short-term memory,简称 LSTM)[1]。它比门控循环单元的结构稍微复杂一点。
长短期记忆
LSTM 中引入了三个门:输入门(input gate)、遗忘门(forget gate)和输出门(output gate),以及与隐藏状态形状相同的记忆细胞(某些文献把记忆细胞当成一种特殊的隐藏状态),从而记录额外的信息。
输入门、遗忘门和输出门
同门控循环单元中的重置门和更新门一样,如图 6.7 所示,长短期记忆的门的输入均为当前时间步输入 \(\boldsymbol{X}_t\) 与上一时间步隐藏状态 \(\boldsymbol{H}_{t-1}\),输出由激活函数为 sigmoid 函数的全连接层计算得到。如此一来,这三个门元素的值域均为 \([0,1]\)。
具体来说,假设隐藏单元个数为 \(h\),给定时间步 \(t\) 的小批量输入 \(\boldsymbol{X}_t \in \mathbb{R}^{n \times d}\)(样本数为 \(n\),输入个数为 \(d\))和上一时间步隐藏状态 \(\boldsymbol{H}_{t-1} \in \mathbb{R}^{n \times h}\)。 时间步 \(t\) 的输入门 \(\boldsymbol{I}_t \in \mathbb{R}^{n \times h}\)、遗忘门 \(\boldsymbol{F}_t \in \mathbb{R}^{n \times h}\) 和输出门 \(\boldsymbol{O}_t \in \mathbb{R}^{n \times h}\) 分别计算如下:
\[ \begin{aligned} \boldsymbol{I}_t &= \sigma(\boldsymbol{X}_t \boldsymbol{W}_{xi} + \boldsymbol{H}_{t-1} \boldsymbol{W}_{hi} + \boldsymbol{b}_i),\\ \boldsymbol{F}_t &= \sigma(\boldsymbol{X}_t \boldsymbol{W}_{xf} + \boldsymbol{H}_{t-1} \boldsymbol{W}_{hf} + \boldsymbol{b}_f),\\ \boldsymbol{O}_t &= \sigma(\boldsymbol{X}_t \boldsymbol{W}_{xo} + \boldsymbol{H}_{t-1} \boldsymbol{W}_{ho} + \boldsymbol{b}_o), \end{aligned} \]
其中的 \(\boldsymbol{W}_{xi}, \boldsymbol{W}_{xf}, \boldsymbol{W}_{xo} \in \mathbb{R}^{d \times h}\) 和 \(\boldsymbol{W}_{hi}, \boldsymbol{W}_{hf}, \boldsymbol{W}_{ho} \in \mathbb{R}^{h \times h}\) 是权重参数,\(\boldsymbol{b}_i, \boldsymbol{b}_f, \boldsymbol{b}_o \in \mathbb{R}^{1 \times h}\) 是偏差参数。
候选记忆细胞
接下来,长短期记忆需要计算候选记忆细胞 \(\tilde{\boldsymbol{C}}_t\)。它的计算同上面介绍的三个门类似,但使用了值域在 \([-1, 1]\) 的 tanh 函数做激活函数,如图 6.8 所示。
具体来说,时间步 \(t\) 的候选记忆细胞 \(\tilde{\boldsymbol{C}}_t \in \mathbb{R}^{n \times h}\) 的计算为
\[\tilde{\boldsymbol{C}}_t = \text{tanh}(\boldsymbol{X}_t \boldsymbol{W}_{xc} + \boldsymbol{H}_{t-1} \boldsymbol{W}_{hc} + \boldsymbol{b}_c),\]
其中的 \(\boldsymbol{W}_{xc} \in \mathbb{R}^{d \times h}\) 和 \(\boldsymbol{W}_{hc} \in \mathbb{R}^{h \times h}\) 是权重参数,\(\boldsymbol{b}_c \in \mathbb{R}^{1 \times h}\) 是偏差参数。
记忆细胞
我们可以通过元素值域在 \([0, 1]\) 的输入门、遗忘门和输出门来控制隐藏状态中信息的流动:这一般也是通过使用按元素乘法(符号为 \(\odot\))来实现。当前时间步记忆细胞 \(\boldsymbol{C}_t \in \mathbb{R}^{n \times h}\) 的计算组合了上一时间步记忆细胞和当前时间步候选记忆细胞的信息,并通过遗忘门和输入门来控制信息的流动:
\[\boldsymbol{C}_t = \boldsymbol{F}_t \odot \boldsymbol{C}_{t-1} + \boldsymbol{I}_t \odot \tilde{\boldsymbol{C}}_t.\]
如图 6.9 所示,遗忘门控制上一时间步的记忆细胞 \(\boldsymbol{C}_{t-1}\) 中的信息是否传递到当前时间步,而输入门则可以控制当前时间步的输入 \(\boldsymbol{X}_t\) 通过候选记忆细胞 \(\tilde{\boldsymbol{C}}_t\) 如何流入当前时间步的记忆细胞。如果遗忘门一直近似 1 且输入门一直近似 0,过去的记忆细胞将一直通过时间保存并传递至当前时间步。这个设计可以应对循环神经网络中的梯度衰减问题,并更好地捕捉时间序列中时间步距离较大的依赖关系。
隐藏状态
有了记忆细胞以后,接下来我们还可以通过输出门来控制从记忆细胞到隐藏状态 \(\boldsymbol{H}_t \in \mathbb{R}^{n \times h}\) 的信息的流动:
\[\boldsymbol{H}_t = \boldsymbol{O}_t \odot \text{tanh}(\boldsymbol{C}_t).\]
这里的 tanh 函数确保隐藏状态元素值在 -1 到 1 之间。需要注意的是,当输出门近似 1 时,记忆细胞信息将传递到隐藏状态供输出层使用;当输出门近似 0 时,记忆细胞信息只自己保留。图 6.10 展示了长短期记忆中隐藏状态的计算。
读取数据集
下面我们开始实现并展示长短期记忆。和前几节中的实验一样,我们依然使用周杰伦歌词数据集来训练模型作词。
循环神经网络
本节将介绍循环神经网络。它并非刚性地记忆所有固定长度的序列,而是通过隐藏状态来储存之前时间步的信息。首先我们回忆一下前面介绍过的多层感知机,然后描述如何添加隐藏状态来将它变成循环神经网络。
不含隐藏状态的神经网络
让我们考虑一个单隐藏层的多层感知机。给定样本数为 \(n\)、输入个数(特征数或特征向量维度)为 \(d\) 的小批量数据样本 \(\boldsymbol{X} \in \mathbb{R}^{n \times d}\)。设隐藏层的激活函数为 \(\phi\),那么隐藏层的输出 \(\boldsymbol{H} \in \mathbb{R}^{n \times h}\) 计算为
\[\boldsymbol{H} = \phi(\boldsymbol{X} \boldsymbol{W}_{xh} + \boldsymbol{b}_h),\]
其中隐藏层权重参数 \(\boldsymbol{W}_{xh} \in \mathbb{R}^{d \times h}\),隐藏层偏差参数 \(\boldsymbol{b}_h \in \mathbb{R}^{1 \times h}\),\(h\) 为隐藏单元个数。上式相加的两项形状不同,因此将按照广播机制相加。把隐藏变量 \(\boldsymbol{H}\) 作为输出层的输入,且设输出个数为 \(q\)(例如分类问题中的类别数),输出层的输出为
\[\boldsymbol{O} = \boldsymbol{H} \boldsymbol{W}_{hq} + \boldsymbol{b}_q,\]
其中输出变量 \(\boldsymbol{O} \in \mathbb{R}^{n \times q}\), 输出层权重参数 \(\boldsymbol{W}_{hq} \in \mathbb{R}^{h \times q}\), 输出层偏差参数 \(\boldsymbol{b}_q \in \mathbb{R}^{1 \times q}\)。如果是分类问题,我们可以使用 \(\text{softmax}(\boldsymbol{O})\) 来计算输出类别的概率分布。
含隐藏状态的循环神经网络
现在我们考虑输入数据存在时间相关性的情况。假设 \(\boldsymbol{X}_t \in \mathbb{R}^{n \times d}\) 是序列中时间步 \(t\) 的小批量输入,\(\boldsymbol{H}_t \in \mathbb{R}^{n \times h}\) 是该时间步的隐藏层变量。跟多层感知机不同的是,这里我们保存上一时间步的隐藏变量 \(\boldsymbol{H}_{t-1}\),并引入一个新的权重参数 \(\boldsymbol{W}_{hh} \in \mathbb{R}^{h \times h}\),该参数用来描述在当前时间步如何使用上一时间步的隐藏变量。具体来说,当前时间步的隐藏变量的计算由当前时间步的输入和上一时间步的隐藏变量共同决定:
\[\boldsymbol{H}_t = \phi(\boldsymbol{X}_t \boldsymbol{W}_{xh} + \boldsymbol{H}_{t-1} \boldsymbol{W}_{hh} + \boldsymbol{b}_h).\]
与多层感知机相比,我们在这里添加了 \(\boldsymbol{H}_{t-1} \boldsymbol{W}_{hh}\) 一项。由上式中相邻时间步的隐藏变量 \(\boldsymbol{H}_t\) 和 \(\boldsymbol{H}_{t-1}\) 之间的关系可知,这里的隐藏变量捕捉了截至当前时间步的序列的历史信息,就像是神经网络当前时间步的状态或记忆一样。因此,该隐藏变量也称为隐藏状态。由于隐藏状态在当前时间步的定义使用了它在上一时间步相同的定义,上式的计算是循环的。使用循环计算的网络即循环神经网络。
循环神经网络有很多种不同的构造方法。含上式所定义的隐藏状态的循环神经网络是极为常见的一种。如无特别说明,本章中的循环神经网络基于上式中隐藏状态的循环计算。在时间步 \(t\),输出层的输出和多层感知机中的计算类似:
\[\boldsymbol{O}_t = \boldsymbol{H}_t \boldsymbol{W}_{hq} + \boldsymbol{b}_q.\]
循环神经网络的参数包括隐藏层的权重 \(\boldsymbol{W}_{xh} \in \mathbb{R}^{d \times h}, \boldsymbol{W}_{hh} \in \mathbb{R}^{h \times h}\) 和偏差 \(\boldsymbol{b}_h \in \mathbb{R}^{1 \times h}\),以及输出层的权重 \(\boldsymbol{W}_{hq} \in \mathbb{R}^{h \times q}\) 和偏差 \(\boldsymbol{b}_q \in \mathbb{R}^{1 \times q}\)。值得一提的是,即便在不同时间步,循环神经网络始终使用这些模型参数。因此,循环神经网络模型参数的数量不随时间步的递增而增长。
图 6.1 展示了循环神经网络在三个相邻时间步的计算逻辑。在时间步 \(t\),隐藏状态的计算可以看成是将输入 \(\boldsymbol{X}_t\) 和前一时间步隐藏状态 \(\boldsymbol{H}_{t-1}\) 连结后输入一个激活函数为 \(\phi\) 的全连接层。该全连接层的输出就是当前时间步的隐藏状态 \(\boldsymbol{H}_t\),且模型参数为 \(\boldsymbol{W}_{xh}\) 与 \(\boldsymbol{W}_{hh}\) 的连结,偏差为 \(\boldsymbol{b}_h\)。当前时间步 \(t\) 的隐藏状态 \(\boldsymbol{H}_t\) 将参与下一个时间步 \(t+1\) 的隐藏状态 \(\boldsymbol{H}_{t+1}\) 的计算,并输入到当前时间步的全连接输出层。
我们刚刚提到,隐藏状态中 \(\boldsymbol{X}_t \boldsymbol{W}_{xh} + \boldsymbol{H}_{t-1} \boldsymbol{W}_{hh}\) 的计算等价于 \(\boldsymbol{X}_t\) 与 \(\boldsymbol{H}_{t-1}\) 连结后的矩阵乘以 \(\boldsymbol{W}_{xh}\) 与 \(\boldsymbol{W}_{hh}\) 连结后的矩阵。接下来,我们用一个具体的例子来验证这一点。首先,我们构造矩阵X
、W_xh
、H
和W_hh
,它们的形状分别为(3,1)、(1,4)、(3,2)和(2,4)。将X
与W_xh
、H
与W_hh
分别相乘,再把两个相乘的结果相加,得到形状为(3,4)的矩阵。
1 | from mxnet import nd, autograd |
读取数据集
1 | with zipfile.ZipFile("jaychou_lyrics.txt.zip") as zin: |
1 | corpus_chars = corpus_chars.replace('\n',' ').replace('\r',' ') |
'想要有直升机 想要和你飞到宇宙去 想要和你融化在一起 融化在宇宙里 我每天每天每'
建立字符索引
1 | index_to_char = list(set(corpus_chars)) |
1027
1 | indices_corpus = [char_to_index[char] for char in corpus_chars] |
chars: 想要有直升机 想要和你飞到宇宙去 想要和
index: [824, 97, 659, 481, 263, 293, 559, 824, 97, 107, 87, 364, 893, 291, 847, 206, 559, 824, 97, 107]
时序数据的采样
- 随机采样
- 相邻采样
随机采样
- 批量大小 batch_size指每个小批量的样本数,num_steps为每个样本所包含的时间步数。
- 在随机采样中,每个样本是原始序列上任意截 取的一段序列。相邻的两个随机小批量在原始序列上的位置不一定相毗邻。因此,我们无法用一 个小批量最终时间步的隐藏状态来初始化下一个小批量的隐藏状态。
- 在训练模型时,每次随机采样前都需要重新初始化隐藏状态。
1 | def data_iter_random(indices_corpus, batch_size, num_steps, ctx=None): |
1 | my_seq = list(range(30)) |
X:
[[12. 13. 14. 15. 16. 17.]
[ 0. 1. 2. 3. 4. 5.]]
<NDArray 2x6 @cpu(0)>
Y:
[[13. 14. 15. 16. 17. 18.]
[ 1. 2. 3. 4. 5. 6.]]
<NDArray 2x6 @cpu(0)>
X:
[[18. 19. 20. 21. 22. 23.]
[ 6. 7. 8. 9. 10. 11.]]
<NDArray 2x6 @cpu(0)>
Y:
[[19. 20. 21. 22. 23. 24.]
[ 7. 8. 9. 10. 11. 12.]]
<NDArray 2x6 @cpu(0)>
相邻采样
除了对原始序列做随机采样之外,我们还可以令相邻的两个随机小批量在原始序列上的位置相毗邻。此时,我们就可以用一个小批量最终时间步的隐藏状态来初始化下一个小批量的隐藏状态, 从而使下一个小批量的输出也取决于当前小批量的输入,并如此循环下去。这对实现循环神经网络造成了两方面影响。一方面,在训练模型时,我们只需在每一个迭代周期开始时初始化隐藏状态。另一方面,当多个相邻小批量通过传递隐藏状态串联起来时,模型参数的梯度计算将依赖所有串联起来的小批量序列。同一迭代周期中,随着迭代次数的增加,梯度的计算开销会越来越大。 为了使模型参数的梯度计算只依赖一次迭代读取的小批量序列,我们可以在每次读取小批量前将隐藏状态从计算图分离出来
1 | def data_iter_consecutive(corpus_indices, batch_size, num_steps, ctx=None): |
1 | for X, Y in data_iter_consecutive(my_seq, batch_size=2, num_steps=6): |
X:
[[ 0. 1. 2. 3. 4. 5.]
[15. 16. 17. 18. 19. 20.]]
<NDArray 2x6 @cpu(0)>
Y:
[[ 1. 2. 3. 4. 5. 6.]
[16. 17. 18. 19. 20. 21.]]
<NDArray 2x6 @cpu(0)>
X:
[[ 6. 7. 8. 9. 10. 11.]
[21. 22. 23. 24. 25. 26.]]
<NDArray 2x6 @cpu(0)>
Y:
[[ 7. 8. 9. 10. 11. 12.]
[22. 23. 24. 25. 26. 27.]]
<NDArray 2x6 @cpu(0)>
循环神经网络的从零开始实现
One hot encoding
1 | def to_onehot(X,size): |
(5, (2, 1027))
初始化模型参数
- num_hiddens 是hidden cell的差参数
1 | num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size |
定义模型
1 | def init_lstm_state(batch_size, num_hiddens, ctx): |
1 | state = init_lstm_state(X.shape[0], num_hiddens, ctx = None) |
1 | len(outputs), outputs[0].shape, state_new[0].shape |
(5, (2, 1027), (2, 256))
定义预测函数
以下函数基于前缀 prefix(含有数个字符的字符串)来预测接下来的 num_chars个字符
1 | def predict_lstm(prefix, num_chars, rnn, params, init_lstm_state, |
1 | predict_lstm('分开', 10, lstm, params, init_lstm_state, num_hiddens, vocab_size, ctx, index_to_char, char_to_index) |
'分开蟑榜沉篇来他已密明漠'
梯度剪裁
1 | def grad_clipping(params, theta, ctx = None): |
梯度下降
1 | def sgd(params, lr, batch_size): # 本函数已保存在 gluonbook 包中方便以后使用。 |
整合
1 | def train_and_predict_rnn(lstm, get_params, init_lstm_state, num_hiddens, |
1 | num_epochs, num_steps, batch_size, lr, clipping_theta = 200, 35, 32, 1e2, 1e-2 |
epoch 50, perplexity 163.030127, time 3.16 sec
- 分开 我想你你你的可爱人 我想想你你你的可爱 我想想你你你的可爱 我想想你你你的可爱 我想想你你你的可爱
- 不分开 我想你你你的可爱人 我想想你你你的可爱 我想想你你你的可爱 我想想你你你的可爱 我想想你你你的可爱
epoch 100, perplexity 32.180855, time 3.49 sec
- 分开 我想你这你是我 一场我 你给我的太笑 有有后觉 我给了这生我 我知好觉 我该了好生活 我知后觉 我
- 不分开 我想你的你笑我妈妈 我想你的你 我不要这想 我不 我不 我不要 我爱 我不了 你不不觉 我爱了觉
epoch 150, perplexity 5.679034, time 3.21 sec
- 分开 你在我不里 除非一乌截棍 它什么 干什么 我想都有难信 干檐是人 是谁在在切记 哼哼哈兮 快使用双
- 不分开 我已你的个笑 我爱你 你爱我的我有你 败一抖的黑有 说你的让我有听的可有人人 我有你这里单美但家
epoch 200, perplexity 1.841203, time 3.22 sec
- 分开 一直我 是场于枪年代白墙黑瓦的淡淡的忧伤 消失的 旧时光 一九四三 在头看 的片段 有一些风霜 老
- 不分开 我已经 我开我 我想了大牵牵着你 不开开心不你 让不不 你经开 我想就这牵牵着你的手不放开 爱可不
1 |