6.5. 循环神经网络的简洁实现

本节将使用Gluon来更简洁地实现基于循环神经网络的语言模型。首先,我们读取周杰伦专辑歌词数据集。

  1. In [1]:
  1. import d2lzh as d2l
  2. import math
  3. from mxnet import autograd, gluon, init, nd
  4. from mxnet.gluon import loss as gloss, nn, rnn
  5. import time
  6.  
  7. (corpus_indices, char_to_idx, idx_to_char,
  8. vocab_size) = d2l.load_data_jay_lyrics()

6.5.1. 定义模型

Gluon的rnn模块提供了循环神经网络的实现。下面构造一个含单隐藏层、隐藏单元个数为256的循环神经网络层rnn_layer,并对权重做初始化。

  1. In [2]:
  1. num_hiddens = 256
  2. rnn_layer = rnn.RNN(num_hiddens)
  3. rnn_layer.initialize()

接下来调用rnn_layer的成员函数begin_state来返回初始化的隐藏状态列表。它有一个形状为(隐藏层个数,批量大小, 隐藏单元个数)的元素。

  1. In [3]:
  1. batch_size = 2
  2. state = rnn_layer.begin_state(batch_size=batch_size)
  3. state[0].shape
  1. Out[3]:
  1. (1, 2, 256)

与上一节中实现的循环神经网络不同,这里rnn_layer的输入形状为(时间步数,批量大小,输入个数)。其中输入个数即one-hot向量长度(词典大小)。此外,rnn_layer作为Gluon的rnn.RNN实例,在前向计算后会分别返回输出和隐藏状态,其中输出指的是隐藏层在各个时间步上计算并输出的隐藏状态,它们通常作为后续输出层的输入。需要强调的是,该“输出”本身并不涉及输出层计算,形状为(时间步数,批量大小,隐藏单元个数)。而rnn.RNN实例在前向计算返回的隐藏状态指的是隐藏层在最后时间步的可用于初始化下一时间步的隐藏状态:当隐藏层有多层时,每一层的隐藏状态都会记录在该变量中;对于像长短期记忆这样的循环神经网络,该变量还会包含其他信息。我们会在本章的后面介绍长短期记忆和深度循环神经网络。

  1. In [4]:
  1. num_steps = 35
  2. X = nd.random.uniform(shape=(num_steps, batch_size, vocab_size))
  3. Y, state_new = rnn_layer(X, state)
  4. Y.shape, len(state_new), state_new[0].shape
  1. Out[4]:
  1. ((35, 2, 256), 1, (1, 2, 256))

接下来我们继承Block类来定义一个完整的循环神经网络。它首先将输入数据使用one-hot向量表示后输入到rnn_layer中,然后使用全连接输出层得到输出。输出个数等于词典大小vocab_size

  1. In [5]:
  1. # 本类已保存在d2lzh包中方便以后使用
  2. class RNNModel(nn.Block):
  3. def __init__(self, rnn_layer, vocab_size, **kwargs):
  4. super(RNNModel, self).__init__(**kwargs)
  5. self.rnn = rnn_layer
  6. self.vocab_size = vocab_size
  7. self.dense = nn.Dense(vocab_size)
  8.  
  9. def forward(self, inputs, state):
  10. # 将输入转置成(num_steps, batch_size)后获取one-hot向量表示
  11. X = nd.one_hot(inputs.T, self.vocab_size)
  12. Y, state = self.rnn(X, state)
  13. # 全连接层会首先将Y的形状变成(num_steps * batch_size, num_hiddens),它的输出
  14. # 形状为(num_steps * batch_size, vocab_size)
  15. output = self.dense(Y.reshape((-1, Y.shape[-1])))
  16. return output, state
  17.  
  18. def begin_state(self, *args, **kwargs):
  19. return self.rnn.begin_state(*args, **kwargs)

6.5.2. 训练模型

同上一节一样,下面定义一个预测函数。这里的实现区别在于前向计算和初始化隐藏状态的函数接口。

  1. In [6]:
  1. # 本函数已保存在d2lzh包中方便以后使用
  2. def predict_rnn_gluon(prefix, num_chars, model, vocab_size, ctx, idx_to_char,
  3. char_to_idx):
  4. # 使用model的成员函数来初始化隐藏状态
  5. state = model.begin_state(batch_size=1, ctx=ctx)
  6. output = [char_to_idx[prefix[0]]]
  7. for t in range(num_chars + len(prefix) - 1):
  8. X = nd.array([output[-1]], ctx=ctx).reshape((1, 1))
  9. (Y, state) = model(X, state) # 前向计算不需要传入模型参数
  10. if t < len(prefix) - 1:
  11. output.append(char_to_idx[prefix[t + 1]])
  12. else:
  13. output.append(int(Y.argmax(axis=1).asscalar()))
  14. return ''.join([idx_to_char[i] for i in output])

让我们使用权重为随机值的模型来预测一次。

  1. In [7]:
  1. ctx = d2l.try_gpu()
  2. model = RNNModel(rnn_layer, vocab_size)
  3. model.initialize(force_reinit=True, ctx=ctx)
  4. predict_rnn_gluon('分开', 10, model, vocab_size, ctx, idx_to_char, char_to_idx)
  1. Out[7]:
  1. '分开 运秋城短型疲狠丘所'

