79.循环神经网络的从零开始实现

从头开始基于循环神经网络实现字符级语言模型。 这样的模型将在H.G.Wells的时光机器数据集上训练。 和之前一样, 我们先读取数据集。

%matplotlib inline
import math
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l
# num_steps表示一个小批量里面的一条样本的长度
batch_size, num_steps = 32, 35
# 加载《时间机器》这本书,返回train_iter
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

1. 独热编码

回想一下,在train_iter中,每个词元都表示为一个数字索引, 将这些索引直接输入神经网络可能会使学习变得困难。 我们通常将每个词元表示为更具表现力的特征向量。 最简单的表示称为独热编码(one-hot encoding)。

简言之,将每个索引映射为相互不同的单位向量: 假设词表中不同词元的数目为 (即len(vocab)), 词元索引的范围为 0 到 −1 。 如果词元的索引是整数 , 那么我们将创建一个长度为 的全 0 向量, 并将第 处的元素设置为 1 。 此向量是原始词元的一个独热向量。 索引为 0 和 2 的独热向量如下所示:

# len(vocab)编码长度,大小为28
F.one_hot(torch.tensor([0, 2]), len(vocab))

运行结果:

79.循环神经网络的从零开始实现_第1张图片

我们每次采样的小批量数据形状是二维张量: (批量大小,时间步数)one_hot函数将这样一个小批量数据转换成三维张量, 张量的最后一个维度等于词表大小(len(vocab))。 我们经常转换输入的维度,以便获得形状为 (时间步数,批量大小,词表大小)的输出。 这将使我们能够更方便地通过最外层的维度, 一步一步地更新小批量数据的隐状态。

# 批量大小为2,时间步数为5
X = torch.arange(10).reshape((2, 5))
# X.T对X做了转置,变成了5 x 2的矩阵,又加上了28这一个维度
# 就会变成(5,2,28)三维的tensor,这样就把时间步数放到了最前面
# 那么可以理解为时间步数为t,后面两维(2,28)可以理解为数据X(t)
# (时间步数,批量大小(每个时间步几个单词),词表大小
F.one_hot(X.T, 28).shape

运行结果:

在这里插入图片描述

2. 初始化模型参数

接下来,我们初始化循环神经网络模型的模型参数。 隐藏单元数num_hiddens是一个可调的超参数。 当训练语言模型时,输入和输出来自相同的词表。 因此,它们具有相同的维度,即词表的大小。

def get_params(vocab_size, num_hiddens, device):
    # 输入和输出的大小都等于词表的大小
    num_inputs = num_outputs = vocab_size 

    # 辅助函数,因为要不断调用,所以额外定义
    # 随机的初始化函数
    def normal(shape):
        return torch.randn(size=shape, device=device) * 0.01

    # 隐藏层参数
    # 对于输入x(num_inputs)映射到h(num_hiddens)
    W_xh = normal((num_inputs, num_hiddens))
    # 上一个时刻的隐藏层h映射到下一个时刻的h
    # ps:如果不要下面这一行代码,可以发现其余代码就是单隐藏层的MLP
    # 从输入x映射到隐藏层h,再从隐藏层映射到输出
    W_hh = normal((num_hiddens, num_hiddens))
    # 偏移b是一个长为num_hiddens的向量,初始化为0
    b_h = torch.zeros(num_hiddens, device=device)
    # 输出层参数,隐藏变量到输出的W
    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

3. 循环神经网络模型

为了定义循环神经网络模型, 我们首先需要一个init_rnn_state函数在初始化时返回隐状态。 这个函数的返回是一个张量,张量全用0填充, 形状为(批量大小,隐藏单元数)。 在后面的章节中我们将会遇到隐状态包含多个变量的情况, 而使用元组可以更容易地处理些。

def init_rnn_state(batch_size, num_hiddens, device):
  # 为什么要初始化隐藏状态呢?
  # 因为在0时刻的时候没有隐藏状态/隐藏变量,所以应该给一个
  # 对于每一个样本,它的长度是一个长为num_hiddens的向量
    return (torch.zeros((batch_size, num_hiddens), device=device), )

