门控循环单元(GRU)——从零开始

上一节介绍了循环神经网络中的梯度计算方法。我们发现,循环神经网络的梯度可能会衰减或爆炸。虽然裁剪梯度可以应对梯度爆炸,但无法解决梯度衰减的问题。给定一个时间序列,例如文本序列,循环神经网络在实际中较难捕捉时间步距离较大的词之间的依赖关系。

门控循环神经网络(gated recurrent neural network)的提出,是为了更好地捕捉时间序列中时间步距离较大的依赖关系。其中,门控循环单元(gated recurrent unit,简称GRU)是一种常用的门控循环神经网络 [1, 2]。我们将在下一节介绍另一种门控循环神经网络:长短期记忆。

门控循环单元

下面将介绍门控循环单元的设计。它引入了门的概念,从而修改了循环神经网络中隐藏状态的计算方式。输出层的设计不变。

重置门和更新门

假设隐藏单元个数为\(h\),给定时间步\(t\)的小批量输入\(\boldsymbol{X}_t \in \mathbb{R}^{n \times x}\)(样本数为\(n\),输入个数为\(x\))和上一时间步隐藏状态\(\boldsymbol{H}_{t-1} \in \mathbb{R}^{n \times h}\)。重置门(reset gate)\(\boldsymbol{R}_t \in \mathbb{R}^{n \times h}\)和更新门(update gate)\(\boldsymbol{Z}_t \in \mathbb{R}^{n \times h}\)的计算如下:

\[\begin{split}\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}\end{split}\]

其中\(\boldsymbol{W}_{xr}, \boldsymbol{W}_{xz} \in \mathbb{R}^{x \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}\)是偏移参数。激活函数\(\sigma\)是sigmoid函数。“多层神经网络”一节中介绍过,sigmoid函数可以将元素的值变换到0和1之间。因此,重置门\(\boldsymbol{R}_t\)和更新门\(\boldsymbol{Z}_t\)中每个元素的值域都是\([0, 1]\)

我们可以通过元素值域在\([0, 1]\)的更新门和重置门来控制隐藏状态中信息的流动:这通常可以应用按元素乘法符\(\odot\)

候选隐藏状态

接下来,时间步\(t\)的候选隐藏状态\(\tilde{\boldsymbol{H}}_t \in \mathbb{R}^{n \times h}\)的计算使用了值域在\([-1, 1]\)的tanh函数做激活函数。它在之前描述的循环神经网络隐藏状态表达式的基础上,引入了重置门和按元素乘法:

\[\tilde{\boldsymbol{H}}_t = \text{tanh}(\boldsymbol{X}_t \boldsymbol{W}_{xh} + \boldsymbol{R}_t \odot \boldsymbol{H}_{t-1} \boldsymbol{W}_{hh} + \boldsymbol{b}_h),\]

其中\(\boldsymbol{W}_{xh} \in \mathbb{R}^{x \times h}\)\(\boldsymbol{W}_{hh} \in \mathbb{R}^{h \times h}\)是权重参数,\(\boldsymbol{b}_h \in \mathbb{R}^{1 \times h}\)是偏移参数。需要注意的是,候选隐藏状态使用了重置门,从而控制包含时间序列历史信息的上一个时间步的隐藏状态如何流入当前时间步的候选隐藏状态。如果重置门近似0,上一个隐藏状态将被丢弃。因此,重置门可以丢弃与预测未来无关的历史信息。

隐藏状态

最后,隐藏状态\(\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.\]

值得注意的是,更新门可以控制隐藏状态应该如何被包含当前时间步信息的候选隐藏状态所更新。假设更新门在时间步\(t^\prime\)\(t\)\(t^\prime < t\))之间一直近似1。那么,在时间步\(t^\prime\)\(t\)之间的输入信息几乎没有流入时间步\(t\)的隐藏状态\(\boldsymbol{H}_t\)。 实际上,这可以看作是较早时刻的隐藏状态\(\boldsymbol{H}_{t^\prime-1}\)一直通过时间保存并传递至当前时间步\(t\)。 这个设计可以应对循环神经网络中的梯度衰减问题,并更好地捕捉时间序列中时间步距离较大的依赖关系。