接下来实现训练函数。算法同上一节的一样,但这里只使用了相邻采样来读取数据。

  1. In [8]:
  1. # 本函数已保存在d2lzh包中方便以后使用
  2. def train_and_predict_rnn_gluon(model, num_hiddens, vocab_size, ctx,
  3. corpus_indices, idx_to_char, char_to_idx,
  4. num_epochs, num_steps, lr, clipping_theta,
  5. batch_size, pred_period, pred_len, prefixes):
  6. loss = gloss.SoftmaxCrossEntropyLoss()
  7. model.initialize(ctx=ctx, force_reinit=True, init=init.Normal(0.01))
  8. trainer = gluon.Trainer(model.collect_params(), 'sgd',
  9. {'learning_rate': lr, 'momentum': 0, 'wd': 0})
  10.  
  11. for epoch in range(num_epochs):
  12. l_sum, n, start = 0.0, 0, time.time()
  13. data_iter = d2l.data_iter_consecutive(
  14. corpus_indices, batch_size, num_steps, ctx)
  15. state = model.begin_state(batch_size=batch_size, ctx=ctx)
  16. for X, Y in data_iter:
  17. for s in state:
  18. s.detach()
  19. with autograd.record():
  20. (output, state) = model(X, state)
  21. y = Y.T.reshape((-1,))
  22. l = loss(output, y).mean()
  23. l.backward()
  24. # 梯度裁剪
  25. params = [p.data() for p in model.collect_params().values()]
  26. d2l.grad_clipping(params, clipping_theta, ctx)
  27. trainer.step(1) # 因为已经误差取过均值,梯度不用再做平均
  28. l_sum += l.asscalar() * y.size
  29. n += y.size
  30.  
  31. if (epoch + 1) % pred_period == 0:
  32. print('epoch %d, perplexity %f, time %.2f sec' % (
  33. epoch + 1, math.exp(l_sum / n), time.time() - start))
  34. for prefix in prefixes:
  35. print(' -', predict_rnn_gluon(
  36. prefix, pred_len, model, vocab_size, ctx, idx_to_char,
  37. char_to_idx))

使用和上一节实验中一样的超参数来训练模型。

  1. In [9]:
  1. num_epochs, batch_size, lr, clipping_theta = 250, 32, 1e2, 1e-2
  2. pred_period, pred_len, prefixes = 50, 50, ['分开', '不分开']
  3. train_and_predict_rnn_gluon(model, num_hiddens, vocab_size, ctx,
  4. corpus_indices, idx_to_char, char_to_idx,
  5. num_epochs, num_steps, lr, clipping_theta,
  6. batch_size, pred_period, pred_len, prefixes)
  1. epoch 50, perplexity 79.334967, time 0.04 sec
  2. - 分开 我想能这爱 我不能再想 我不能再想 我不能再想 我不能再想 我不能再想 我不能再想 我不能再想
  3. - 不分开 我想你这爱 我不能我想 我不能你想 我不能再想 我不能再想 我不能再想 我不能再想 我不能再想
  4. epoch 100, perplexity 13.665787, time 0.04 sec
  5. - 分开你的手篇美公主卷风我的那画面知难过 就在星人一棍 我怀带起主每我妈攻 难道你手不会痛么难个人我的泪望
  6. - 不分开 就是星人一点 我每带起主每我妈妈 难道你的太快就像龙不风我的那画面知的可爱女人 坏坏的让我疯狂的可
  7. epoch 150, perplexity 4.181531, time 0.04 sec
  8. - 分开 我来想能宣布 不你依对不起我进妈 我的伤口被你拆封 誓言太 一步两颗三颗四颗 连成线背著背默默许下
  9. - 不分开 娘子的手猫的模瓣 古著葛没担忧软单的姑窗 铺阳榉木去在见驳的砖娘 铺著榉木去在见驳的砖娘 铺著榉木
  10. epoch 200, perplexity 2.327051, time 0.04 sec
  11. - 分开 我来无这为布 对你依依不舍 连隔壁邻居都猜到我 在有翅 失沉时睛 如悔的假蜜 让欢在失潮的模样
  12. - 不分开 就是兵我环绕大自 我想再这样牵着愿 思念像底格里斯都难的漫延 当古文明只剩垂不朽的诗篇 我给你的爱
  13. epoch 250, perplexity 1.857790, time 0.04 sec
  14. - 分开 我有就这白照片开 思是你笑格里斯河般的一延 当古文明只剩下甜解的语言 传说就成了永垂不朽的诗篇
  15. - 不分开 就是我不多太多 但那个人 再来一碗热粥 配上几斤的牛肉 我说店小二 三两银够不够 景色入秋 漫天黄

6.5.3. 小结

  • Gluon的rnn模块提供了循环神经网络层的实现。
  • Gluon的rnn.RNN实例在前向计算后会分别返回输出和隐藏状态。该前向计算并不涉及输出层计算。

6.5.4. 练习

  • 与上一节的实现进行比较。看看Gluon的实现是不是运行速度更快?如果你觉得差别明显,试着找找原因。