从零开始实现递归神经网络——【torch学习笔记】

从零开始实现递归神经网络——【torch学习笔记】

引用翻译:《动手学深度学习》

从头开始实现一个语言模型。它是基于H.G.威尔斯的 "时间机器 "所训练的字符级递归神经网络。

import collections
class Vocab(object): 
  def __init__(self, tokens, min_freq=0, use_special_tokens=False):
    # 根据频率和词进行排序
    counter = collections.Counter(tokens)
    token_freqs = sorted(counter.items(), key=lambda x: x[0])
    token_freqs.sort(key=lambda x: x[1], reverse=True)
    if use_special_tokens:
      # padding, 句首, 句尾, unknown
      self.pad, self.bos, self.eos, self.unk = (0, 1, 2, 3)
      tokens = ['', '', '', '']
    else:
      self.unk = 0
      tokens = ['']
    tokens += [token for token, freq in token_freqs if freq >= min_freq]
    self.idx_to_token = []
    self.token_to_idx = dict()
    # len(self.idx_to_token)刚开始为0,这里在逐渐扩大,从0到token的数量
    for token in tokens:
      self.idx_to_token.append(token)
      # 因为初始时前一步self.idx_to_token.append添加了元素,所以后续需要减去1,这样才能从0开始
      self.token_to_idx[token] = len(self.idx_to_token) - 1
      
  def __len__(self):
    return len(self.idx_to_token)
  
  def __getitem__(self, tokens):
    if not isinstance(tokens, (list, tuple)):
      return self.token_to_idx.get(tokens, self.unk)
    else:
      return [self.__getitem__(token) for token in tokens]
    
  def to_tokens(self, indices):
    if not isinstance(indices, (list, tuple)):
      return self.idx_to_token[indices]
    else:
      return [self.idx_to_token[index] for index in indices]

def load_data_time_machine(num_examples=10000):
    """加载数据集."""
    with open('../data/timemachine.txt') as f:
        raw_text = f.read()
    lines = raw_text.split('\n')
    text = ' '.join(' '.join(lines).lower().split())[:num_examples]
    vocab = Vocab(text)
    corpus_indices = [vocab[char] for char in text]
    return corpus_indices, vocab
import sys
sys.path.insert(0, '..')
import d2l
import math
import torch
import torch.nn.functional as F
import torch.nn as nn
import time
corpus_indices, vocab = load_data_time_machine()

数据概况如下:

print(corpus_indices[0:25])
print(vocab)
print(len(vocab))  # 有44个字符
[3, 10, 2, 1, 3, 5, 13, 2, 1, 13, 4, 14, 10, 5, 7, 2, 20, 1, 22, 16, 1, 10, 25, 1, 18]
<__main__.Vocab object at 0x7fb4f3443780>
44

一、独热编码

独热编码向量提供了一种简单的方法,将单词表达为向量,以便在深度网络中处理它们。

简而言之,我们将每个词映射到一个不同的单元向量:假设字典中不同的字符数量为(len(vocab)),每个字符与0到-1的连续整数的索引中的一个值有一一对应关系。

如果一个字符的索引是整数,那么我们创建一个长度为的所有0的向量,并将位置的元素设置为1,这个向量就是原始字符的一热向量。索引为0和2的单热向量如下所示(向量的长度等于字典的大小)。

F.one_hot(torch.Tensor([0, 2]).long(), len(vocab))

输出:

tensor([[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
         0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
         0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]])

请注意,单热编码只是将编码(例如将字符a映射到(1,0,0,…))与嵌入(即将编码向量乘以一些权重矩阵)分离的一种方便方式。相对于存储一个用户需要维护的嵌入矩阵,这大大简化了代码。

我们每次采样的迷你批的形状是(批大小,时间步长)。下面的函数将这样的迷你批次转化为一些形状为(批次大小,字典大小)的矩阵,可以输入到网络中。向量的总数等于时间步骤的数量。也就是说,时间步骤的输入是∈ℝ× ,其中是批处理量,是输入的数量。这就是单热向量长度(字典大小)。

def to_onehot(X,size):
    return F.one_hot(X.long().transpose(0,-1), size)

