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

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

门控循环神经网络(gated recurrent neural networks)的提出,是为了更好地捕捉时序数据中间隔较大的依赖关系。其中,门控循环单元(gated recurrent unit,简称GRU)是一种常用的门控循环神经网络。它由Cho、van Merrienboer、 Bahdanau和Bengio在2014年被提出。

门控循环单元

我们先介绍门控循环单元的构造。它比循环神经网络中的隐含层构造稍复杂一点。

重置门和更新门

门控循环单元的隐含状态只包含隐含层变量\(\mathbf{H}\)。假定隐含状态长度为\(h\),给定时刻\(t\)的一个样本数为\(n\)特征向量维度为\(x\)的批量数据\(\mathbf{X}_t \in \mathbb{R}^{n \times x}\)和上一时刻隐含状态\(\mathbf{H}_{t-1} \in \mathbb{R}^{n \times h}\),重置门(reset gate)\(\mathbf{R}_t \in \mathbb{R}^{n \times h}\)和更新门(update gate)\(\mathbf{Z}_t \in \mathbb{R}^{n \times h}\)的定义如下:

\[\mathbf{R}_t = \sigma(\mathbf{X}_t \mathbf{W}_{xr} + \mathbf{H}_{t-1} \mathbf{W}_{hr} + \mathbf{b}_r)\]
\[\mathbf{Z}_t = \sigma(\mathbf{X}_t \mathbf{W}_{xz} + \mathbf{H}_{t-1} \mathbf{W}_{hz} + \mathbf{b}_z)\]

其中的\(\mathbf{W}_{xr}, \mathbf{W}_{xz} \in \mathbb{R}^{x \times h}\)\(\mathbf{W}_{hr}, \mathbf{W}_{hz} \in \mathbb{R}^{h \times h}\)是可学习的权重参数,\(\mathbf{b}_r, \mathbf{b}_z \in \mathbb{R}^{1 \times h}\)是可学习的偏移参数。函数\(\sigma\)自变量中的三项相加使用了广播

需要注意的是,重置门和更新门使用了值域为\([0, 1]\)的函数\(\sigma(x) = 1/(1+\text{exp}(-x))\)。因此,重置门\(\mathbf{R}_t\)和更新门\(\mathbf{Z}_t\)中每个元素的值域都是\([0, 1]\)

候选隐含状态

我们可以通过元素值域在\([0, 1]\)的更新门和重置门来控制隐含状态中信息的流动:这通常可以应用按元素乘法符\(\odot\)。门控循环单元中的候选隐含状态\(\tilde{\mathbf{H}}_t \in \mathbb{R}^{n \times h}\)使用了值域在\([-1, 1]\)的双曲正切函数tanh做激活函数:

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

其中的\(\mathbf{W}_{xh} \in \mathbb{R}^{x \times h}\)\(\mathbf{W}_{hh} \in \mathbb{R}^{h \times h}\)是可学习的权重参数,\(\mathbf{b}_h \in \mathbb{R}^{1 \times h}\)是可学习的偏移参数。

需要注意的是,候选隐含状态使用了重置门来控制包含过去时刻信息的上一个隐含状态的流入。如果重置门近似0,上一个隐含状态将被丢弃。因此,重置门提供了丢弃与未来无关的过去隐含状态的机制。

隐含状态

隐含状态\(\mathbf{H}_t \in \mathbb{R}^{n \times h}\)的计算使用更新门\(\mathbf{Z}_t\)来对上一时刻的隐含状态\(\mathbf{H}_{t-1}\)和当前时刻的候选隐含状态\(\tilde{\mathbf{H}}_t\)做组合,公式如下:

\[\mathbf{H}_t = \mathbf{Z}_t \odot \mathbf{H}_{t-1} + (1 - \mathbf{Z}_t) \odot \tilde{\mathbf{H}}_t\]

