一、何为隐状态?
有时,我们想让模型当前t时刻的输出不仅取决于当前时刻的输入xt,还取决于t时刻以前的输入,但如果把xt-1,xt-2,...,x1都考虑进来,词表中的词汇量将会变得很大,而且也很难设计这样一个如此大的模型。因此,我们便会想让t时刻之前的输入加权和并到一个隐藏变量(hidden variable)中,这个隐藏变量也称为隐状态(hidden state),存储了t时刻之前的序列信息。通常我们可以基于当前输入xt和先前隐状态ht−1来计算时间步t处的任何时间的隐状态:
循环神经网络(recurrent neural networks,RNNs) 是具有隐状态的神经网络。
二、无隐状态的神经网络
在介绍具有隐状态的神经网络之前,先回顾一下无隐状态的神经网络。经典的多层感知机MLP就是一种无隐状态的神经网络。在MLP的隐藏层中,我们会使用如下式子表示其输入输出关系:
上式中,Φ为隐藏层的激活函数,假设批量大小为n,那么输入X的维度为n×x,输出H的维度为n×h。
同理,输出层的输入输出关系如下式所示:
式中各符号意义已显而易见。
三、有隐状态的循环神经网络
不同于无隐状态的神经网络,在有隐状态的循环循环神经网络中,我们首先需要考虑以另一种形式表示当前时刻的隐藏变量:
上式中,输入Xt的维度为n×x,n表示批量大小,x可以理解为一个token进行独热编码后的长度;隐状态H的维度为n×h,n表示批量大小,h为隐藏单元的数目。
可以看到,我们引入了一个新的权重参数Whh,使得当前时刻的隐藏变量不仅与当前输入有关,还与之前输入造成的影响因子有关。在循环神经网络中,执行上述步骤的网络层称为循环层。
循环神经网络的输出层与MLP相似:
具有隐状态的循环神经网络的网络结构如下图所示:
四、损失函数
语言模型可以视作一个分类模型,预测出来的词可以理解为就是一个类别。对应分类模型,我们可以使用交叉熵来衡量模型质量:
由于历史原因,自然语言处理的科学家更喜欢使用一个叫做困惑度(perplexity)的量:
五、梯度裁剪
隐状态的存在意味着假如我们向模型输入了T个token,那么就要反向传播时就会产生长度为O(T)的乘法链,乘法链太长的话就容易出现梯度爆炸或梯度消失的情况。
为了防止梯度爆炸情况的出现,反向传播时我们会加入一个取最小值的判断操作,防止梯度过大:
上式中,g是所有层计算得到的梯度组成的一个向量,如果这个向量的二范数(平方和开根)大于阈值θ,那么它或被缩小移动倍数,否则保持不变。
六、RNN的应用
六、从零开始实现RNN
在项目中引入utils.py:
rnnModel.py:
导入依赖包:
import math
import torch
import seqDataLoader as loader
from utils import Timer, Accumulator, Animator, sgd, show
from torch import nn
from torch.nn import functional as F
初始化模型参数:
def get_params(vocab_size, num_hiddens, device):
# 输入到rnn的都是一个个token,每个token经过one-hot编码后,会生成一个与vocab长度相同的向量
# 所以输入输出大小等于vocab_size
num_inputs = num_outputs = vocab_size
def normal(shape):
return torch.randn(size=shape, device=device) * 0.01 # 方差为0.01
# 隐藏层参数
W_xh = normal((num_inputs, num_hiddens))
W_hh = normal((num_hiddens, num_hiddens))
b_h = torch.zeros(num_hiddens, device=device)
# 输出层参数
W_hq = normal((num_hiddens, num_outputs))
b_q = torch.zeros(num_outputs, device=device)
# 附加梯度
params = [W_xh, W_hh, b_h, W_hq, b_q]
for param in params:
param.requires_grad_(True)
return params
由于零时刻时没有上一时刻的隐藏状态,所以定义一个函数用来初始化隐藏状态:
def init_rnn_state(batch_size, num_hiddens, device):
return (torch.zeros((batch_size, num_hiddens), device=device), )
定义一个函数用于计算一个时间步内的隐状态和输出:
def rnn(inputs, state, params):
# inputs的维度:(num_steps,batch_size,vocab_size)
W_xh, W_hh, b_h, W_hq, b_q = params
H, = state
outputs = []
# X的维度:(batch_size,vocab_size)
for X in inputs:
# W_xh的维度:(vocab_size * num_hiddens)
# torch.mm(X, W_xh)是矩阵乘法操作,返回矩阵的尺度为 batch_size * num_hiddens
# tanh用于作非线性变换
H = torch.tanh(torch.mm(X, W_xh) + torch.mm(H, W_hh) + b_h)
Y = torch.mm(H, W_hq) + b_q
# 循环结束后,outputs的形状为:(num_steps,batch_size,vocab_size)
outputs.append(Y)
# 返回前向推理一次后网络的输出和隐状态
# torch.cat(outputs, dim=0)使得outputs的形状变为 (num_steps * batch_size,num_outputs)
return torch.cat(outputs, dim=0), (H,)
创建一个类来封装上面定义的函数:
class RNNModelScratch:
def __init__(self, vocab_size, num_hiddens, device, get_params, init_state, forward_fn):
self.vocab_size, self.num_hiddens = vocab_size, num_hiddens
# 初始化模型参数结构
self.params = get_params(vocab_size, num_hiddens, device)
# 定义初始隐藏状态、rnn函数
self.init_state, self.forward_fn = init_state, forward_fn
def __call__(self, X, state):
# X的维度:(batch_size,num_steps)
# 独热编码处理并设置为浮点型
X = F.one_hot(X.T, self.vocab_size).type(torch.float32)
# 返回一次前向推导结果
return self.forward_fn(X, state, self.params)
# 隐藏层参数初始化
def begin_state(self, batch_size, device):
return self.init_state(batch_size, self.num_hiddens, device)
接下来定义一个预测函数。在函数的输入参数中,prefix是给定的句子开头;num_preds表示想要预测几步;net是网络模型,在这里其实就是前面定义的RNNModelScratch的实例:
def predictRNN(prefix, num_preds, net, vocab, device):
# 生成初始的隐藏状态
state = net.begin_state(batch_size=1, device=device)
# prefix[0]是一个token,通过vocab实例的__getitem__方法可以获取到这个token在词典中的索引
outputs = [vocab[prefix[0]]]
# outputs[-1]表示当前outpus列表中的最后一个元素
# get_input函数用于获取outpus列表中的最后一个token,经过reshape这个token的维度为(1,1,vocab_size)
get_input = lambda: torch.tensor([outputs[-1]], device=device).reshape((1, 1))
# prefix是由多个token组成的一个句子,这个循环在依次遍历prefix中的token去更新网络中的隐状态
for y in prefix[1:]:
# 这里是调用了RNNModelScratch中的__call__函数
_, state = net(get_input(), state)
# 输出的前num_steps-1个token和输入是一致的
outputs.append(vocab[y])
for _ in range(num_preds): # 预测num_preds步
# y的维度为(1,vocab_size)
y, state = net(get_input(), state)
# 提取除y最大元素的index并转成int类型,添加到outputs列表中(独热码转换成原来编码)
outputs.append(int(y.argmax(dim=1).reshape(1)))
# 最后将输出列表中的所有token组合成一个字符串返回
return ''.join([vocab.idx_to_token[i] for i in outputs])
梯度裁剪函数,梯度裁剪原理与第五节中的公式相同:
def grad_clipping(net, theta):
# 判断现在使用的网络是torch已经实现的网络还是我们自定义的网络
if isinstance(net, nn.Module):
params = [p for p in net.parameters() if p.requires_grad]
else:
# params = [W_xh, W_hh, b_h, W_hq, b_q]
params = net.params
# 求g的二范数(平方和开根)
norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
# 如果 theta/norm < 1,那么缩小梯度
if norm > theta:
for param in params:
param.grad[:] *= theta / norm
定义模型训练函数,用于迭代一次进行训练:
def train_epoch(net, train_iter, loss, updater, device, use_random_iter):
state, timer = None, Timer()
# 训练损失之和
metric = Accumulator(2)
# Y是X的延时,也就是真实值。从train_iter中遍历获取所有的batch进行训练
for X, Y in train_iter:
# (batch_size,num_steps),torch.Size([3, 10])
# print(X.shape)
# 第一次迭代或使用随机抽样时初始化state
if state is None or use_random_iter:
state = net.begin_state(batch_size=X.shape[0], device=device)
else:
if isinstance(net, nn.Module) and not isinstance(state, tuple):
# 释放之前的计算图,但是state中还保存着之前更新的结果
state.detach_()
else:
# state对于我们从零开始实现的模型来说是个张量
for s in state:
s.detach_()
y = Y.T.reshape(-1)
# 数据转移到gpu上
X, y = X.to(device), y.to(device)
# forward预测,这里是调用了RNNModelScratch中的__call__函数
y_hat, state = net(X, state)
# 计算损失,这里调用mean方法应该是取了 batch * num_steps 个token的评价损失
l = loss(y_hat, y.long()).mean()
# 当张量的requires_grad属性被注册后,调用l.backward()能够根据自动微分机制计算出损失函数l相对于图中所有需要梯度的张量的梯度
if isinstance(updater, torch.optim.Optimizer):
updater.zero_grad()
l.backward()
grad_clipping(net, 1) # 梯度剪裁
updater.step()
else:
l.backward()
grad_clipping(net, 1)
updater(batch_size=1)
metric.add(l * y.numel(), y.numel())
# 计算困惑度,metric[0]表示loss的累加,metric[1]表示样本总数
return math.exp(metric[0] / metric[1]), metric[1] / timer.stop()
定义用于给外部调用的训练接口函数:
def trainRNN(net, train_iter, vocab, lr, num_epochs, device, use_random_iter=False):
# 多分类问题采用交叉熵损失
loss = nn.CrossEntropyLoss()
animator = Animator(xlabel='epoch', ylabel='perplexity', legend=['train'], xlim=[10, num_epochs])
# 初始化参数更新方法,采用随机梯度下降法
if isinstance(net, nn.Module):
updater = torch.optim.SGD(net.parameters(), lr)
else:
updater = lambda batch_size: sgd(net.params, lr, batch_size)
# 参数5表示往后预测5个词元
predict = lambda prefix: predictRNN(prefix, 5, net, vocab, device)
# 训练和预测
for epoch in range(num_epochs):
# ppl:用指数衡量的困惑度;speed:计算速度,单位是词元每秒
ppl, speed = train_epoch(net, train_iter, loss, updater, device, use_random_iter) # 单次epoch训练
# 在训练过程中每隔一定周期作图查看效果
if (epoch + 1) % 10 == 0:
print(predict('lose your'))
animator.add(epoch + 1, [ppl])
print(f'困惑度 {ppl:.1f}, {speed:.1f} 词元/秒 {str(device)}')
print(predict('lose your'))
show()
主函数测试:
if __name__=='__main__':
batch_size, num_steps = 3, 10
train_iter, vocab = loader.loadData(batch_size, num_steps, 'loseyourself.txt')
num_hiddens = 512
net = RNNModelScratch(len(vocab), num_hiddens, torch.device('cuda'), get_params, init_rnn_state, rnn)
# 训练轮次,学习率
num_epochs, lr = 20, 1
# 对网络进行训练并作图显示结果
trainRNN(net, train_iter, vocab, lr, num_epochs, torch.device('cuda'))
参考链接:
《动手学深度学习》 — 动手学深度学习 2.0.0 documentationhttps://zh-v2.d2l.ai/