X = torch.arange(10).reshape((2, 5))
print('X:',X)
inputs = to_onehot(X, len(vocab))
print(len(inputs), inputs[0].shape)  # len(inputs)即批次数量,inputs[0].shape即独热编码后向量的长度
X: tensor([[0, 1, 2, 3, 4],
        [5, 6, 7, 8, 9]])
5 torch.Size([2, 44])

上面的代码生成了5个minibatch,每个包含2个向量。由于我们在 "时间机器 "中总共有43个不同的符号,我们得到43个维度的向量。

二、初始化模型参数

接下来,初始化模型参数。隐藏单元的数量num_hiddens是一个可调整的参数。

num_inputs, num_hiddens, num_outputs = len(vocab), 512, len(vocab)
ctx = d2l.try_gpu()
print('Using', ctx)

# 创建模型的参数,初始化它们并附加梯度
def get_params():
    def _one(shape):
        """按照std=0.01正态分布进行初始化"""
        return torch.Tensor(size=shape, device=ctx).normal_(std=0.01)

    # 隐蔽层参数
    W_xh = _one((num_inputs, num_hiddens))  # xt-1和隐变量的部分权重
    W_hh = _one((num_hiddens, num_hiddens))  # 隐变量的权重
    b_h = torch.zeros(num_hiddens, device=ctx)  # 偏置
    # 输出层参数
    W_hq = _one((num_hiddens, num_outputs))  # 输出层的权重,即括号外的权重
    b_q = torch.zeros(num_outputs, device=ctx)  # 括号外的偏置
    # 附加一个梯度
    params = [W_xh, W_hh, b_h, W_hq, b_q]  # 后续即在这五个权重变量中进行训练
    for param in params:
        param.requires_grad_(True)  # 将权重设定梯度,后续需要进行训练调整
    return params
Using cpu

三、序列建模

四、RNN模型

我们根据RNN的定义来实现这个模型。首先,我们需要一个init_rnn_state函数来返回初始化时的隐藏状态。它返回一个由NDArray组成的元组,其值为0,形状为(批量大小,隐藏单元的数量)。使用元组可以更容易地处理隐藏状态包含多个NDArray的情况(例如,在一个RNN中结合多个层,每个层都需要初始化)。

def init_rnn_state(batch_size, num_hiddens, ctx):
    """初始化RNN的参数"""
    return (torch.zeros(size=(batch_size, num_hiddens), device=ctx), )

下面的rnn函数定义了如何在一个时间步骤中计算隐藏状态和输出。这里的激活函数使用tanh函数。正如多层感知器(chapter_mlp)中所述,当元素在实数上均匀分布时,tanh函数值的平均值为0。

# RNN模型训练,即遍历各个批次,对权重进行迭代优化
def rnn(inputs, state, params):
    # 输入和输出都是由num_steps矩阵组成,其形状为(batch_size, len(vocab))
    W_xh, W_hh, b_h, W_hq, b_q = params
    H, = state  # 代表取元组的第一个元素
    outputs = []
    for X in inputs:
        """参考RNN模型的公式,主要隐变量的部分"""
        H = torch.tanh(torch.matmul(X.float(), W_xh) + torch.matmul(H.float(), W_hh) + b_h)
        Y = torch.matmul(H.float(), W_hq) + b_q
        # H代表前一刻的隐藏状态,第一个字符的前一刻,即初始化的state里的,作为初始状态
        outputs.append(Y)
    return outputs, (H,)

让我们运行一个简单的测试来检查这个模型是否有任何意义。特别是,让我们检查一下输入和输出是否有正确的维度,例如,确保隐藏状态的维度没有改变。

state = init_rnn_state(X.shape[0], num_hiddens, ctx)
inputs = to_onehot(X.to(ctx), len(vocab))
params = get_params()
outputs, state_new = rnn(inputs, state, params)
print('num_inputs, num_hiddens, num_outputs:',num_inputs, num_hiddens, num_outputs)
print('len(outputs), outputs[0].shape, state_new[0].shape :',len(outputs), outputs[0].shape, state_new[0].shape) 
num_inputs, num_hiddens, num_outputs: 44 512 44
len(outputs), outputs[0].shape, state_new[0].shape : 5 torch.Size([2, 44]) torch.Size([2, 512])