需要注意的是,更新门可以控制过去的隐含状态在当前时刻的重要性。如果更新门一直近似1,过去的隐含状态将一直通过时间保存并传递至当前时刻。这个设计可以应对循环神经网络中的梯度衰减问题,并更好地捕捉时序数据中间隔较大的依赖关系。

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

  • 重置门有助于捕捉时序数据中短期的依赖关系。
  • 更新门有助于捕捉时序数据中长期的依赖关系。

输出层的设计可参照循环神经网络中的描述。

实验

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

数据处理

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

In [1]:
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)
print('vocab size:', vocab_size)
vocab size: 1465

我们使用onehot来将字符索引表示成向量。

In [2]:
def get_inputs(data):
    return [nd.one_hot(X, vocab_size) for X in data.T]

初始化模型参数

以下部分对模型参数进行初始化。参数hidden_dim定义了隐含状态的长度。

In [3]:
import mxnet as mx

# 尝试使用GPU
import sys
sys.path.append('..')
from mxnet import nd
import utils
ctx = utils.try_gpu()
print('Will use', ctx)

input_dim = vocab_size
# 隐含状态长度
hidden_dim = 256
output_dim = vocab_size
std = .01

def get_params():
    # 隐含层
    W_xz = nd.random_normal(scale=std, shape=(input_dim, hidden_dim), ctx=ctx)
    W_hz = nd.random_normal(scale=std, shape=(hidden_dim, hidden_dim), ctx=ctx)
    b_z = nd.zeros(hidden_dim, ctx=ctx)

    W_xr = nd.random_normal(scale=std, shape=(input_dim, hidden_dim), ctx=ctx)
    W_hr = nd.random_normal(scale=std, shape=(hidden_dim, hidden_dim), ctx=ctx)
    b_r = nd.zeros(hidden_dim, ctx=ctx)

    W_xh = nd.random_normal(scale=std, shape=(input_dim, hidden_dim), ctx=ctx)
    W_hh = nd.random_normal(scale=std, shape=(hidden_dim, hidden_dim), ctx=ctx)
    b_h = nd.zeros(hidden_dim, ctx=ctx)

    # 输出层
    W_hy = nd.random_normal(scale=std, shape=(hidden_dim, output_dim), ctx=ctx)
    b_y = nd.zeros(output_dim, 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
Will use gpu(0)

定义模型

我们将前面的模型公式翻译成代码。

In [4]:
def gru_rnn(inputs, H, *params):
    # inputs: num_steps 个尺寸为 batch_size * vocab_size 矩阵
    # H: 尺寸为 batch_size * hidden_dim 矩阵
    # outputs: num_steps 个尺寸为 batch_size * vocab_size 矩阵
    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)

训练模型

下面我们开始训练模型。我们假定谱写歌词的前缀分别为“分开”、“不分开”和“战争中部队”。这里采用的是相邻批量采样实验门控循环单元谱写歌词。

In [5]:
seq1 = '分开'
seq2 = '不分开'
seq3 = '战争中部队'
seqs = [seq1, seq2, seq3]

utils.train_and_predict_rnn(rnn=gru_rnn, is_random_iter=False, epochs=200,
                            num_steps=35, hidden_dim=hidden_dim,
                            learning_rate=0.2, clipping_norm=5,
                            batch_size=32, pred_period=20, pred_len=100,
                            seqs=seqs, get_params=get_params,
                            get_inputs=get_inputs, ctx=ctx,
                            corpus_indices=corpus_indices,
                            idx_to_char=idx_to_char, char_to_idx=char_to_idx)
Epoch 20. Training perplexity 273.881812
 -  分开 我不的我的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你
 -  不分开 我不的我的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你
 -  战争中部队 我不的我的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你的我的你

Epoch 40. Training perplexity 106.118016
 -  分开 我想想你的爱我 想不要我的爱你 我想要你的爱我 想不要我的爱你 我想要你的爱我 想不要我的爱你 我想要你的爱我 想不要我的爱你 我想要你的爱我 想不要我的爱你 我想要你的爱我 想不要我的爱你 我想要你
 -  不分开 我想要你的爱我 想不要我的爱你 我想要你的爱我 想不要我的爱你 我想要你的爱我 想不要我的爱你 我想要你的爱我 想不要我的爱你 我想要你的爱我 想不要我的爱你 我想要你的爱我 想不要我的爱你 我想要你
 -  战争中部队 我想你的爱我有你的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的

