门控循环单元(GRU)
上一节介绍了循环神经网络中的梯度计算方法。我们发现,当时间步数较大或者时间步较小时,循环神经网络的梯度较容易出现衰减或爆炸。虽然裁剪梯度可以应对梯度爆炸,但无法解决梯度衰减的问题。通常由于这个原因,循环神经网络在实际中较难捕捉时间序列中时间步距离较大的依赖关系。
门控循环神经网络(gated recurrent neural network)的提出,正是为了更好地捕捉时间序列中时间步距离较大的依赖关系。它通过可以学习的门来控制信息的流动。其中,门控循环单元(gated recurrent unit,简称 GRU)是一种常用的门控循环神经网络 [1, 2]。另一种常见门控循环神经网络则将在下一节中介绍。
门控循环单元
下面将介绍门控循环单元的设计。它引入了重置门和更新门的概念,从而修改了循环神经网络中隐藏状态的计算方式。
重置门和更新门
如图 6.4 所示,门控循环单元中的重置门(reset gate)和更新门(update gate)的输入均为当前时间步输入 \(\boldsymbol{X}_t\) 与上一时间步隐藏状态 \(\boldsymbol{H}_{t-1}\),输出由激活函数为 sigmoid 函数的全连接层计算得到。
具体来说,假设隐藏单元个数为 \(h\),给定时间步 \(t\) 的小批量输入 \(\boldsymbol{X}\_t \in \mathbb{R}^{n \times d}\)(样本数为 \(n\),输入个数为 \(d\))和上一时间步隐藏状态 \(\boldsymbol{H}\_{t-1} \in \mathbb{R}^{n \times h}\)。重置门 \(\boldsymbol{R}\_t \in \mathbb{R}^{n \times h}\) 和更新门 \(\boldsymbol{Z}\_t \in \mathbb{R}^{n \times h}\) 的计算如下:
\[ \begin{aligned} \boldsymbol{R}\_t = \sigma(\boldsymbol{X}\_t \boldsymbol{W}\_{xr} + \boldsymbol{H}\_{t-1} \boldsymbol{W}\_{hr} + \boldsymbol{b}\_r),\\ \boldsymbol{Z}\_t = \sigma(\boldsymbol{X}\_t \boldsymbol{W}\_{xz} + \boldsymbol{H}\_{t-1} \boldsymbol{W}\_{hz} + \boldsymbol{b}\_z), \end{aligned} \]
其中 \(\boldsymbol{W}\_{xr}, \boldsymbol{W}\_{xz} \in \mathbb{R}^{d \times h}\) 和 \(\boldsymbol{W}\_{hr}, \boldsymbol{W}\_{hz} \in \mathbb{R}^{h \times h}\) 是权重参数,\(\boldsymbol{b}\_r, \boldsymbol{b}\_z \in \mathbb{R}^{1 \times h}\) 是偏差参数。“多层感知机”一节中介绍过,sigmoid 函数可以将元素的值变换到 0 和 1 之间。因此,重置门 \(\boldsymbol{R}\_t\) 和更新门 \(\boldsymbol{Z}\_t\) 中每个元素的值域都是 \([0, 1]\)。
候选隐藏状态
接下来,门控循环单元将计算候选隐藏状态来辅助稍后的隐藏状态计算。如图 6.5 所示,我们将当前时间步重置门的输出与上一时间步隐藏状态做按元素乘法(符号为 \(\odot\))。如果重置门中元素值接近 0,那么意味着重置对应隐藏状态元素为 0,即丢弃上一时间步的隐藏状态。如果元素值接近 1,那么表示保留上一时间步的隐藏状态。然后,将按元素乘法的结果与当前时间步的输入连结,再通过含激活函数 tanh 的全连接层计算出候选隐藏状态,其所有元素的值域为 \([-1, 1]\)。
具体来说,时间步 \(t\) 的候选隐藏状态 \(\tilde{\boldsymbol{H}}\_t \in \mathbb{R}^{n \times h}\) 的计算为
\[\tilde{\boldsymbol{H}}\_t = \text{tanh}(\boldsymbol{X}\_t \boldsymbol{W}\_{xh} + \left(\boldsymbol{R}\_t \odot \boldsymbol{H}\_{t-1}\right) \boldsymbol{W}\_{hh} + \boldsymbol{b}\_h),\]
其中 \(\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}\) 是偏差参数。从上面这个公式可以看出,重置门控制了上一时间步的隐藏状态如何流入当前时间步的候选隐藏状态。而上一时间步的隐藏状态可能包含了时间序列截至上一时间步的全部历史信息。因此,重置门可以用来丢弃与预测无关的历史信息。
隐藏状态
最后,时间步 \(t\) 的隐藏状态 \(\boldsymbol{H}\_t \in \mathbb{R}^{n \times h}\) 的计算使用当前时间步的更新门 \(\boldsymbol{Z}\_t\) 来对上一时间步的隐藏状态 \(\boldsymbol{H}\_{t-1}\) 和当前时间步的候选隐藏状态 \(\tilde{\boldsymbol{H}}\_t\) 做组合:
\[\boldsymbol{H}\_t = \boldsymbol{Z}\_t \odot \boldsymbol{H}\_{t-1} + (1 - \boldsymbol{Z}\_t) \odot \tilde{\boldsymbol{H}}\_t.\]
值得注意的是,更新门可以控制隐藏状态应该如何被包含当前时间步信息的候选隐藏状态所更新,如图 6.6 所示。假设更新门在时间步 \(t'\) 到 \(t\)(\(t' < t\))之间一直近似 1。那么,在时间步 \(t'\) 到 \(t\) 之间的输入信息几乎没有流入时间步 \(t\) 的隐藏状态 \(\boldsymbol{H}_t\)。实际上,这可以看作是较早时刻的隐藏状态 \(\boldsymbol{H}_{t'-1}\) 一直通过时间保存并传递至当前时间步 \(t\)。这个设计可以应对循环神经网络中的梯度衰减问题,并更好地捕捉时间序列中时间步距离较大的依赖关系。
我们对门控循环单元的设计稍作总结:
- 重置门有助于捕捉时间序列里短期的依赖关系。
- 更新门有助于捕捉时间序列里长期的依赖关系。
读取数据集
为了实现并展示门控循环单元,我们依然使用周杰伦歌词数据集来训练模型作词。这里除门控循环单元以外的实现已在“循环神经网络”一节中介绍。以下为读取数据集部分。
循环神经网络
本节将介绍循环神经网络。它并非刚性地记忆所有固定长度的序列,而是通过隐藏状态来储存之前时间步的信息。首先我们回忆一下前面介绍过的多层感知机,然后描述如何添加隐藏状态来将它变成循环神经网络。
不含隐藏状态的神经网络
让我们考虑一个单隐藏层的多层感知机。给定样本数为 \(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: [411, 880, 895, 556, 110, 829, 374, 411, 880, 525, 567, 837, 551, 220, 203, 258, 374, 411, 880, 525]
时序数据的采样
- 随机采样
- 相邻采样
随机采样
- 批量大小 batch_size指每个小批量的样本数,num_steps为每个样本所包含的时间步数。
- 在随机采样中,每个样本是原始序列上任意截 取的一段序列。相邻的两个随机小批量在原始序列上的位置不一定相毗邻。因此,我们无法用一 个小批量最终时间步的隐藏状态来初始化下一个小批量的隐藏状态。
- 在训练模型时,每次随机采样前都需要重新初始化隐藏状态。
1 | def data_iter_random(indices_corpus, batch_size, num_steps, ctx=None): |
1 | my_seq = list(range(30)) |
X:
[[ 0. 1. 2. 3. 4. 5.]
[12. 13. 14. 15. 16. 17.]]
<NDArray 2x6 @cpu(0)>
Y:
[[ 1. 2. 3. 4. 5. 6.]
[13. 14. 15. 16. 17. 18.]]
<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_rnn_state(batch_size, num_hiddens, ctx): |
1 | state = init_rnn_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_gru(prefix, num_chars, gru, params, init_rnn_state, |
1 | predict_rnn('分开', 10, gru, params, init_rnn_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_gru(gru, get_params, init_rnn_state, num_hiddens, |
1 | num_epochs, num_steps, batch_size, lr, clipping_theta = 200, 35, 32, 1e2, 1e-2 |
epoch 50, perplexity 107.604530, time 2.57 sec
- 分开 我想你的让我 爱爱人 我不要你的可爱女人 坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏
- 不分开 说我有你的爱人 爱有我有你的可爱女人 坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏
epoch 100, perplexity 12.245766, time 2.56 sec
- 分开 我想要这样 我不要再想 我不 我不 我不 我不 我不 我不 我不 我不 我不 我不 我不 我不 我
- 不分开 没有你在我有多多 难散 你想很久了吧? 我想想你 你不要再想 我不 我不 我不 我不 我不 我不
epoch 150, perplexity 1.785267, time 2.54 sec
- 分开 我想带这样布 对你依依不舍 连隔壁邻居都猜到我现在的感受 河边的风 在吹着头发飘动 牵着你的手 一
- 不分开 没有你烦我有多烦多多难熬 穿过云层 我试著努力向你奔跑 爱才送到 你却已在别人怀抱 就是开不了
epoch 200, perplexity 1.072161, time 2.53 sec
- 分开 我已轻这天布 对你依依不舍 连隔壁邻居都猜到我现在的感受 河边的风 在吹着头发飘动 牵着你的手 一
- 不分开 你已经离开我 不知不觉 我跟了这节奏 后知后觉 后知后觉 迷迷蒙蒙 你给的梦 出现裂缝 隐隐作痛
1 |