五、预测功能

下面的函数根据前缀(一个包含多个字符的字符串)预测下一个num_chars字符。这个函数有点复杂。每当实际的序列是已知的,即对于序列的开始,我们只更新隐藏状态。之后,我们开始生成新的字符,并将它们发射出去。为了方便起见,我们使用递归神经单元rnn作为函数参数,这样这个函数就可以在下面几节中描述的其他递归神经网络中重复使用。

def predict_rnn(prefix, num_chars, rnn, params, init_rnn_state,
                num_hiddens, vocab, ctx):
    state = init_rnn_state(1, num_hiddens, ctx)
    output = [vocab[prefix[0]]]
    for t in range(num_chars + len(prefix) - 1):
        # 前一个时间步骤的输出被作为当前时间步骤的输入。
        X = to_onehot(torch.Tensor([output[-1]],device=ctx), len(vocab))
        # 计算输出并更新隐藏状态
        (Y, state) = rnn(X, state, params)
        # 下一个时间步骤的输入是前缀中的字符或当前的最佳预测字符。
        if t < len(prefix) - 1:
            # 从给定的字符序列中读出
            output.append(vocab[prefix[t + 1]])
        else:
            # 这就是最大似然解码。如果你想使用取样、波束搜索或波束取样来获得更好的序列,请修改这个。
            output.append(int(Y[0].argmax(dim=1).item()))
    return ''.join([vocab.idx_to_token[i] for i in output])

我们首先测试predict_rnn函数。鉴于我们没有训练网络,它将产生无意义的预测结果。我们用序列旅行者初始化它,让它产生10个额外的字符。

predict_rnn('traveller ', 10, rnn, params, init_rnn_state, num_hiddens,
            vocab, ctx)

输出:

'traveller rq rq rq r'

六、梯度剪裁

在解决一个优化问题时,我们对权重在负梯度的大方向上采取更新步骤,比如说-⋅。让我们进一步假设目标是良好的,即它是Lipschitz连续的,有常数,也就是说。