Epoch 60. Training perplexity 29.473133
 -  分开 不想就这样打我妈妈 我不要再想 我不能再想你我妈 我不要再想你我妈不是我想要的可爱女人 沉坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让
 -  不分开不是我的手不能开不能 我不能再想 我不能再想你我妈 我不要再想你我妈不是我想要的可爱女人 沉坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让
 -  战争中部队 我想要这样子 你的手不被我更可的可爱女人 我想你的爱写在西元 我想像一只蜂找没蜜 像往南一张一张ㄟ撕 连车叨位去 你的那里 不想就是你不到我的你不知道 我不要再想 我不能再想你我妈 我不要再想你我妈不

Epoch 80. Training perplexity 8.119215
 -  分开了这样的想多的 你在我会不想要一个人剧 我想我这辈子 你的手不该过我太妈 我真的没有天份 安静的没这么我 会乡的天一天 我会想你 分兽人 的灵魂 单纯 停远古存在的街 用谦卑的茶 有一种味道叫做家 他满
 -  不分开了一场悲剧 我想我这辈子 一颗银在黑 还是一碗热粥 配上的钥栈人找我 想想你看的国 我想想能你 我不能再想 我不 我不 我不能 想不再再想我 不想再这样打我 不知不觉 我跟了这节奏 后知后觉 我该好好生
 -  战争中部队 他时间的风 我说的想要我要的可爱 你怎么这样子 你已经远远离开 我也会慢慢走开 为什么我连分开都迁 我不要再想 我不能再想 我不 我不 我不能 想不再再想我 不想再这样打我 不知不觉 我跟了这节奏 后

Epoch 100. Training perplexity 3.124672
 -  分开了这样的想想就一定 这样的眼后对听 半旧是你在自自 然原谅在风滴的日 随有什么我给才出出的可爱女人 温坏的让我面狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人
 -  不分开了书 我要你的爱你 想要在风不过 已经的真我已红的太多 透坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的
 -  战争中部队 他说 ㄌㄚ ㄙㄡ ㄈㄚ ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇー ㄇ

Epoch 120. Training perplexity 1.678306
 -  分开了这样的想要走 我给你的爱写在西元前 深埋在美索不达米亚平原 几十个世纪后出土发现 泥板上的字迹依然清晰可见 我给你的爱写在西元前 深埋在美索不达米亚平原 用楔形文字刻下了永远 那已风化千年的誓言 一切
 -  不分开了无命的一切走剧 我们不想 你不要再想 我不 我不 我不能 爱情走的太快就像龙卷风 不能承受我已无处可躲 我不要再想 我不要再想 我不 我不 我不能 爱情走的太快就像龙卷风 不能承受我已无处可躲 我不要
 -  战争中部队 我想要的叹写没人道 我懂我也知道 你没有舍不得 你说你也会难过我不相信 牵着你陪着我 也只是曾经 希望他是真的比我还要爱你 我才会逼自己离开 你要我说多难堪 我根本不想分开 为什么还要我用微笑来带过

Epoch 140. Training perplexity 1.232117
 -  分开了生多的想多走 活过到底年 我每天 你小后的太快我找道 风不开罪的国度 请原谅我的自负 没人能说没人可说 好难承受 荣耀的背后刻着一道孤独 闭上双眼我又看见 当年那梦的画面 天空是蒙蒙的雾 父亲牵着我的
 -  不分开了无命的一切走重重心心 去吃林当我的天手 换过去 来不再有永远的梦 等风沙会念约翰福的做延 传爬文明所有的屋内 嗜血森林醒来的早晨 任何侵略都成为可能 我用古老的咒语重温 吟唱灵魂序曲寻根 面对魔界的邪
 -  战争中部队 痛年那层 那个地心脏汹涌 不安跳动 全世界 的表情只剩下一种 等待英雄 我就是那条龙 我右拳打开了天 化身为龙 那大地心脏汹涌 不安跳动 全世界 的表情只剩下一种 等待英雄 我就是那条龙 我右拳打开了