至此,初始化了可学习的参数,以及初始化了隐藏状态,接下来可以做计算了。

下面的rnn函数定义了如何在一个时间步内计算隐状态和输出。循环神经网络模型通过inputs最外层的维度实现循环, 以便逐时间步更新小批量数据的隐状态H。 此外,这里使用 tanh 函数作为激活函数。 当元素在实数上满足均匀分布时, tanh 函数的平均值为0。

def rnn(inputs, state, params):
    # inputs的形状:(时间步数量,批量大小,词表大小)
    # state:初始化的隐藏层的状态
    # params:可学习的参数
    W_xh, W_hh, b_h, W_hq, b_q = params
    H, = state # state是一个只有1个元素的tuple
    outputs = []
    # X的形状:(批量大小,词表大小)
    for X in inputs: 
      # 沿着第一个维度去遍历,首先拿到第0时刻的对应的数据,
      # 形状为(批量大小,词表大小),之后再拿时刻1,一直到最后时刻t
      # 所以每一次循环就是算一个特定的时间步
      # torch.mm(X, W_xh):输入X和W_xh做矩阵乘法再加上
      # torch.mm(H, W_hh):这里的H是前一个时刻的隐藏状态,再加上偏置
      # 最后用tanh做激活函数得到当前时刻的H
        H = torch.tanh(torch.mm(X, W_xh) + torch.mm(H, W_hh) + b_h)
      # 获得到当前时刻的H之后,再跟输出层的W相乘,加上偏移,得到Y
      # 这个Y是当前时刻的预测,就是在当前时刻预测下一个时刻的词是什么
        Y = torch.mm(H, W_hq) + b_q
        # 因为有循环,所以把每一时刻的预测都放入outputs
        outputs.append(Y)

    # 循环结束之后,把输出和当前的(最新的)隐藏状态返回
    # torch.cat(outputs, dim=0)把所有输出进行了拼接,得到一个二维的矩阵
    # 这个矩阵是按照垂直方向拼接的,所以列数没有变,仍然是 词表的大小
    # 但是行数变了,行数变成了 批量大小*时间步数量
    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)
        # 存下初始的隐藏层状态以及forward函数
        # forward函数就是上面定义的rnn函数,因为之后会讲别的更新法则,如GRU,LSTM等,
        # 这样写上使得这个类通用一点,到时候直接传参即可
        self.init_state, self.forward_fn = init_state, forward_fn

    # 该函数的forward函数既可以自定义forward函数,也能重写call函数
    def __call__(self, X, state):
      # 这里的X的形状是(批量大小,时间步数),转置后进行one-hot编码
      # 因为one-hot之后会变成整形,要变成浮点型
      # X的形状是(时间步数,批量大小,词表大小)
        X = F.one_hot(X.T, self.vocab_size).type(torch.float32)
        # 把X放入rnn函数中,可以得到输出以及更新后的状态
        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)

让我们检查输出是否具有正确的形状。 例如,隐状态的维数是否保持不变。

num_hiddens = 512
net = RNNModelScratch(len(vocab), num_hiddens, d2l.try_gpu(), get_params,
                      init_rnn_state, rnn)
# X是(批量大小,时间步数),之前定义的是(2,5)
state = net.begin_state(X.shape[0], d2l.try_gpu())
Y, new_state = net(X.to(d2l.try_gpu()), state)
# Y的形状是(批量大小*时间步数量,词表大小)也就是(2*5,28)
# 其实就是,X表示有10个词,对其中每一个词去预测下一个词是什么
# new_state是一个长为1的tuple,元组中每个元素的形状是(batch_size, num_hiddens),
# 因此也就是(2,521)
Y.shape, len(new_state), new_state[0].shape

运行结果:

在这里插入图片描述
我们可以看到输出形状是(时间步数 × 批量大小,词表大小), 而隐状态形状保持不变,即(批量大小,隐藏单元数)。

4. 预测