∣ l ( w ) − l ( w ′ ) ∣ ≤ L ∥ w − w ′ ∥ . |l(\mathbf{w}) - l(\mathbf{w}')| \leq L \|\mathbf{w} - \mathbf{w}'\|. l(w)l(w)Lww.

在这种情况下,我们可以有把握地认为,如果我们通过⋅来更新权重向量,我们将不会观察到超过‖‖的变化。这既是一种诅咒也是一种祝福。诅咒是因为它限制了我们取得进展的速度,祝福是因为它限制了如果我们向错误的方向发展,事情可能出错的程度。

有时梯度可能相当大,优化算法可能无法收敛。我们可以通过降低学习率或其他一些高阶技巧来解决这个问题。但如果我们只是很少得到大梯度呢?在这种情况下,这种方法可能显得完全没有必要。一种替代方法是将梯度投射到一个给定半径的球上,比如说,通过以下方式来剪辑梯度:

当g小于时,则取值为1,如果g大于时,则取值为

g ← min ⁡ ( 1 , θ ∥ g ∥ ) g . \mathbf{g} \leftarrow \min\left(1, \frac{\theta}{\|\mathbf{g}\|}\right) \mathbf{g}. gmin(1,gθ)g.

通过这样做,我们知道梯度规范永远不会超过,更新的梯度完全与原始方向一致。它还有一个理想的副作用,即限制任何给定的mini batch(以及其中的任何给定样本)对权重向量的影响。这给模型带来了一定程度的稳健性。回到目前的情况–RNN的优化。其中一个问题是,RNN中的梯度可能会爆炸或消失。考虑到反向传播中涉及的矩阵乘积链。如果矩阵的最大特征值通常大于1,那么许多这样的矩阵的乘积可能远远大于1。因此,聚合梯度可能会爆炸。梯度剪裁提供了一个快速解决方案。虽然它并不能完全解决这个问题,但它是缓解这个问题的众多技术之一。

def grad_clipping(params, theta, ctx):
    norm = torch.Tensor([0], device=ctx)
    for param in params:
        norm += (param.grad ** 2).sum()
    norm = norm.sqrt().item()
    if norm > theta:  # 如果||g||大于时,即g梯度较大时,将其映射到半径为的球体上,所有梯度都不会超过
        # 如||g||小于,则不用管,取g本身即可
        for param in params:
            param.grad.data.mul_(theta / norm)

七、困惑性

衡量一个序列模型工作得如何的一种方法是检查文本的正确程度。一个好的语言模型能够高度准确地预测我们接下来会看到什么。考虑以下由不同语言模型提出的短语It is raining的延续。

  1. It is raining outside
  2. It is raining banana tree
  3. It is raining piouw;kcj pwepoiut

就质量而言,例子1显然是最好的。这些词是合理的,在逻辑上是连贯的。虽然它可能不太准确地反映出哪个词在后面(in San Francisco和in winter将是完全合理的扩展),但该模型能够捕捉到哪种词在后面。例2的情况要糟糕得多,它产生了一个无意义的、边缘的、不符合语法的扩展。尽管如此,至少该模型已经学会了如何拼写单词以及单词之间的某种程度的关联性。最后,例3显示了一个训练有素的模型,不适合数据。

衡量模型质量的一种方法是计算(),即序列的可能性。不幸的是,这是一个难以理解和难以比较的数字。毕竟,较短的序列比长的序列更有可能,因此对托尔斯泰的巨著《战争与和平》进行评估的模型将不可避免地产生一个比圣埃克苏佩里的长篇小说《小王子》小得多的可能性。缺少的是相当于一个平均值。

信息理论在这里很方便。如果我们想压缩文本,我们可以询问在当前符号集的情况下估计下一个符号。

位数的下限是由-log2(|-1, …1)给出的。一个好的语言模型应该允许我们相当准确地预测下一个词,因此它应该允许我们花很少的比特来压缩序列。衡量它的一种方法是我们需要花费的平均比特数。

1 n ∑ t = 1 n − log ⁡ p ( w t ∣ w t − 1 , … w 1 ) = 1 ∣ w ∣ − log ⁡ p ( w ) \frac{1}{n} \sum_{t=1}^n -\log p(w_t|w_{t-1}, \ldots w_1) = \frac{1}{|w|} -\log p(w) n1t=1nlogp(wtwt1,w1)=w1logp(w)

这使得不同长度的文件的性能具有可比性。由于历史原因,自然语言处理领域的科学家们更愿意使用一种叫做 "困惑 "的数量,而不是比特率。简而言之,它是上述数字的指数。

P P L : = exp ⁡ ( − 1 n ∑ t = 1 n log ⁡ p ( w t ∣ w t − 1 , … w 1 ) ) \mathrm{PPL} := \exp\left(-\frac{1}{n} \sum_{t=1}^n \log p(w_t|w_{t-1}, \ldots w_1)\right) PPL:=exp(n1t=1nlogp(wtwt1,w1))

它可以最好地理解为我们在决定下一步选哪个词时拥有的真实选择数量的谐波平均值。请注意,Perplexity自然地概括了我们介绍softmax回归时定义的交叉熵损失的概念(chapter_softmax)。也就是说,对于单个符号来说,这两个定义是相同的,只是一个是另一个的指数。让我们看一下一些情况。

  • 在最好的情况下,该模型总是估计下一个符号的概率为1。在这种情况下,模型的困惑度为1。

  • 在最坏的情况下,模型总是预测标签类别的概率为0,在这种情况下,困惑度是无限的。

  • 在基线上,模型预测的是所有标记的均匀分布。在这种情况下,困惑度等于字典len(vocab)的大小。事实上,如果我们在没有任何压缩的情况下存储序列,这将是我们能做的最好的编码。因此,这提供了一个任何模型都必须满足的非线性上界。

八、训练模型

训练序列模型的过程与以前的代码完全不同。特别是我们需要照顾到以下变化,因为标记是按顺序出现的。

我们使用plexity来评估模型。这确保了不同的测试具有可比性。

在更新模型参数之前,我们对梯度进行剪辑。这确保了即使梯度在训练过程中的某个点爆炸,模型也不会发散(实际上它自动减少了步长)。

顺序数据的不同采样方法(独立采样和顺序分割)将导致隐藏状态初始化的不同。我们在介绍chapter_lang_model_dataset时详细讨论了这些问题。

九、Optimization Loop

def train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
                          corpus_indices, vocab, ctx, is_random_iter,
                          num_epochs, num_steps, lr, clipping_theta,
                          batch_size, 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()  # 本质上就是多分类问题
    start = time.time()
    for epoch in range(num_epochs):
        if not is_random_iter:
            # 如果使用相邻采样,隐藏状态在历时开始时被初始化
            state = init_rnn_state(batch_size, num_hiddens, ctx)
        l_sum, n = 0.0, 0
        data_iter = data_iter_fn(corpus_indices, batch_size, num_steps, ctx)
        for X, Y in data_iter:
            if is_random_iter:
                # 如果使用随机抽样,则在每次小批量更新前初始化隐藏状态
                state = init_rnn_state(batch_size, num_hiddens, ctx)
            else:
                # 否则,需要使用detach函数将隐藏状态从计算图中分离出来,以避免反向传播超出当前样本的范围。
                for s in state:
                    s.detach_()
            inputs = to_onehot(X, len(vocab))
            # 输出是num_steps形状的术语(batch_size, len(vocab))。
            (outputs, state) = rnn(inputs, state, params)
            
            # 缝合后是(num_steps * batch_size, len(vocab))。
            outputs = torch.cat(outputs, dim=0)
            # Y的形状是(batch_size,num_steps),然后变成一个长度为batch * num_steps的转置后的向量。这使它与输出行有一对一的对应关系
            y = Y.t().reshape((-1,))
            """
            是输入X转置与不转置都是可以的,只不过需要根据输入的维度调整模型参数的维度。
            
            若转置,X的维度是(时间步,批量大小,词表大小),每次训练一个batch的时候是按照时间步的维度提取词元。
            
            假设批量大小=2,时间步大小=5,共10个词元。那么每次训练的时候按时间维度提取2个词元,共提取5次。
            
            若X不转置,和上面的流程类似。两者区别在于按时间维度提取的话,参数数量更少,模型更容易训练。
            
            若按批量维度提取,最后一个batch中词元的数目可能不一样,会需要剔除数据或补充。总之,还是按时间维度更好。
            """
            # 通过交叉熵损失的平均分类误差
            l = loss(outputs, y.long()).mean()
            l.backward()
            with torch.no_grad():
                grad_clipping(params, clipping_theta, ctx)  # 梯度剪裁
                d2l.sgd(params, lr, 1)
            # 由于误差是平均值,这里不需要对梯度进行平均。
            l_sum += l.item() * y.numel()
            n += y.numel()
        if (epoch + 1) % 50 == 0:
            print('epoch %d, perplexity %f, time %.2f sec' % (
                epoch + 1, math.exp(l_sum / n), time.time() - start))
            start = time.time()
        if (epoch + 1) % 100 == 0:
            for prefix in prefixes:
                print(' -',  predict_rnn(prefix, 50, rnn, params,
                                         init_rnn_state, num_hiddens,
                                         vocab, ctx))

十、序列模型的实验

现在我们可以训练这个模型了。首先,我们需要设置模型的超参数。为了允许一些有意义的上下文,我们将序列长度设置为64。特别是,我们将看到使用 "单独 "和 "连续 "术语生成的训练将如何影响模型的性能。

num_epochs, num_steps, batch_size, lr, clipping_theta = 500, 64, 32, 1, 1
prefixes = ['traveller', 'time traveller']

让我们使用随机抽样来训练模型并产生一些文本

train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
                      corpus_indices, vocab, ctx, True, num_epochs,
                      num_steps, lr, clipping_theta, batch_size, prefixes)
