RNN项目实战使用周杰伦专辑歌词训练模型并创作歌曲
本文将介绍如何预处理一个语言模型数据集,并将其转换成字符级循环神经网络所需要的输入格式。然后通过循环神经网络RNN进行模型训练,然后使用训练好的模型创作歌曲。
语言模型数据集采用的是我最喜欢的歌手周杰伦第一张专辑《Jay》到第十张专辑《跨时代》中的所有歌词,下面来开始我们的项目吧。
1.语言模型数据集预处理
1.1 读取数据集
首先读取这个周杰伦专辑歌词的数据集,并显示前100个字符。
import torch import random import zipfile with zipfile.ZipFile('./RNN-JayZhou/jaychou_lyrics.txt.zip' as zin: with zin.open('jaychou_lyrics.txt') as f: corpus_chars = f.read().decode('utf-8') corpus_chars[:100]
输出:
'想要有直升机\n想要和你飞到宇宙去\n想要和你融化在一起\n融化在宇宙里\n我每天每天每天在想想想想著你\n这样的甜蜜\n让我开始乡相信命运\n感谢地心引力\n让我碰到你\n漂亮的让我面红的可爱女人\n温柔的让我心疼的可'
这个数据集共有6万多个字符。为了打印方便,我们把换行符替换成空格。为了节省模型计算时间,后续仅使用前1万个字符来训练模型。
corpus_chars = corpus_chars.replace('\n', ' ').replace('\r', ' ') corpus_chars = corpus_chars[0:10000] #取前10000个字符进行后续模型训练
1.2 建立字符索引
我们将每个字符映射成一个从0开始的连续整数,又称索引,来方便之后的数据处理。为了得到索引,我们将数据集里所有不同字符取出来,然后将其逐一映射到索引来构造词典。词典中不同字符的个数vocab_size
称为词典大小。
idx_to_char = list(set(corpus_chars)) char_to_idx = dict([(char, i) for i, char in enumerate(idx_to_char)]) vocab_size = len(char_to_idx) vocab_size # 1027
之后,将训练数据集中每个字符转化为索引,并打印前20个字符及其对应的索引。
corpus_indices = [char_to_idx[char] for char in corpus_chars] sample = corpus_indices[:20] print('chars:', ''.join([idx_to_char[idx] for idx in sample])) print('indices:', sample)
输出:
chars: 想要有直升机 想要和你飞到宇宙去 想要和 indices: [250, 164, 576, 421, 674, 653, 357, 250, 164, 850, 217, 910, 1012, 261, 275, 366, 357, 250, 164, 850]
我们定义一个函数load_data_jay_lyrics
,返回corpus_indices
【数据集字符对应的索引】、char_to_idx
【词典不同字符对应索引】、idx_to_char
【词典索引对应不同字符】和vocab_size
【数据集不同字符数量】这4个变量。后续直接调用这个函数来获取数据集对应的这4个变量。
def load_data_jay_lyrics(path): # path为数据集jaychou_lyrics.txt.zip路径 """加载周杰伦歌词数据集""" with zipfile.ZipFile(path) as zin: with zin.open('jaychou_lyrics.txt') as f: corpus_chars = f.read().decode('utf-8') corpus_chars = corpus_chars.replace('\n', ' ').replace('\r', ' ') corpus_chars = corpus_chars[0:10000] idx_to_char = list(set(corpus_chars)) char_to_idx = dict([(char, i) for i, char in enumerate(idx_to_char)]) vocab_size = len(char_to_idx) corpus_indices = [char_to_idx[char] for char in corpus_chars] return corpus_indices, char_to_idx, idx_to_char, vocab_size
1.3 时序数据的2种采样方式
在训练中我们需要每次随机读取小批量样本和标签,时序数据的一个样本通常包含连续的字符。假设时间步数为5,样本序列为5个字符,即“想”“要”“有”“直”“升”。该样本的标签序列为这些字符分别在训练集中的下一个字符,即“要”“有”“直”“升”“机”。表示如下:
样本序列1:“想”“要”“有”“直”“升” ----> 标签序列:“要”“有”“直”“升”“机”
对于时序数据采样方式通常有两种:随机采样和相邻采样。下面分别介绍这两种采样方式。
我们有两种方式对时序数据进行采样,分别是随机采样和相邻采样。
1.3.1 随机采样
下面的代码每次从数据里随机采样一个小批量。其中批量大小batch_size
指每个小批量的样本数,num_steps
为每个样本所包含的时间步数。
在随机采样中,每个样本是原始序列上任意截取的一段序列。相邻的两个随机小批量在原始序列上的位置不一定相毗邻。因此,我们无法用一个小批量最终时间步的隐藏状态来初始化下一个小批量的隐藏状态。在训练模型时,每次随机采样前都需要重新初始化隐藏状态。
def data_iter_random(corpus_indices, batch_size, num_steps, device=None): # 减1是因为输出的索引是相应输入的索引加1 num_examples = (len(corpus_indices) - 1) // num_steps #样本数 epoch_size = num_examples // batch_size # epoch数 example_indices = list(range(num_examples)) random.shuffle(example_indices) # 返回从pos开始的长为num_steps的序列 def _data(pos): return corpus_indices[pos: pos + num_steps] if device is None: device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') for i in range(epoch_size): # 每次读取batch_size个随机样本 i = i * batch_size batch_indices = example_indices[i: i + batch_size] X = [_data(j * num_steps) for j in batch_indices] Y = [_data(j * num_steps + 1) for j in batch_indices] yield torch.tensor(X, dtype=torch.float32, device=device), torch.tensor(Y, dtype=torch.float32, device=device)
让我们输入一个从0到29的连续整数的人工序列。设批量大小和时间步数分别为2和6。打印随机采样每次读取的小批量样本的输入X
和标签Y
。可见,相邻的两个随机小批量在原始序列上的位置不一定相毗邻。
my_seq = list(range(30)) for X, Y in data_iter_random(my_seq, batch_size=2, num_steps=6): print('X: ', X, '\nY:', Y, '\n')
输出:
X: tensor([[18., 19., 20., 21., 22., 23.], [12., 13., 14., 15., 16., 17.]]) Y: tensor([[19., 20., 21., 22., 23., 24.], [13., 14., 15., 16., 17., 18.]]) X: tensor([[ 0., 1., 2., 3., 4., 5.], [ 6., 7., 8., 9., 10., 11.]]) Y: tensor([[ 1., 2., 3., 4., 5., 6.], [ 7., 8., 9., 10., 11., 12.]])
1.3.2 相邻采样
相邻采样指相邻的两个随机小批量在原始序列上的位置相毗邻。这时候,我们就可以用一个小批量最终时间步的隐藏状态来初始化下一个小批量的隐藏状态,从而使下一个小批量的输出也取决于当前小批量的输入,并如此循环下去。
这种方式对实现循环神经网络有两方面影响:
一方面,在训练模型时,我们只需在每一个迭代周期开始时初始化隐藏状态;
另一方面,当多个相邻小批量通过传递隐藏状态串联起来时,模型参数的梯度计算将依赖所有串联起来的小批量序列。同一迭代周期中,随着迭代次数的增加,梯度的计算开销会越来越大。
为了使模型参数的梯度计算只依赖一次迭代读取的小批量序列,我们可以在每次读取小批量前将隐藏状态从计算图中分离出来。
def data_iter_consecutive(corpus_indices, batch_size, num_steps, device=None): if device is None: device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') corpus_indices = torch.tensor(corpus_indices, dtype=torch.float32, device=device) data_len = len(corpus_indices) batch_len = data_len // batch_size indices = corpus_indices[0: batch_size*batch_len].view(batch_size, batch_len) epoch_size = (batch_len - 1) // num_steps for i in range(epoch_size): i = i * num_steps X = indices[:, i: i + num_steps] Y = indices[:, i + 1: i + num_steps + 1] yield X, Y
同样的设置下,打印相邻采样每次读取的小批量样本的输入X
和标签Y
。相邻的两个随机小批量在原始序列上的位置相毗邻。
for X, Y in data_iter_consecutive(my_seq, batch_size=2, num_steps=6): print('X: ', X, '\nY:', Y, '\n')
输出:
X: tensor([[ 0., 1., 2., 3., 4., 5.], [15., 16., 17., 18., 19., 20.]]) Y: tensor([[ 1., 2., 3., 4., 5., 6.], [16., 17., 18., 19., 20., 21.]]) X: tensor([[ 6., 7., 8., 9., 10., 11.], [21., 22., 23., 24., 25., 26.]]) Y: tensor([[ 7., 8., 9., 10., 11., 12.], [22., 23., 24., 25., 26., 27.]])
小结
- 时序数据采样方式包括随机采样和相邻采样。使用这两种方式的循环神经网络训练在实现上略有不同。
2. 从零实现循环神经网络并进行训练预测
在本节中,我们将从零开始实现一个基于字符级循环神经网络的语言模型,并在周杰伦专辑歌词数据集上训练一个模型来进行歌词创作。首先,我们读取周杰伦专辑歌词数据集:
import time import math import numpy as np import torch from torch import nn, optim import torch.nn.functional as F import sys import d2lzh_pytorch as d2l device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') (corpus_indices, char_to_idx, idx_to_char, vocab_size) = load_data_jay_lyrics('./RNN-JayZhou/jaychou_lyrics.txt.zip')
2.1 one-hot向量表示
为了将词表示成向量输入到神经网络,一个简单的办法是使用one-hot向量。假设词典中不同字符的数量为N NN(即词典大小vocab_size
),每个字符已经同一个从0到N−1的连续整数值索引一一对应。如果一个字符的索引是整数i ii, 那么我们创建一个全0的长为N的向量,并将其位置为i ii的元素设成1。该向量就是对原字符的one-hot向量表示。
我们每次采样的小批量的形状是(批量大小, 时间步数)。下面的函数to_onehot将这样的小批量变换成数个可以输入进网络的形状为(批量大小, 词典大小)的矩阵,矩阵个数等于时间步数。也就是说,时间步t的输入为Xt∈Rn×d,其中n为批量大小,d为输入个数,即one-hot向量长度(词典大小)。
def one_hot(x, n_class, dtype=torch.float32): # X shape: (batch), output shape: (batch, n_class) x = x.long() res = torch.zeros(x.shape[0], n_class, dtype=dtype, device=x.device) res.scatter_(1, x.view(-1, 1), 1) return res def to_onehot(X, n_class): # X shape: (batch, seq_len), output: seq_len elements of (batch, n_class) return [one_hot(X[:, i], n_class) for i in range(X.shape[1])] X = torch.arange(10).view(2, 5) inputs = to_onehot(X, vocab_size) print(len(inputs), inputs[0].shape)
输出:
5 torch.Size([2, 1027])
2.2 初始化模型参数
初始化模型参数,隐藏单元个数 num_hiddens
是一个超参数。
num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size print('will use', device) def get_params(): def _one(shape): # 初始化参数的函数,正态分布 ts = torch.tensor(np.random.normal(0, 0.01, size=shape), device=device, dtype=torch.float32) return torch.nn.Parameter(ts, requires_grad=True) # 隐藏层参数 W_xh = _one((num_inputs, num_hiddens)) W_hh = _one((num_hiddens, num_hiddens)) b_h = torch.nn.Parameter(torch.zeros(num_hiddens, device=device, requires_grad=True)) # 输出层参数 W_hq = _one((num_hiddens, num_outputs)) b_q = torch.nn.Parameter(torch.zeros(num_outputs, device=device, requires_grad=True)) return nn.ParameterList([W_xh, W_hh, b_h, W_hq, b_q])
2.3 定义模型
根据循环神经网络的计算表达式实现该模型。首先定义init_rnn_state
函数来返回初始化的隐藏状态。它返回由一个形状为(批量大小, 隐藏单元个数)的值为0的NDArray
组成的元组。使用元组是为了更便于处理隐藏状态含有多个NDArray
的情况。
def init_rnn_state(batch_size, num_hiddens, device): # 返回初始化的隐藏状态,形状:(批量大小, 隐藏单元个数) return (torch.zeros((batch_size, num_hiddens), device=device), )
下面的rnn
函数定义了在一个时间步里如何计算隐藏状态和输出。这里的激活函数使用了tanh函数。当元素在实数域上均匀分布时,tanh函数值的均值为0。
def rnn(inputs, state, params): # inputs和outputs皆为num_steps个形状为(batch_size, vocab_size)的矩阵 # state为初始的隐藏状态 W_xh, W_hh, b_h, W_hq, b_q = params H, = state outputs = [] for X in inputs: # 计算隐藏状态H H = torch.tanh(torch.matmul(X, W_xh) + torch.matmul(H, W_hh) + b_h) # 计算输出 Y = torch.matmul(H, W_hq) + b_q outputs.append(Y) return outputs, (H,)
做个简单的测试来观察输出结果的个数(时间步数),以及第一个时间步的输出层输出的形状和隐藏状态的形状。
# state初始隐藏状态,形状:(批量大小, 隐藏单元个数) state = init_rnn_state(X.shape[0], num_hiddens, device) # 输入one_hot形式 inputs = to_onehot(X.to(device), vocab_size) # 获取各层参数 params = get_params() # 输出:outputs,state_new新的隐藏状态 outputs, state_new = rnn(inputs, state, params) print(len(outputs), outputs[0].shape, state_new[0].shape)
输出:
5 torch.Size([2, 1027]) torch.Size([2, 256])
2.4 定义预测函数
以下函数基于前缀prefix
(含有数个字符的字符串)来预测接下来的num_chars
个字符。
def predict_rnn(prefix, num_chars, rnn, params, init_rnn_state, num_hiddens, vocab_size, device, idx_to_char, char_to_idx): state = init_rnn_state(1, num_hiddens, device) output = [char_to_idx[prefix[0]]] for t in range(num_chars + len(prefix) - 1): # 将上一时间步的输出作为当前时间步的输入 X = to_onehot(torch.tensor([[output[-1]]], device=device), vocab_size) # 计算输出和更新隐藏状态 (Y, state) = rnn(X, state, params) # 下一个时间步的输入是prefix里的字符或者当前的最佳预测字符 if t < len(prefix) - 1: output.append(char_to_idx[prefix[t + 1]]) else: # 输出中最大的一个值对应的索引 output.append(int(Y[0].argmax(dim=1).item())) return ''.join([idx_to_char[i] for i in output])
我们先测试一下predict_rnn
函数。我们将根据前缀“分开”创作长度为10个字符(不考虑前缀长度)的一段歌词。因为模型参数为随机值,所以预测结果也是随机的。
predict_rnn('分开', 10, rnn, params, init_rnn_state, num_hiddens, vocab_size, device, idx_to_char, char_to_idx)
输出:
'分开西圈绪升王凝瓜必客映'
2.5 裁剪梯度
循环神经网络中较容易出现梯度衰减或梯度爆炸。为了应对梯度爆炸,可以裁剪梯度(clip gradient)。假设我们把所有模型参数梯度的元素拼接成一个向量g,并设裁剪的阈值是θ。裁剪后的梯度min(∥g∥θ,1)g
的L2范数不超过θ。
def grad_clipping(params, theta, device): norm = torch.tensor([0.0], device=device) for param in params: norm += (param.grad.data ** 2).sum() norm = norm.sqrt().item() if norm > theta: for param in params: param.grad.data *= (theta / norm)
2.6 困惑度
我们通常使用困惑度(perplexity)来评价语言模型的好坏。困惑度是对交叉熵损失函数做指数运算后得到的值。特别地,
- 最佳情况下,模型总是把标签类别的概率预测为1,此时困惑度为1;
- 最坏情况下,模型总是把标签类别的概率预测为0,此时困惑度为正无穷;
- 基线情况下,模型总是预测所有类别的概率都相同,此时困惑度为类别个数。
显然,任何一个有效模型的困惑度必须小于类别个数。在本例中,困惑度必须小于词典大小vocab_size
。
2.7 定义模型训练函数
与之前CNN模型训练函数相比,这里的模型训练函数有以下几点不同:
- 使用困惑度评价模型。
- 在迭代模型参数前裁剪梯度。
- 对时序数据采用不同采样方法将导致隐藏状态初始化的不同。
定义模型
def train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens, vocab_size, device, corpus_indices, idx_to_char, char_to_idx, is_random_iter, num_epochs, num_steps, lr, clipping_theta, batch_size, pred_period, pred_len, prefixes): ``` num_epochs:迭代的周期数 num_steps:每个样本所包含的时间步数 batch_size:批大小 pred_period:在本函数中用于控制pred_period个迭代周期打印一次预测结果 pred_len:预测的字符数 prefixes:预测前缀,依据该前缀进行预测 ``` if is_random_iter: # 是否是随机采样还是相邻采样 data_iter_fn = d2l.data_iter_random else: data_iter_fn = d2l.data_iter_consecutive params = get_params() loss = nn.CrossEntropyLoss() for epoch in range(num_epochs): # is_random_iter:True表示随机采样,False表示相邻采样 if not is_random_iter: # 如使用相邻采样,在epoch开始时初始化隐藏状态 state = init_rnn_state(batch_size, num_hiddens, device) l_sum, n, start = 0.0, 0, time.time() data_iter = data_iter_fn(corpus_indices, batch_size, num_steps, device) for X, Y in data_iter: if is_random_iter: # 如使用随机采样,在每个小批量更新前初始化隐藏状态 state = init_rnn_state(batch_size, num_hiddens, device) else: # 否则需要使用detach函数从计算图分离隐藏状态, 这是为了 # 使模型参数的梯度计算只依赖一次迭代读取的小批量序列(防止梯度计算开销太大) for s in state: s.detach_() inputs = to_onehot(X, vocab_size) # outputs有num_steps个形状为(batch_size, vocab_size)的矩阵 (outputs, state) = rnn(inputs, state, params) # 拼接之后形状为(num_steps * batch_size, vocab_size) outputs = torch.cat(outputs, dim=0) # Y的形状是(batch_size, num_steps),转置后再变成长度为 # batch_size * num_steps 的向量,这样跟输出的行一一对应 y = torch.transpose(Y, 0, 1).contiguous().view(-1) # 使用交叉熵损失计算平均分类误差 l = loss(outputs, y.long()) # 梯度清0 if params[0].grad is not None: for param in params: param.grad.data.zero_() l.backward() grad_clipping(params, clipping_theta, device) # 裁剪梯度 d2l.sgd(params, lr, 1) # 因为误差已经取过均值,梯度不用再做平均 l_sum += l.item() * y.shape[0] n += y.shape[0] if (epoch + 1) % pred_period == 0: print('epoch %d, perplexity %f, time %.2f sec' % ( epoch + 1, math.exp(l_sum / n), time.time() - start)) for prefix in prefixes: print(' -', predict_rnn(prefix, pred_len, rnn, params, init_rnn_state, num_hiddens, vocab_size, device, idx_to_char, char_to_idx))
2.8 训练模型并创作歌词
现在我们可以训练模型了。首先,设置模型超参数。我们将根据前缀“分开”和“不分开”分别创作长度为50个字符(不考虑前缀长度)的一段歌词。我们每过50个迭代周期便根据当前训练的模型创作一段歌词。
num_epochs, num_steps, batch_size, lr, clipping_theta = 250, 35, 32, 1e2, 1e-2 pred_period, pred_len, prefixes = 50, 50, ['分开', '不分开']
下面采用随机采样训练模型并创作歌词。
train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens, vocab_size, device, corpus_indices, idx_to_char, char_to_idx, True, num_epochs, num_steps, lr, clipping_theta, batch_size, pred_period, pred_len, prefixes)
输出:
epoch 50, perplexity 69.127998, time 1.30 sec - 分开 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我 - 不分开 我想要你的你 一知哈觉 我已了这 你我不能 你怎么 别怪我 我不要 一沉我 一子就 一直我 一子 epoch 100, perplexity 10.106804, time 1.39 sec - 分开 一只用双留 谁人它 一皮箱 一颗四有 在人忆 的片段 有 是对旧 快颗都颗 全小村空 在人海中 你 - 不分开吗 我后好这生 我不能再想 我不 我不 我不 我不 我不 我不 我不 我不 我不 我不 我不 我不 epoch 150, perplexity 2.871314, time 1.30 sec - 分开 一直在停留 谁让它停留的 为什么我女朋友场外加油 你却还让我出糗 可小睡耳濡目染 什么刀枪跟棍棒 - 不分开吗 我不能再想你 不知都觉 你已经离开我 不知不觉 我跟了这节奏 后知后觉 又过了双截棍 哼哼哈兮 epoch 200, perplexity 1.543714, time 1.46 sec - 分开 有直在对医 有思寄人牛 三里什么奇怪的事都有 包括像猫的狗 印地安老斑鸠 平常话不多 除非是乌鸦抢 - 不分开扫 然后将过去 慢慢温习 让我爱上你 那场悲剧 是你完美演出的一场戏 宁愿心碎哭泣 再狠狠不记 你非 epoch 250, perplexity 1.298522, time 1.60 sec - 分开 有双去不 你作常怎球出的手尾椅 用括像猫前年的誓言 纪录又一只遇见的你 Jay Chou 如果 - 不分开期把的胖女巫 用拉丁文念咒语啦啦呜 她养的黑猫笑起来像哭 啦啦啦呜 在时内悬 我不懂 我不走 装壶就
接下来采用相邻采样训练模型并创作歌词。
train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens, vocab_size, device, corpus_indices, idx_to_char, char_to_idx, False, num_epochs, num_steps, lr, clipping_theta, batch_size, pred_period, pred_len, prefixes)
输出:
epoch 50, perplexity 59.174035, time 1.30 sec - 分开 我想要这爱 我不要再不 我不要再不 我不要再想 我不要再不 我不要再想 我不要再不 我不要再想 我 - 不分开你 你想我有你的让我 你不我有你不着你的手不女 我爱你的爱写 一哼哈兮 快使用双截棍 一直哈兮 快使 epoch 100, perplexity 6.633354, time 1.35 sec - 分开 我说的这样 你一在热抽 仙蝪什么羞 仙人什么走 三人掌么走 三人什么走 三人什么走 三人什么走 三 - 不分开柳 你是那过了 我两就开 我有懂不么我有你的恼言 我有你再是 有人银够不够 景色入秋 漫使用双截棍 epoch 150, perplexity 1.969446, time 1.31 sec - 分开 我说的让我每去 却发现跟了当 快使用双截棍 哼哼哈兮 快使用双截棍 哼哼哈兮 快果我有轻功 飞檐走 - 不分开觉 你已经过了我 不知不觉 我跟了这节奏 后知后觉 又过了一个秋 后知后觉 我该好好生活 我该好好生 epoch 200, perplexity 1.292166, time 1.29 sec - 分开 问候的人我 上的完美 我的完不面 但没在美主 我被它拖烦 静有悄烦快着 快使温篮 说你得动防 - 不分开觉 你已经离开我 不知不觉 我跟了这节奏 后知后觉 又过了一个秋 后知后觉 我该好好生活 我该好好生 epoch 250, perplexity 1.181980, time 1.33 sec - 分开 问候的让我面红的可爱就人风榜你 可知前 语给两步三步四步望著天 看星星 一颗两颗三颗四颗 连成线背 - 不分开觉 你已经离开我 不知不觉 我跟了这节奏 后知后觉 又过了一个秋 后知后觉 我该好好生活 我该好好生
小结
- 可以用基于字符级循环神经网络的语言模型来生成文本序列,例如创作歌词。
- 当训练循环神经网络时,为了应对梯度爆炸,可以裁剪梯度。
- 困惑度是对交叉熵损失函数做指数运算后得到的值。
3. 基于Pytorch-RNN进行训练预测
使用PyTorch可以更简洁地实现基于循环神经网络的语言模型。首先,我们读取周杰伦专辑歌词数据集。
import time import math import numpy as np import torch from torch import nn, optim import torch.nn.functional as F import sys import d2lzh_pytorch as d2l device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') (corpus_indices, char_to_idx, idx_to_char, vocab_size) = load_data_jay_lyrics('./RNN-JayZhou/jaychou_lyrics.txt.zip')
3.1 定义模型
PyTorch中的nn
模块提供了循环神经网络的实现。下面构造一个含单隐藏层、隐藏单元个数为256的循环神经网络层rnn_layer
。
num_hiddens = 256 # rnn_layer = nn.LSTM(input_size=vocab_size, hidden_size=num_hiddens) rnn_layer = nn.RNN(input_size=vocab_size, hidden_size=num_hiddens)
与上一节中实现的循环神经网络不同,这里rnn_layer
的输入形状为(时间步数, 批量大小, 输入个数)。其中输入个数即one-hot向量长度(词典大小)。此外,rnn_layer
作为nn.RNN
实例,在前向计算后会分别返回输出和隐藏状态h,其中输出指的是隐藏层在各个时间步上计算并输出的隐藏状态,它们通常作为后续输出层的输入。需要强调的是,该“输出”本身并不涉及输出层计算,形状为(时间步数, 批量大小, 隐藏单元个数)。而nn.RNN
实例在前向计算返回的隐藏状态指的是隐藏层在最后时间步的隐藏状态:当隐藏层有多层时,每一层的隐藏状态都会记录在该变量中;对于像长短期记忆(LSTM),隐藏状态是一个元组(h, c),即hidden state和cell state。后续会介绍长短期记忆和深度循环神经网络。关于循环神经网络(以LSTM为例)的输出,可以参考下图。
举个例子:输出形状为(时间步数, 批量大小, 隐藏单元个数),隐藏状态h的形状为(层数, 批量大小, 隐藏单元个数)。
num_steps = 35 batch_size = 2 state = None X = torch.rand(num_steps, batch_size, vocab_size) Y, state_new = rnn_layer(X, state) print(Y.shape, len(state_new), state_new[0].shape)
输出:
torch.Size([35, 2, 256]) 1 torch.Size([2, 256])
接下来我们继承Module
类来定义一个完整的循环神经网络。它首先将输入数据使用one-hot向量表示后输入到rnn_layer
中,然后使用全连接输出层得到输出。输出个数等于词典大小vocab_size
。
class RNNModel(nn.Module): def __init__(self, rnn_layer, vocab_size): super(RNNModel, self).__init__() self.rnn = rnn_layer # rnn_layer.bidirectional,如果是双向循环网络则为2,单向则为1 self.hidden_size = rnn_layer.hidden_size * (2 if rnn_layer.bidirectional else 1) self.vocab_size = vocab_size self.dense = nn.Linear(self.hidden_size, vocab_size) self.state = None def forward(self, inputs, state): # inputs: (batch, seq_len) # 获取one-hot向量表示 X = d2l.to_onehot(inputs, self.vocab_size) # X是个list Y, self.state = self.rnn(torch.stack(X), state) # 全连接层会首先将Y的形状变成(num_steps * batch_size, num_hiddens),它的输出 # 形状为(num_steps * batch_size, vocab_size) output = self.dense(Y.view(-1, Y.shape[-1])) return output, self.state
3.2 训练模型
定义预测函数。这里的实现与第二节中的区别在于前向计算和初始化隐藏状态的函数接口。
def predict_rnn_pytorch(prefix, num_chars, model, vocab_size, device, idx_to_char, char_to_idx): state = None output = [char_to_idx[prefix[0]]] # output会记录prefix加上输出 for t in range(num_chars + len(prefix) - 1): X = torch.tensor([output[-1]], device=device).view(1, 1) if state is not None: if isinstance(state, tuple): # LSTM, state:(h, c) state = (state[0].to(device), state[1].to(device)) else: state = state.to(device) (Y, state) = model(X, state) if t < len(prefix) - 1: output.append(char_to_idx[prefix[t + 1]]) else: output.append(int(Y.argmax(dim=1).item())) return ''.join([idx_to_char[i] for i in output])
让我们使用权重为随机值的模型来预测一次。
model = RNNModel(rnn_layer, vocab_size).to(device) predict_rnn_pytorch('分开', 10, model, vocab_size, device, idx_to_char, char_to_idx)
输出:
'分开戏想暖迎凉想征凉征征'
实现训练函数,这里只使用相邻采样来读取数据。
def train_and_predict_rnn_pytorch(model, num_hiddens, vocab_size, device, corpus_indices, idx_to_char, char_to_idx, num_epochs, num_steps, lr, clipping_theta, batch_size, pred_period, pred_len, prefixes): loss = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=lr) model.to(device) state = None for epoch in range(num_epochs): l_sum, n, start = 0.0, 0, time.time() data_iter = d2l.data_iter_consecutive(corpus_indices, batch_size, num_steps, device) # 相邻采样 for X, Y in data_iter: if state is not None: # 使用detach函数从计算图分离隐藏状态, 这是为了 # 使模型参数的梯度计算只依赖一次迭代读取的小批量序列(防止梯度计算开销太大) if isinstance (state, tuple): # LSTM, state:(h, c) state = (state[0].detach(), state[1].detach()) else: state = state.detach() (output, state) = model(X, state) # output: 形状为(num_steps * batch_size, vocab_size) # Y的形状是(batch_size, num_steps),转置后再变成长度为 # batch_size * num_steps 的向量,这样跟输出的行一一对应 y = torch.transpose(Y, 0, 1).contiguous().view(-1) # y.long()表示向下取整 l = loss(output, y.long()) ''' y = torch.transpose(Y, 0, 1).contiguous().view(-1)作用 Y = tensor([[ 7., 8., 9., 10., 11., 12.], [22., 23., 24., 25., 26., 27.]]) 则y = torch.transpose(Y, 0, 1).contiguous().view(-1)结果为: tensor([ 7., 22., 8., 23., 9., 24., 10., 25., 11., 26., 12., 27.]) ''' optimizer.zero_grad() l.backward() # 梯度裁剪 d2l.grad_clipping(model.parameters(), clipping_theta, device) optimizer.step() l_sum += l.item() * y.shape[0] n += y.shape[0] try: perplexity = math.exp(l_sum / n) except OverflowError: perplexity = float('inf') if (epoch + 1) % pred_period == 0: print('epoch %d, perplexity %f, time %.2f sec' % ( epoch + 1, perplexity, time.time() - start)) for prefix in prefixes: print(' -', predict_rnn_pytorch( prefix, pred_len, model, vocab_size, device, idx_to_char, char_to_idx))
使用和2.8中一样的超参数(除了学习率)来训练模型。
num_epochs, batch_size, lr, clipping_theta = 250, 32, 1e-3, 1e-2 # 注意这里的学习率设置 pred_period, pred_len, prefixes = 50, 50, ['分开', '不分开'] train_and_predict_rnn_pytorch(model, num_hiddens, vocab_size, device, corpus_indices, idx_to_char, char_to_idx, num_epochs, num_steps, lr, clipping_theta, batch_size, pred_period, pred_len, prefixes)
输出:
epoch 50, perplexity 7.681733, time 0.40 sec - 分开 我想了这样牵 不的 我 你的一场 我 你的那里 我想要你的微笑 我想要你的微笑 我想要你的微笑 我 - 不分开不 我不开始不 不要再想要你 一场去 后知后觉 我想你这样牵着你的手不放人 你是你人我 别不能再 epoch 100, perplexity 1.258844, time 0.46 sec - 分开 我跟了这节奏 后知后觉 又过了一个秋 后知后觉 我该好好生活 我该好好生活 不知不觉 你已经离开我 - 不分开不你是我不懂 想你怎么面对我 甩开球我满腔的怒火 我想揍你已经很久 别想躲 说你眼睛看着我 别发抖 epoch 150, perplexity 1.064266, time 0.46 sec - 分开 我跟再这样 思要你被你都我的错 爱知走 杵在伊斯坦堡 却只想你和汉堡 我想要你的微笑每天都能看 - 不分开不 是我的话 穿人的没躲 说你的 从小到大的片来败的窝对我的味道 又过了人慢慢 轻知再觉 你已经 epoch 200, perplexity 1.031409, time 0.47 sec - 分开 我过再这样 想要你 升机我想要和你飞到宇宙去 想要和你融化在一起 融化在宇宙里 我每天每天每天在 - 不分开 是从的起 它给的进爱 在的感受 已边的话过 我不啊 平常话不多 一定会有护三马到也什么在这样对 epoch 250, perplexity 1.056751, time 0.46 sec - 分开 我过再这样 思出你 我知多的熬 难没 什么 干什么 我被开口吴侬软语沉默 娘子她依旧每日折一枝 - 不分开 了是我不起 我给 这些功 快使用双截棍 哼哼哈兮 快使用双截棍 哼哼哈兮 习武之人切记 仁者无敌
小结
- PyTorch的
nn
模块提供了循环神经网络层的实现。 - PyTorch的
nn.RNN
实例在前向计算后会分别返回输出和隐藏状态。该前向计算并不涉及输出层计算。