让我们首先定义预测函数来生成prefix之后的新字符, 其中的prefix是一个用户提供的包含多个字符的字符串。 在循环遍历prefix中的开始字符时, 我们不断地将隐状态传递到下一个时间步,但是不生成任何输出。 这被称为预热(warm-up)期, 因为在此期间模型会自我更新(例如,更新隐状态), 但不会进行预测。 预热期结束后,隐状态的值通常比刚开始的初始值更适合预测, 从而预测字符并输出它们。

# prefix是给的一段句子的开头,需要根据这个开头,一直生成接下来的词(字符)
# num_preds:需要生成(预测)多少个词(字符)
# ps:在这里vocab用的是字符char
def predict_ch8(prefix, num_preds, net, vocab, device):  
    """在prefix后面生成新字符"""
    # 生成厨师的隐藏状态,因为是对一个字符串做预测,因此batch_size=1
    state = net.begin_state(batch_size=1, device=device)
    # outputs存储的是prefix中第一个字符在词表中对应的下标,
    # 所以一开始outputs这个数组中就只有一个元素
    outputs = [vocab[prefix[0]]]
    # 把outputs中最后一个元素(最新预测完的词)存下来,作为下一个时刻的输入
    # get_input是得到一个1*1的矩阵,批量大小为1,时间步长是1
    # lambda: 是匿名函数
    get_input = lambda: torch.tensor([outputs[-1]], device=device).reshape((1, 1))
    # 预热期
    for y in prefix[1:]: # 因为prefix[0]已经放在outputs中了,假设prefix[0]是't'
    # 就对这之后的词(字符)遍历一下
      # 第一次调用net时,get_input()是prefix[0],我们不关注输出,所以用_
      # 为什么不关注输出呢?
      # 因为在这里我们已经告诉了prefix中的每一个字符(预测结果),所以不需要关注
        _, state = net(get_input(), state) # 就用't'经过net来更新state
        # 把't'的下一个字符'i'放入outputs,下一次循环,又用'i'来进行更新
        # 所以当循环结束时,outputs就得到prefix每个字符对应的idx
        outputs.append(vocab[y])  # outputs后面append的是真实的字符

    # 循环结束后,可知outputs中保存了prefix中每个字符对应的idx,
    # 至此可以知道,这个循环其实就把整个prefix都放进来,用于模型自我更新,更新隐状态
    # 至此,已经把prefix的信息存进了state,这时的隐状态的值通常比刚开始的初始值更适合预测

    # 下面开始真正的预测:给定当前词和之前看到的prefix来预测下一个词,循环往复,预测num_preds步
    for _ in range(num_preds): 
      # 第一次循环,get_input()拿到的是prefix中最后一个字符对应的idx,更新state,拿到输出
      # 之后每次都是拿到上一个时刻的预测y和state做成输入放进net,得到预测y和新的state
        y, state = net(get_input(), state) # y是一个1*vocab_size的向量,因为用了one-hot编码
        # y.argmax(dim=1) 是指拿出每一行的最大值的下标,也就是拿到y这一个向量中1对应的idx
        # 其实在这里就是拿到预测的字符的idx,只是说之前使用了one-hot编码,
        outputs.append(int(y.argmax(dim=1).reshape(1))) # reshape之后直接转成整型放入outputs

    # 总结一下这个循环:第一次用的是prefix的最后一个字符的idx以及存进了prefix的信息的state来预测
    # prefix的下一个字符,以及更新state,再由新的state和刚预测出来的字符继续向下预测,直到预测num_preds步

    #vocab.idx_to_token[i]:给定idx可以转成对应的token
    # outputs是一个存了很多数字(idx)的数组,把每一个数字都转成字符
    # 最后用一个join函数拼接得到字符串,返回
    return ''.join([vocab.idx_to_token[i] for i in outputs])

现在我们可以测试predict_ch8函数。 我们将前缀指定为time traveller, 并基于这个前缀生成10个后续字符。 鉴于我们还没有训练网络,它会生成荒谬的预测结果。

predict_ch8('time traveller ', 10, net, vocab, d2l.try_gpu())

运行结果:

在这里插入图片描述

5. 梯度裁剪