epoch 50, perplexity 10.920823, time 197.93 sec
epoch 100, perplexity 8.943638, time 191.40 sec
 - travellere the the the the the the the the the the the the 
 - time travellere the the the the the the the the the the the the 
epoch 150, perplexity 7.861344, time 191.90 sec
epoch 200, perplexity 6.732129, time 196.10 sec
 - traveller sthe the the the ght on the the the ght on the th
 - time traveller sthe the the the ght on the the the ght on the th
epoch 250, perplexity 5.622874, time 192.79 sec
epoch 300, perplexity 4.452135, time 194.31 sec
 - traveller. 'but the the begrace te time traveller. 'but the
 - time traveller. 'but the the betrace ore we tre wer all onetoug 
epoch 350, perplexity 3.025274, time 200.60 sec
epoch 400, perplexity 2.108240, time 189.89 sec
 - traveller peread we cal ghest.' 'nos, whing the time travel
 - time traveller peone in to see dament one or the lay ge move abo
epoch 450, perplexity 1.671173, time 195.50 sec
epoch 500, perplexity 1.399303, time 199.80 sec
 - traveller held in his hand was a glittering metallic framew
 - time traveller smiled. 'are you sure we can move freely in space

尽管我们的模型相当原始,但它还是能够产生类似于语言的文本。现在让我们将其与顺序划分进行比较。