Epoch 160. Training perplexity 1.099886
 -  分开 这里桌樟木的横切面 年轮有二十三圈 镜头的另一边跳接我成熟的脸 经过这些年 爷爷的手茧 泡在水里会有茶色蔓延 爷爷泡的茶 有一种味道叫做家 没法挑剔它 口感味觉还不差 陆羽泡的茶 像幅泼墨的山水画 唐
 -  不分开就走 我手 你的黑色幽默 风通残的东防栓像顶成 可为你怎么是我爱你 这样界渐的困意 这次是抱得更紧 这样挽留不知还来不来得及 想回到过去 思绪不断阻挡着回忆播放 盲目谓 你怎么打我不要 说  我们 想你
 -  战争中部队 暗年到过去飞直 你说 苦笑常常陪着你 在一起有点勉强 该不该现在休了我 不想太多 我想一定是我听错弄错搞错 拜托 我想是你的脑袋有问题 随便说说 其实我早已经猜透看透不想多说 只是我怕眼泪撑不住 不懂

Epoch 180. Training perplexity 1.066156
 -  分开 想要那么我都能 说我 你的黑色幽默 周杰残-的是你 说后还得得直走 没有太多沉走 争论一张古铜 渴望着血脉相通 无限个千万弟兄 我把天地拆封将长江水掏空 人在古老河床蜕变中 我右拳打开了天 化身为龙
 -  不分开就走 把手慢慢交给我 放下心中的困惑 雨点从两旁划过 割开两种精神的我 经过老伯的家 篮框变得好高 爬过的那棵树 又何时变得渺小 这样也好 开始没人注意到你我 等雨变强之前 我们将会分化软弱 趁时间没发
 -  战争中部队 我想要到分化 对你依依不舍 连隔壁邻居都猜到我现在的感受 河边的风 在吹着头发飘动 牵着你的手 一阵莫名感动 我想带你 回我的外婆家 一起看着日落 一直到我们都睡着 我想就这样牵着你的手不放开 爱能不

Epoch 200. Training perplexity 1.045707
 -  分开 这里桌樟木的横切面 年轮有二十三圈 镜头的另一边跳接我成熟的脸 经过去的你是是我的想要得得一定 但再再回到过去甜甜 温馨的欢乐香味 虽然你那权 我的世界将被摧毁 也许事与愿违 累不累 睡不睡 单影无人
 -  不分开就走 把手慢慢交给我 放下心中的困惑 雨点从两旁划过 割开两种精神的我 经过老伯的家 篮框变得好高 爬过的那棵树 又何时变得渺小 这样也好 开始没人注意到你我 等雨变强之前 我们将会分化软弱 趁时间没发
 -  战争中部队 到故事到反去到那个个的味少 漂古高原南下的风写些什么内容 汉字到底懂不懂 一样肤色和面孔 跨越黄河东 登上泰山顶峰 我向西引北风 晒成一身古铜 渴望着血脉相通 无限个千万弟兄 我把天地拆封将长江水掏空

可以看到一开始学到简单的字符,然后简单的词,接着是复杂点的词,然后看上去似乎像个句子了。

结论

  • 门控循环单元的提出是为了更好地捕捉时序数据中间隔较大的依赖关系。
  • 重置门有助于捕捉时序数据中短期的依赖关系。
  • 更新门有助于捕捉时序数据中长期的依赖关系。

练习

  • 调调参数(例如数据集大小、序列长度、隐含状态长度和学习率),看看对运行时间、perplexity和预测的结果造成的影响。
  • 在相同条件下,比较门控循环单元和循环神经网络的运行效率。

吐槽和讨论欢迎点这里