我们对门控循环单元的设计稍作总结:

  • 重置门有助于捕捉时间序列里短期的依赖关系。
  • 更新门有助于捕捉时间序列里长期的依赖关系。

实验

为了实现并展示门控循环单元,我们依然使用周杰伦歌词数据集来训练模型作词。这里除门控循环单元以外的实现已在“循环神经网络——从零开始”一节中介绍。

处理数据

我们先读取并简单处理数据集。

In [1]:
import sys
sys.path.append('..')
import gluonbook as gb
from mxnet import nd
import zipfile

with zipfile.ZipFile('../data/jaychou_lyrics.txt.zip', 'r') as zin:
    zin.extractall('../data/')
with open('../data/jaychou_lyrics.txt') as f:
    corpus_chars = f.read()

corpus_chars = corpus_chars.replace('\n', ' ').replace('\r', ' ')
corpus_chars = corpus_chars[0:20000]
idx_to_char = list(set(corpus_chars))
char_to_idx = dict([(char, i) for i, char in enumerate(idx_to_char)])
corpus_indices = [char_to_idx[char] for char in corpus_chars]
vocab_size = len(char_to_idx)

初始化模型参数

以下部分对模型参数进行初始化。超参数num_hiddens定义了隐藏单元的个数。

In [2]:
ctx = gb.try_gpu()
num_inputs = vocab_size
num_hiddens = 256
num_outputs = vocab_size

def get_params():
    # 更新门参数。
    W_xz = nd.random_normal(scale=0.01, shape=(num_inputs, num_hiddens),
                            ctx=ctx)
    W_hz = nd.random_normal(scale=0.01, shape=(num_hiddens, num_hiddens),
                            ctx=ctx)
    b_z = nd.zeros(num_hiddens, ctx=ctx)
    # 重置门参数。
    W_xr = nd.random_normal(scale=0.01, shape=(num_inputs, num_hiddens),
                            ctx=ctx)
    W_hr = nd.random_normal(scale=0.01, shape=(num_hiddens, num_hiddens),
                            ctx=ctx)
    b_r = nd.zeros(num_hiddens, ctx=ctx)
    # 候选隐藏状态参数。
    W_xh = nd.random_normal(scale=0.01, shape=(num_inputs, num_hiddens),
                            ctx=ctx)
    W_hh = nd.random_normal(scale=0.01, shape=(num_hiddens, num_hiddens),
                            ctx=ctx)
    b_h = nd.zeros(num_hiddens, ctx=ctx)
    # 输出层参数。
    W_hy = nd.random_normal(scale=0.01, shape=(num_hiddens, num_outputs),
                            ctx=ctx)
    b_y = nd.zeros(num_outputs, ctx=ctx)

    params = [W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hy, b_y]
    for param in params:
        param.attach_grad()
    return params

定义模型

下面根据门控循环单元的计算表达式定义模型。

In [3]:
def gru_rnn(inputs, H, *params):
    W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hy, b_y = params
    outputs = []
    for X in inputs:
        Z = nd.sigmoid(nd.dot(X, W_xz) + nd.dot(H, W_hz) + b_z)
        R = nd.sigmoid(nd.dot(X, W_xr) + nd.dot(H, W_hr) + b_r)
        H_tilda = nd.tanh(nd.dot(X, W_xh) + R * nd.dot(H, W_hh) + b_h)
        H = Z * H + (1 - Z) * H_tilda
        Y = nd.dot(H, W_hy) + b_y
        outputs.append(Y)
    return (outputs, H)

训练模型并创作歌词

设置好超参数后,我们将训练模型并跟据前缀“分开”和“不分开”分别创作长度为100个字符的一段歌词。我们每过30个迭代周期便根据当前训练的模型创作一段歌词。训练模型时采用了相邻采样。

In [4]:
get_inputs = gb.to_onehot
num_epochs = 150
num_steps = 35
batch_size = 32
lr = 0.25
clipping_theta = 5
prefixes = ['分开', '不分开']
pred_period = 30
pred_len = 100

gb.train_and_predict_rnn(gru_rnn, False, num_epochs, num_steps, num_hiddens,
                         lr, clipping_theta, batch_size, vocab_size,
                         pred_period, pred_len, prefixes, get_params,
                         get_inputs, ctx, corpus_indices, idx_to_char,
                         char_to_idx)