对于长度为 的序列,我们在迭代中计算这 个时间步上的梯度, 将会在反向传播过程中产生长度为 O(T) 的矩阵乘法链。 当 较大时,它可能导致数值不稳定, 例如可能导致梯度爆炸或梯度消失。 因此,循环神经网络模型往往需要额外的方式来支持稳定训练。

下面我们定义一个函数来裁剪模型的梯度, 模型是从零开始实现的模型或由高级API构建的模型。 我们在此计算了所有模型参数的梯度的范数。

79.循环神经网络的从零开始实现_第2张图片

# 全局参数梯度剪裁
def grad_clipping(net, theta):  
    """裁剪梯度"""
    # ps:下面的if else是把网络的所有层的可以参与训练的参数都拿出来了
    if isinstance(net, nn.Module):
      # 如果net是nn.Module中的,就把需要梯度的参数都拿出来放到一个数组中
        params = [p for p in net.parameters() if p.requires_grad]
    else: # 如果net是自定义的,就通过net.params拿到参数
        params = net.params

    # 把所有的层里面的参数的梯度进行平方之后求和,然后对所有层求和,再开根号
    # 把所有的层的梯度拉成一个向量,然后把这些向量全部拼在一起,拼成一个特别长的向量,再对其求L2 Norm
    norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
    if norm > theta:
        for param in params:
          # [:] 这是一个replace操作,原地改写
            param.grad[:] *= theta / norm

6. 训练

在训练模型之前,让我们定义一个函数在一个迭代周期内训练模型。 它与我们训练 softmax模型从零实现的方式有三个不同之处。

  1. 序列数据的不同采样方法(随机采样和顺序分区)将导致隐状态初始化的差异。
  2. 我们在更新模型参数之前裁剪梯度。 这样的操作的目的是,即使训练过程中某个点上发生了梯度爆炸,也能保证模型不会发散。
  3. 我们用困惑度来评价模型。这样的度量确保了不同长度的序列具有可比性。

具体来说,当使用顺序分区时, 我们只在每个迭代周期的开始位置初始化隐状态。 由于下一个小批量数据中的第 个子序列样本 与当前第 个子序列样本相邻, 因此当前小批量数据最后一个样本的隐状态, 将用于初始化下一个小批量数据第一个样本的隐状态。 这样,存储在隐状态中的序列的历史信息 可以在一个迭代周期内流经相邻的子序列。

然而,在任何一点隐状态的计算, 都依赖于同一迭代周期中前面所有的小批量数据, 这使得梯度计算变得复杂。 为了降低计算量,在处理任何一个小批量数据之前, 我们先分离梯度,使得隐状态的梯度计算总是限制在一个小批量数据的时间步内。

当使用随机抽样时,因为每个样本都是在一个随机位置抽样的, 因此需要为每个迭代周期重新初始化隐状态。 与softmax_scratch中的 train_epoch_ch3函数相同, updater是更新模型参数的常用函数。 它既可以是从头开始实现的d2l.sgd函数, 也可以是深度学习框架中内置的优化函数。