train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
                      corpus_indices, vocab, ctx, False, num_epochs,
                      num_steps, lr, clipping_theta, batch_size, prefixes)
epoch 50, perplexity 11.091064, time 192.43 sec
epoch 100, perplexity 8.832573, time 203.01 sec
 - traveller the the the the the the the the the the the the t
 - time traveller the the the the the the the the the the the the t
epoch 150, perplexity 7.666273, time 194.50 sec
epoch 200, perplexity 6.637110, time 193.61 sec
 - traveller anoughist another at allere theng the the ghat in
 - time traveller che mereedinge the ghate the promed anceplong the
epoch 250, perplexity 5.082556, time 200.50 sec
epoch 300, perplexity 3.214104, time 196.70 sec
 - traveller sminne-dimensions ifur ches iluthen arnot?' said 
 - time traveller smowny of shere it an the ractor pramid' 'ore ine
epoch 350, perplexity 1.868414, time 196.00 sec
epoch 400, perplexity 1.371924, time 198.10 sec
 - traveller (ey uthe tre fteres onveramot' of urareerat toan 
 - time traveller ceme it enthe antentions, we cantre tite tho ghe 
epoch 450, perplexity 1.254278, time 194.29 sec
epoch 500, perplexity 1.106602, time 190.61 sec
 - traveller (for so it will be convenient to speak of him) wa
 - time traveller smiled round at us. then, ste ingany'sorracyou th

在下文中,我们将看到如何在现有模式的基础上进行重大改进,以及如何使其更快、更容易实现。

十一、摘要

1、序列模型需要状态初始化来进行训练。

2、在序列模型之间,你需要确保脱离梯度,以确保自动区分的效果不会传播到当前样本之外。

3、一个简单的RNN语言模型由一个编码器、一个RNN模型和一个解码器组成。

4、梯度剪裁可以防止梯度爆炸(但它不能修复消失的梯度)。

5、复杂度(Perplexity)校准了模型在不同序列长度下的性能。它是交叉熵损失的指数化平均值。

6、序列划分通常会导致更好的模型。

十二、练习

1、证明单次编码等同于为每个对象选择不同的嵌入。

2、调整超参数以提高困惑度。

  • 能做到多低?调整嵌入、隐藏单元、学习率等。

  • 它对H.G.威尔斯的其他书的效果如何,例如《世界大战》。

3、在不剪切梯度的情况下运行本节中的代码。会发生什么?

4、将pred_period变量设为1,观察训练不足的模型(高plexity)是如何写出歌词的。能从这里学到什么?

5、改变相邻的采样,使其不从计算图中分离出隐藏状态。运行时间有变化吗?准确性如何?

6、用ReLU替换本节中使用的激活函数,并重复本节的实验。

7、证明困惑度是条件词概率的谐波平均值的倒数。

你可能感兴趣的:(深度学习——torch学习笔记,神经网络,深度学习,RNN,循环神经网络)