epoch 30, perplexity 114.430466
 -  分开 一直有人 一直人 一颗两 三颗 一颗两 三颗 一颗两 三颗 一颗两 三颗 一颗两 三颗 一颗两 三颗 一颗两 三颗 一颗两 三颗 一颗两 三颗 一颗两 三颗 一颗两 三颗 一颗两 三颗 一颗两 三颗
 -  不分开  难道我的爱你 爱人的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏

epoch 60, perplexity 10.200583
 -  分开了  我们 半兽人 的灵魂 单纯 对止忿存恨的公望 用恍烟惚 一步两步三步四步 连成线背著背默默许许心愿 看远方的星迹听一种风 印地安斑鸠 腿短毛不安 你没没这样牵着我的天 我给你的爱写在西元前 深埋在
 -  不分开 我不能再想 我不能再想你 你不是我不要再想你 不知不觉 你已经离开我 不知不觉 我跟了这节奏 后知后觉 我就是你慢的没有你 什么这么我 试你着你说说

epoch 90, perplexity 1.945664
 -  分开 我想要你的微笑每天都能看到  我知道这里很美但家乡的你更美 从不来 太永简单 一壶好酒 我再一定感到走 仁慈的父我已坠入 看不见罪的国度 请原谅我的自负 没人能说没人可说 好难承受 荣耀的背后刻着一道
 -  不分开  我连好打我的侧 任风看义 不懂 安殿跟中旧的神 用谦卑的身份 再也没有纯白的灵魂 自人类堕落为半兽人 我开始使用第一人称 记录眼前所有的发生 嗜血到底不知来 所有不同对你 快使用双截棍 哼哼哈兮 快

epoch 120, perplexity 1.141570
 -  分开 盲包抢中不起 让不起你在黑色的乐 还是开都着无号 我害怕你心碎没人帮你擦眼泪 别管那是非只要我们感觉对 我害怕你心碎没人帮你擦眼泪 别离开身边拥有你我的世界才能完美 他们猜随便猜不重要  连上彼此的讯
 -  不分开 我在远 灵魂序曲完成 带领族人写下祈祷文 让我们 半兽人 的灵魂 翻滚 停止忿恨 永无止尽的战争 让我们 半兽人 的灵魂 单纯 对远古存在的神 用谦卑的身份 再也没有纯白的灵魂 自人类堕落为半兽人 我

epoch 150, perplexity 1.058475
 -  分开 我想要的想笑的你比谁都了  你想说的想给的我全都知道 未接来电没留言  一定是你孤单的想念 任何人都猜不到  这是我们的暗 没有些白的身身 我想要的话笑常着演龙 深埋让我想到 你的温柔像羽毛 秘密躺在
 -  不分开 你在远赢中中的泪 有时事情你叫瞒着我 一口一口四边 泛黄的春联还残留在 放在糖果旁喜新   纪录那最后你的美  何有你我 朋实现 说怎么 小跟伦 的日出调 一切两口 回来跟沉的溪肉 我说店小二 三两银

小结

  • 门控循环神经网络的可以更好地捕捉时间序列中时间步距离较大的依赖关系,它包括门控循环单元和长短期记忆。
  • 门控循环单元引入了门的概念,从而修改了循环神经网络中隐藏状态的计算方式。它包括重置门、更新门、候选隐藏状态和隐藏状态。
  • 重置门有助于捕捉时间序列里短期的依赖关系。
  • 更新门有助于捕捉时间序列里长期的依赖关系。

练习

  • 假设时间步\(t^\prime < t\)。如果我们只希望用时间步\(t^\prime\)的输入来预测时间步\(t\)的输出,每个时间步的重置门和更新门的值最好是多少?
  • 调调超参数,观察并分析对运行时间、困惑度以及创作歌词的结果造成的影响。
  • 在相同条件下,比较门控循环单元和循环神经网络的运行时间。

扫码直达讨论区

参考文献

[1] Cho, Kyunghyun, et al. “On the properties of neural machine translation: Encoder-decoder approaches.” arXiv:1409.1259 (2014).

[2] Chung, Junyoung, et al. “Empirical evaluation of gated recurrent neural networks on sequence modeling.” arXiv:1412.3555 (2014).