# 随机抽样:下一个批量的第i个样本和上一个批量的第i个样本是没有任何关系的
# 顺序分区:下一个批量的第i个样本是接着上一个批量的第i个样本,也就是在原文本是相邻的
# 二者的选取会导致隐藏状态更新的差异
def train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter):
    """训练网络一个迭代周期(定义见第8章)"""
    state, timer = None, d2l.Timer()
    metric = d2l.Accumulator(2)  # 训练损失之和,词元数量
    for X, Y in train_iter:
        if state is None or use_random_iter: # 如果state为None,当然要初始化state
            # 使用随机抽样时初始化state:在每一个iteraton(或者batch)的时候,把state重新初始化为0
            # 为什么呢? 原因是:前面那个时刻的序列信息和当前序列的信息不是连续的,
            # 所以上一个批量的state不应该用到这一个批量上,因为在时序上是不连续的,
            # 所以每一个当前的批量都要把state初始化为0,每一个当前的批量都是新的序列
            state = net.begin_state(batch_size=X.shape[0], device=device)
        else:  # 如果是顺序分区且不是第一个小批量的话,就不会对state做重新初始化,
        # 只做detach:做了detach的话,在做backward的时候,前面的计算图就分离了,也就是分离梯度,
        # 使得隐状态的梯度计算总是限制在一个小批量数据的时间步内。
        # 也就是说:断开前面的链式求导
            if isinstance(net, nn.Module) and not isinstance(state, tuple):
                # state对于nn.GRU是个张量
                state.detach_()
            else:
                # state对于nn.LSTM或对于我们从零开始实现的模型是个张量
                for s in state:
                    s.detach_()
        y = Y.T.reshape(-1)
        X, y = X.to(device), y.to(device)
        y_hat, state = net(X, state)
        l = loss(y_hat, y.long()).mean()
        if isinstance(updater, torch.optim.Optimizer):
            updater.zero_grad()
            l.backward()
            grad_clipping(net, 1) # 在更新之前做一次梯度剪裁
            updater.step()
        else:
            l.backward()
            grad_clipping(net, 1)
            # 因为已经调用了mean函数
            updater(batch_size=1)
        metric.add(l * y.numel(), y.numel())
    # math.exp(metric[0] / metric[1])是困惑度
    return math.exp(metric[0] / metric[1]), metric[1] / timer.stop()

关于detach掉state的理解:顺序抽样时,state用的上次得到的结果来做初始化。不过不detach的话,就会保留上一个batch计算下来的梯度,但是我们只要这个数值来做初始化,而不需要它之前的梯度,所以要detach。
另一个评论:因为一个文本很长,如果用顺序批次的话,计算图会在不同的批次之间不断累积,如果用zero _grad()只是讲计算图中前面的梯度值清零,不会改变计算图的大小,反传的时候还是要全部算一遍;而detach则是每换一个批次时将前面一个批次的计算图全部删除,这样计算图最大也只有一个批次这么大,不会随着一个由一个批次而不断变大,也就减小了计算量。

循环神经网络模型的训练函数既支持从零开始实现, 也可以使用高级API来实现。

def train_ch8(net, train_iter, vocab, lr, num_epochs, device,
              use_random_iter=False):
    """训练模型(定义见第8章)"""
    loss = nn.CrossEntropyLoss() # 虽然是语言模型,但其实是一个标准的多分类问题
    animator = d2l.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: d2l.sgd(net.params, lr, batch_size)
    # 给一个prefix,往后预测50个字符
    predict = lambda prefix: predict_ch8(prefix, 50, net, vocab, device)
    # 训练和预测
    for epoch in range(num_epochs):
        ppl, speed = train_epoch_ch8(
            net, train_iter, loss, updater, device, use_random_iter)
        if (epoch + 1) % 10 == 0:
            print(predict('time traveller'))
            animator.add(epoch + 1, [ppl])
    print(f'困惑度 {ppl:.1f}, {speed:.1f} 词元/秒 {str(device)}')
    print(predict('time traveller'))
    print(predict('traveller'))

现在,我们训练循环神经网络模型。因为我们在数据集中只使用了10000个词元, 所以模型需要更多的迭代周期来更好地收敛。

num_epochs, lr = 500, 1
train_ch8(net, train_iter, vocab, lr, num_epochs, d2l.try_gpu())

运行结果:

79.循环神经网络的从零开始实现_第3张图片

困惑度训练到了1.0:困惑度最好的情况就是1.0,loss已经很低很低了,可以理解为差不把文本记住了。

可以看到到第300个epoch时,差不多就记住了。

最后,让我们检查一下使用随机抽样方法的结果。

net = RNNModelScratch(len(vocab), num_hiddens, d2l.try_gpu(), get_params,
                      init_rnn_state, rnn)
train_ch8(net, train_iter, vocab, lr, num_epochs, d2l.try_gpu(),
          use_random_iter=True)

运行结果:

79.循环神经网络的从零开始实现_第4张图片
从零开始实现上述循环神经网络模型, 虽然有指导意义,但是并不方便。 在下一节中,我们将学习如何改进循环神经网络模型。 例如,如何使其实现地更容易,且运行速度更快。

你可能感兴趣的:(深度学习,深度学习,rnn)