【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

时间:2022-09-26 11:29:56

  ????大家好,我是Sonhhxg_柒,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流????

????个人主页-Sonhhxg_柒的博客_CSDN博客 ????

????欢迎各位→点赞???? + 收藏⭐️ + 留言????​

????系列专栏 - 机器学习【ML】 自然语言处理【NLP】  深度学习【DL】

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

 ????foreword

✔说明⇢本人讲解主要包括Python、机器学习(ML)、深度学习(DL)、自然语言处理(NLP)等内容。

如果你对这个系列感兴趣的话,可以关注订阅哟????

在上一章中,我们专注于卷积神经网络CNN )。我们介绍了 CNN 架构的构建块以及如何在 PyTorch 中实现深度 CNN。最后,您学习了如何使用 CNN 进行图像分类。在本章中,我们将探索循环神经网络RNN ) 并了解它们在序列数据建模中的应用。

我们将涵盖以下主题:

  • 引入顺序数据
  • 用于建模序列的 RNN
  • 长短期记忆
  • 随时间截断的反向传播
  • 在 PyTorch 中实现用于序列建模的多层 RNN
  • 项目一:IMDb影评数据集的RNN情感分析
  • 项目二:使用来自儒勒·凡尔纳的《神秘岛》的文本数据,使用 LSTM 单元进行 RNN 字符级语言建模
  • 使用渐变剪裁来避免爆炸渐变

引入顺序数据

让我们通过查看 RNN 的性质来开始我们对 RNN 的讨论序列数据,通常称为序列数据或序列。我们将研究使它们与其他类型的数据不同的序列的独特属性。然后,我们将了解如何表示序列数据并探索基于模型输入和输出的序列数据模型的各种类别。这将有助于我们在本章中探索 RNN 和序列之间的关系。

建模顺序数据——顺序很重要

是什么使得与其他类型的数据相比,序列的独特之处在于序列中的元素以一定的顺序出现,并且彼此之间并不独立。用于监督学习的典型机器学习算法假设输入独立同分布IID)数据,这意味着训练样本是相互独立的,并且具有相同的底层分布。在这方面,基于相互独立的假设,将训练样例提供给模型的顺序是无关紧要的。例如,如果我们有一个包含n 个训练示例的样本,则(1) , (2) , ..., ( n ),我们使用数据来训练我们的机器学习算法的顺序并不重要。这种情况的一个例子是我们之前使用的 Iris 数据集。在 Iris 数据集中,每朵花都是独立测量的,一朵花的测量值不会影响另一朵花的测量值。

然而,当我们处理序列时,这个假设是无效的——根据定义,顺序很重要。预测特定股票的市场价值就是这种情况的一个例子。例如,假设我们有一个包含n 个训练样例的样本,其中每个训练样例代表某只股票在特定日期的市场价值。如果我们的任务是预测未来三天的股票市场价值,那么按照日期排序的顺序考虑以前的股票价格来推导趋势而不是按随机顺序使用这些训练示例是有意义的。

序列数据与时间序列数据

时间序列数据是特殊类型的顺序数据,其中每个示例都与时间维度相关联。在时间序列数据中,样本是在连续的时间戳上进行的,因此,时间维度决定了数据点之间的顺序。例如,股票价格和语音或语音记录是时间序列数据。

另一方面,并​​非所有的顺序数据有时间维度。为了例如,在文本数据或 DNA 序列中,示例是有序的,但文本或 DNA 不符合时间序列数据的条件。正如您将看到的,在本章中,我们将重点介绍非时间序列数据的自然语言处理 (NLP) 和文本建模示例。但是请注意,RNN 也可以用于时间序列数据,这超出了本书的范围。

表示序列

我们已经建立了这个顺序数据点之间的排序在顺序数据中很重要,因此我们接下来需要找到一种在机器学习模型中利用这种排序信息的方法。在本章中,我们将序列表示为

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

。上标索引表示实例的顺序,序列的长度为T。对于一个合理的序列示例,请考虑时间序列数据,其中每个示例点( t )属于特定时间t图 15.1显示了一个时间序列数据示例,其中输入特征 ( x ) 和目标标签 ( y ) 自然地按照时间轴的顺序排列;因此,两个x's 和y是序列。

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

图 15.1:时间序列数据示例

正如我们已经提到的,到目前为止,我们已经介绍了标准的 NN 模型,例如多层感知器MLP ) 和用于图像数据的 CNN 假设训练示例彼此独立,因此不包含排序信息。我们可以说这样的模型没有以前见过的训练例子的记忆。例如,样本通过前馈和反向传播步骤,权重的更新与训练样本的处理顺序无关。

相比之下,RNN 是为序列建模而设计的,能够记住过去的信息并相应地处理新事件,这在处理序列数据时是一个明显的优势。

不同类别的序列建模

序列建模有许多引人入胜的应用,例如语言翻译(例如,将文本从英语翻译成德语)、图像字幕和文本生成。然而,为了选择合适的架构和方法,我们必须理解并能够区分这些不同的序列建模任务。图 15.2基于Andrej Karpathy 2015 年 ( The Unreasonable Effectiveness of Recurrent Neural Networks ) 的优秀文章The Unreasonable Effectiveness of Recurrent Neural Networks中的解释,总结了最常见的序列建模任务,依赖于输入和输出数据的关系类别。【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

图 15.2:最常见的排序任务

让我们更详细地讨论上图中描述的输入和输出数据之间的不同关系类别。如果输入和输出数据都不代表序列,那么我们处理的是标准数据,我们可以简单地使用多层感知器(或本书前面介绍的其他分类模型)对此类数据进行建模。但是,如果输入或输出是一个序列,则建模任务可能属于以下类别之一:

  • 多对一输入数据是一个序列,但输出是一个固定大小的向量或标量,而不是一个序列。例如,在情感分析中,输入是基于文本的(例如,电影评论),输出是类标签(例如,表示评论者是否喜欢电影的标签)。
  • 一对多输入数据是标准格式而不是序列,但输出是序列。此类别的一个示例是图像字幕——输入是图像,输出是总结该图像内容的英文短语。
  • 多对多:两者输入和输出数组是序列。这个类别可以根据输入和输出是否同步来进一步划分。同步多对多建模任务的一个示例是视频分类,其中视频中的每一帧都被标记。延迟多对多建模任务的一个示例是将一种语言翻译成另一种语言。例如,在翻译成德语之前,必须由机器阅读和处理整个英语句子。

现在,在总结了序列建模的三大类之后,我们可以继续讨论 RNN 的结构。

用于建模序列的 RNN

在本节中,在我们开始之前在 PyTorch 中实现 RNN,我们将讨论 RNN 的主要概念。我们将从查看典型结构开始一个RNN,它包括一个用于对序列数据建模的递归组件。然后,我们将研究如何在典型的 RNN 中计算神经元激活。这将为我们讨论训练 RNN 中的常见挑战创造一个背景,然后我们将讨论这些挑战的解决方案,例如 LSTM门控循环单元GRU)。

理解 RNN 中的数据流

让我们从RNN 的架构。图 15.3并排显示了标准前馈 NN 和 RNN 中的数据流以进行比较:【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

图 15.3:标准前馈 NN 和 RNN 的数据流

这两个网络都只有一个隐藏层。在此表示中,不显示单元,但我们假设输入层 ( x )、隐藏层 ( h ) 和输出层 ( o ) 是包含许多单元的向量。

确定 RNN 的输出类型

这种通用的 RNN 架构可以对应于输入是序列的两个序列建模类别。通常,循环层可以返回一个序列作为输出,【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)或者简单地返回最后一个输出(在t  =  T处,即( T ))。因此,它可以是多对多的,也可以是多对一的,例如,如果我们只使用最后一个元素( T )作为最终输出。

稍后我们将在 PyTorch 模块中看到这是如何处理的torch.nn,当我们详细了解循环层在将序列作为输出返回时的行为时。

在一个标准前馈网络,信息从输入流向隐藏层,然后从隐藏层流向输出层。另一方面,在 RNN 中,隐藏层从当前时间步的输入层和前一个时间步的隐藏层接收其输入。

隐藏层中相邻时间步长的信息流使网络能够记忆过去的事件。这种信息流通常显示为一个循环,也称为循环作为图形符号中的循环边,这就是这种通用 RNN 架构的名称。

与多层感知器类似,RNN 可以由多个隐藏层组成。请注意,将具有一个隐藏层的 RNN 称为单层 RNN是一种常见的约定,不要与没有隐藏层的单层 NN 混淆,例如 Adaline 或逻辑回归。图 15.4展示了一个带有一个隐藏层(顶部)的 RNN 和一个带有两个隐藏层(底部)的 RNN:【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

图 15.4:具有一个和两个隐藏层的 RNN 示例

检查RNN 的架构和信息流,可以展开具有循环边的紧凑表示,如图 15.4 所示

众所周知,标准 NN 中的每个隐藏单元只接收一个输入——与输入层相关的网络预激活。相比之下,RNN 中的每个隐藏单元都接收两组不同的输入——来自输入层的预激活和来自上一个时间步t  – 1 的相同隐藏层的激活。

在第一个时间步,t  = 0,隐藏单元被初始化为零或小的随机值。然后,在t  > 0 的时间步,隐藏单元从当前时间的数据点( t )接收它们的输入,以及隐藏单元在t  – 1 处的先前值,表示为( t –1 ) .

同样,在多层 RNN 的情况下,我们可以将信息流总结如下:

  • layer  = 1:这里,隐藏层表示为

    【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

    ,它接收来自数据点( t )的输入,以及同一层中的隐藏值,但在前一个时间步,。
  • layer  = 2:第二个隐藏层 ,在当前时间步(【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

因为在这种情况下,每个循环层都必须接收一个序列作为输入,所以除了最后一个循环层之外的所有循环层都必须返回一个序列作为输出(也就是说,我们稍后必须设置return_sequences=True)。最后一个循环层的行为取决于问题的类型。

在 RNN 中计算激活

现在你了解RNN 中的结构和一般信息流,让我们更具体地计算隐藏层以及输出层的实际激活。为简单起见,我们只考虑一个隐藏层;然而,同样的概念也适用于多层 RNN。

我们刚刚看到的 RNN 表示中的每个有向边(框之间的连接)都与一个权重矩阵相关联。这些权重不依赖于时间t;因此,它们在时间轴上共享。单层RNN中不同的权重矩阵如下:

  • xh :输入( t )和隐藏层h之间的权重矩阵
  • hh : 与循环边相关的权重矩阵
  • Who :隐藏层和输出层之间的权重矩阵

这些权重矩阵如图 15.5 所示【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

图 15.5:将权重应用于单层 RNN

在某些实现中,您可能会观察到权重矩阵xh和hh连接到组合矩阵h  = [ xh ; ]。在本节的后面部分,我们也将使用这种表示法。

计算激活与标准多层感知器和其他类型的前馈神经网络非常相似。对于隐藏层,通过线性组合计算净输入h(预激活);也就是说,我们计算权重矩阵与相应向量的乘积之和,并添加偏置单元:【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

 然后,隐藏单元在时间步t的激活值计算如下:【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

 这里,h是隐藏的偏置向量单位

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

是隐藏层的激活函数。

如果您想使用连接的权重矩阵,h  = [ xh ; hh ],计算隐藏单元的公式会发生变化,如下:

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

一旦计算了当前时间步的隐藏单元的激活,那么将计算输出单元的激活,如下所示:【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

为了帮助进一步阐明这一点,图 15.6显示了使用两种公式计算这些激活的过程:【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

图 15.6:计算激活

使用随时间的反向传播 (BPTT) 训练 RNN

RNN 的学习算法是在 1990 年引入的:Backpropagation Through Time: What It does and How to Do ( Paul Werbos , Proceedings of IEEE , 78(10): 1550-1560, 1990)。

梯度的推导可能有点复杂,但基本思想是总损失L是时间t  = 1 到t  =  T的所有损失函数的总和:【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

 由于时间t的损失取决于所有先前时间步骤 1 :  t的隐藏单元,因此梯度将按如下方式计算:【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

 在这里,【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)计算为相邻时间步长的乘积:

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

隐藏重复与输出重复

到目前为止,你已经看到循环网络,其中隐藏层具有循环性。但是,请注意,还有另一种模型,其中循环连接来自输出层。在这种情况下,可以通过以下两种方式之一添加上一个时间步的输出层的净激活值t –1 :

  • 到当前时间步的隐藏层,t(在图 15.7中显示为输出到隐藏的递归)
  • 到当前时间步的输出层,t(在图 15.7中显示为输出到输出的递归)【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

图 15.7:不同的循环连接模型

如图 15.7所示,这些架构之间的差异可以在重复连接中清楚地看到。按照我们的符号,与循环连接相关的权重将用hh表示隐藏到隐藏的循环,用oh表示输出到隐藏的循环,用oo表示输出到输出的循环. 在一些文献中,与循环连接相关的权重也用rec表示。

看看这个如何在实践中工作,让我们手动计算这些循环类型之一的前向传递。使用该torch.nn模块,可以通过定义循环层RNN,这类似于隐藏到隐藏的循环。在以下代码中,我们将从长度为 3 的输入序列创建循环层RNN并执行前向传递以计算输出。我们还将手动计算前向传递并将结果与RNN​​ .

首先,让我们创建层并为我们的手动计算分配权重和偏差:

>>> import torch
>>> import torch.nn as nn
>>> torch.manual_seed(1)
>>> rnn_layer = nn.RNN(input_size=5, hidden_size=2,
...                    num_layers=1, batch_first=True)
>>> w_xh = rnn_layer.weight_ih_l0
>>> w_hh = rnn_layer.weight_hh_l0
>>> b_xh = rnn_layer.bias_ih_l0
>>> b_hh = rnn_layer.bias_hh_l0
>>> print('W_xh shape:', w_xh.shape)
>>> print('W_hh shape:', w_hh.shape)
>>> print('b_xh shape:', b_xh.shape)
>>> print('b_hh shape:', b_hh.shape)
W_xh shape: torch.Size([2, 5])
W_hh shape: torch.Size([2, 2])
b_xh shape: torch.Size([2])
b_hh shape: torch.Size([2])

该层的输入形状是(batch_size, sequence_length, 5),其中第一个维度是批量维度(我们设置batch_first=True),第二个维度对应于序列,最后一个维度对应于特征。请注意,我们将输出一个序列,其中,对于长度为 3 的输入序列将产生输出序列【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)。此外,RNN默认使用一层,您可以设置num_layers将多个 RNN 层堆叠在一起,形成一个堆叠的 RNN。

现在,我们将调用前向传递rnn_layer并手动计算每个时间步的输出并比较它们:

>>> x_seq = torch.tensor([[1.0]*5, [2.0]*5, [3.0]*5]).float()
>>> ## output of the simple RNN:
>>> output, hn = rnn_layer(torch.reshape(x_seq, (1, 3, 5)))
>>> ## manually computing the output:
>>> out_man = []
>>> for t in range(3):
...     xt = torch.reshape(x_seq[t], (1, 5))
...     print(f'Time step {t} =>')
...     print('   Input           :', xt.numpy())
...     
...     ht = torch.matmul(xt, torch.transpose(w_xh, 0, 1)) + b_hh
...     print('   Hidden          :', ht.detach().numpy()
...     
...     if t > 0:
...         prev_h = out_man[t-1]
...     else:
...         prev_h = torch.zeros((ht.shape))
...     ot = ht + torch.matmul(prev_h, torch.transpose(w_hh, 0, 1)) \
...             + b_hh
...     ot = torch.tanh(ot)
...     out_man.append(ot)
...     print('   Output (manual) :', ot.detach().numpy())
...     print('   RNN output      :', output[:, t].detach().numpy())
...     print()
Time step 0 =>
   Input           : [[1. 1. 1. 1. 1.]]
   Hidden          : [[-0.4701929  0.5863904]]
   Output (manual) : [[-0.3519801   0.52525216]]
   RNN output      : [[-0.3519801   0.52525216]]
Time step 1 =>
   Input           : [[2. 2. 2. 2. 2.]]
   Hidden          : [[-0.88883156  1.2364397 ]]
   Output (manual) : [[-0.68424344  0.76074266]]
   RNN output      : [[-0.68424344  0.76074266]]
Time step 2 =>
   Input           : [[3. 3. 3. 3. 3.]]
   Hidden          : [[-1.3074701  1.886489 ]]
   Output (manual) : [[-0.8649416   0.90466356]]
   RNN output      : [[-0.8649416   0.90466356]]

在我们的手动前向计算中,我们使用了双曲正切 (tanh) 激活函数,因为它也用于RNN(默认激活)。从打印结果中可以看出,手动前向计算的输出与RNN每个时间步的层输出完全匹配。希望这项实践任务能够启发您了解循环网络的奥秘。

学习远程交互的挑战

BPTT,其中前面简要提到过,介绍了一些新的挑战。由于乘法因子 ,【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)在计算损失函数的梯度时,会出现所谓的梯度消失爆炸问题。

这些问题由图 15.8中的示例解释,为简单起见,它显示了一个只有一个隐藏单元的 RNN:

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

图 15.8:计算损失函数梯度的问题

基本上,

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

t  –  k次乘法;因此,将权重w自身乘以t  –  k倍会得到一个因子t – k。因此,如果 | w | < 1,当t  -  k很大时,这个因素变得非常小。另一方面,如果循环边的权重为 | w | > 1,则当t  -  k很大时, t - k变得非常大。请注意,大的t  –  k指的是长期依赖。我们可以看到,通过确保 | 可以达到避免梯度消失或爆炸的简单解决方案。w | = 1. 如果您有兴趣并想对此进行更多调查详细信息,请阅读R. PascanuT. MikolovY. Bengio的关于训练递归神经网络的难度,2012 年 ( https://arxiv.org/pdf/1211.5063.pdf )。

在实践中,这个问题至少有三种解决方案:

  • 渐变剪裁
  • 随时间截断的反向传播TBPTT )
  • 长短期记忆体

使用梯度裁剪,我们为梯度指定一个截止值或阈值,并将这个截止值分配给超过这个值的梯度值。相比之下,TBPTT 只是限制了信号在每次前向传播后可以反向传播的时间步数。例如,即使序列有 100 个元素或步长,我们也可能只反向传播最近的 20 个时间步长。

虽然渐变剪裁和TBPTT可以解决爆炸梯度问题,截断限制了梯度可以有效回流并正确更新权重的步数。另一方面,由 Sepp Hochreiter 和 Jürgen Schmidhuber 于 1997 年设计的 LSTM 在通过使用存储单元对长程依赖性进行建模的同时,更成功地解决了梯度消失和爆炸问题。让我们更详细地讨论 LSTM。

长短期记忆细胞

如前所述,LSTM 是首次引入以克服梯度消失问题(S. HochreiterJ. SchmidhuberLong Short-Term MemoryNeural Computation,9(8): 1735-1780, 1997)。一个构建块LSTM 是一个记忆单元,它本质上代表或替代了标准 RNN 的隐藏层。

正如我们所讨论的,在每个存储单元中,都有一个具有理想权重w  = 1 的循环边,以克服梯度消失和爆炸的问题。与此循环边相关联的值统称为称为细胞状态。现代 LSTM 单元的展开结构如图 15.9所示:【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

图 15.9:LSTM 单元的结构

请注意,前一个时间步长的细胞状态( t –1)被修改以获取当前时间步长( t )的细胞状态,而无需直接乘以任何权重因子。这个存储单元中的信息流由几个计算单元(通常称为)控制,这些计算单元将被描述here。图中,

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

逐元素乘积(逐元素乘法),表示逐元素求和(逐元素加法)。此外,( t )指的是输入数据时间t( t –1)表示时间t  – 1 的隐藏单元。四个框用激活函数表示,或者是 sigmoid 函数 ( ) 或 tanh,以及一组权重;这些框通过对其输入(即( t –1)和( t ))执行矩阵向量乘法来应用线性组合。这些使用 sigmoid 的计算单元激活函数,其输出单元通过,是称为门。

在 LSTM 单元中,存在三种不同类型的门,称为遗忘门、输入门和输出门:

遗忘门( f )允许存储单元重置单元状态而不会无限增长。事实上,遗忘门决定了哪些信息可以通过,哪些信息可以抑制。现在,t计算如下:

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

请注意,遗忘门不是原始 LSTM 单元的一部分;几年后它被添加以改进原始模型(学习忘记:F. GersJ. SchmidhuberF. Cummins的LSTM 连续预测,神经计算 12 , 2451-2471, 2000)。

输入门( i )和候选值

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

) 负责用于更新细胞状态。它们被计算为如下:【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

时间t的单元状态计算如下:【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

输出门( o )决定如何更新隐藏单元的值:

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

鉴于此,当前时间步的隐藏单元计算如下:

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

一个结构LSTM 单元及其底层计算可能看起来非常复杂且难以实现。然而,好消息是 PyTorch 已经在优化的包装函数中实现了所有内容,这使我们能够轻松有效地定义 LSTM 单元。我们将在本章后面将 RNN 和 LSTM 应用于现实世界的数据集。

其他高级 RNN 模型

LSTM 为序列中的远程依赖关系建模提供了一种基本方法。然而,重要的是要注意文献中描述的 LSTM 的许多变体(Rafal JozefowiczWojciech ZarembaIlya Sutskever的循环网络架构的实证探索,ICML 会议记录,2342-2350,2015)。另外值得注意的是一种更新的方法,门控循环单元GRU ),它于 2014 年提出。GRU 的架构比 LSTM 更简单;因此,它们的计算效率更高,而它们在某些任务(例如和弦音乐建模)中的性能可与 LSTM 相媲美。如果您有兴趣了解有关这些现代 RNN 架构的更多信息,请参阅Junyoung Chung等人在序列建模上的门控循环神经网络的实证评估,2014 年 ( https://arxiv.org/pdf/1412.3555v1.pdf)。

在 PyTorch 中为序列建模实现 RNN

现在我们有了涵盖了背后的基本理论RNN,我们准备好进入本章更实用的部分:在 PyTorch 中实现 RNN。在本章的其余部分,我们将把 RNN 应用于两个常见的问题任务:

  1. 情绪分析
  2. 语言建模

这两个项目,我们将在接下来的几页中一并介绍,它们既引人入胜,也相当投入。因此,我们不会一次提供所有代码,而是将实现分成几个步骤并详细讨论代码。如果您想在深入讨论之前有一个全面的概览并希望一次查看所有代码,请先查看代码实现。

项目一——预测IMDb电影评论的情绪

您可能还记得第 8 章将机器学习应用于情感分析,情感分析涉及分析句子或文本文档的表达意见。在本节和以下小节中,我们将实现一个多层 RNN使用多对一架构的情感分析。

在下一节中,我们将为语言建模应用实现多对多 RNN。虽然选择的示例有意简单地介绍 RNN 的主要概念,但语言建模具有广泛的有趣应用,例如构建聊天机器人——使计算机能够直接与人类交谈和交互。

准备电影评论数据

第 8 章中,我们预处理和清理评论数据集。我们现在也会这样做。首先,我们将导入必要的模块并从中读取数据torchtext(我们将通过 安装pip install torchtext;截至 2021 年底使用的是 0.10.0 版),如下所示:

>>> from torchtext.datasets import IMDB
>>> train_dataset = IMDB(split='train')
>>> test_dataset = IMDB(split='test')

每组有 25,000 个样本。数据集的每个样本都包含两个元素,代表我们要预测的目标标签的情感标签(neg指的是负面情绪和pos指的是正面情绪),以及电影评论文本(输入特征)。这些电影评论的文本组件是单词序列,RNN 模型将每个序列分类为正面 ( 1) 或负面 ( 0) 评论。

然而,在我们将数据输入 RNN 模型之前,我们需要应用几个预处理步骤:

  1. 将训练数据集拆分为单独的训练和验证分区。
  2. 识别训练数据集中的唯一词
  3. 将每个唯一单词映射到唯一整数,并将评论文本编码为编码整数(每个唯一单词的索引)
  4. 将数据集划分为小批量作为模型的输入

让我们继续第一步:根据train_dataset我们之前阅读的内容创建一个训练和验证分区:

>>> ## Step 1: create the datasets
>>> from torch.utils.data.dataset import random_split
>>> torch.manual_seed(1)
>>> train_dataset, valid_dataset = random_split(
...     list(train_dataset), [20000, 5000])

原始训练数据集包含 25,000 个示例。随机选择 20,000 个样本进行训练,5,000 个样本进行验证。

为了准备输入到 NN 的数据,我们需要将其编码为数值,如步骤 23中所述。为此,我们将首先在训练数据集中找到唯一的词(标记)。虽然找到唯一标记是我们可以使用 Python 数据集的过程,但使用包中的Counter类会更有效collections,它是 Python 标准库的一部分。

在下面的代码中,我们将实例化一个新Counter对象 ( token_counts),该对象将收集唯一的词频。请注意,在这个特定的应用程序中(与词袋模型相反),我们只对唯一词集感兴趣,而不需要作为副产品创建的词数。要将文本拆分为单词(或标记),我们将重用tokenizer我们在第 8 章中开发的函数,该函数还删除了 HTML 标记以及标点符号和其他非字母字符:

收集唯一令牌的代码如下:

>>> ## Step 2: find unique tokens (words)
>>> import re
>>> from collections import Counter, OrderedDict
>>> 
>>> def tokenizer(text):
...     text = re.sub('<[^>]*>', '', text)
...     emoticons = re.findall(
...         '(?::|;|=)(?:-)?(?:\)|\(|D|P)', text.lower()
...     )
...     text = re.sub('[\W]+', ' ', text.lower()) +\
...         ' '.join(emoticons).replace('-', '')
...     tokenized = text.split()
...     return tokenized
>>> 
>>> token_counts = Counter()
>>> for label, line in train_dataset:
...     tokens = tokenizer(line)
...     token_counts.update(tokens)
>>> print('Vocab-size:', len(token_counts))
Vocab-size: 69023

如果您想了解更多关于的信息,请参阅collections — Container datatypes — Python 3.10.7 documentationCounter上的文档。

接下来,我们是将每个唯一的单词映射到一个唯一的整数。这可以使用 Python 字典手动完成,其中键是唯一标记(单词),与每个键关联的值是唯一整数。但是,该torchtext包已经提供了一个类 ,Vocab我们可以使用它来创建这样的映射并对整个数据集进行编码。首先,我们将vocab通过将有序字典映射标记传递给它们对应的出现频率来创建一个对象(有序字典是 sorted token_counts)。其次,我们将在词汇表中添加两个特殊标记——填充和未知标记:

>>> ## Step 3: encoding each unique token into integers
>>> from torchtext.vocab import vocab
>>> sorted_by_freq_tuples = sorted(
...     token_counts.items(), key=lambda x: x[1], reverse=True
... )
>>> ordered_dict = OrderedDict(sorted_by_freq_tuples)
>>> vocab = vocab(ordered_dict)
>>> vocab.insert_token("<pad>", 0)
>>> vocab.insert_token("<unk>", 1)
>>> vocab.set_default_index(1)

为了演示如何使用该vocab对象,我们将示例输入文本转换为整数值列表:

>>> print([vocab[token] for token in ['this', 'is',
...     'an', 'example']])
[11, 7, 35, 457]

请注意,验证或测试数据中可能有一些标记不存在于训练数据中,因此不包含在映射中。如果我们有q个标记(即token_counts传递给的大小Vocab,在本例中为 69,023),那么之前未见过的所有标记,因此未包含在 中token_counts,将被分配整数 1(占位符对于未知令牌)。换句话说,索引 1 是为未知词保留的。另一个保留值是整数 0,它用作占位符,即所谓的填充标记,用于调整序列长度。稍后,当我们在 PyTorch 中构建 RNN 模型时,我们将更详细地考虑这个占位符 0。

我们可以定义text_pipeline函数来相应地转换数据集中的每个文本,以及label_pipeline将每个标签转换为 1 或 0 的函数:

>>> ## Step 3-A: define the functions for transformation
>>> text_pipeline =\
...      lambda x: [vocab[token] for token in tokenizer(x)]
>>> label_pipeline = lambda x: 1. if x == 'pos' else 0.

我们将使用先前声明的数据处理管道生成批量样本DataLoader并将其传递给参数collate_fn。我们将文本编码和标签转换函数包装到collate_batch函数中:

 ## Step 3-B: wrap the encode and transformation function
... def collate_batch(batch):
...     label_list, text_list, lengths = [], [], []
...     for _label, _text in batch:
...         label_list.append(label_pipeline(_label))
...         processed_text = torch.tensor(text_pipeline(_text),
...                                       dtype=torch.int64)
...         text_list.append(processed_text)
...         lengths.append(processed_text.size(0))
...     label_list = torch.tensor(label_list)
...     lengths = torch.tensor(lengths)
...     padded_text_list = nn.utils.rnn.pad_sequence(
...         text_list, batch_first=True)
...     return padded_text_list, label_list, lengths
>>> 
>>> ## Take a small batch
>>> from torch.utils.data import DataLoader
>>> dataloader = DataLoader(train_dataset, batch_size=4,
...                         shuffle=False, collate_fn=collate_batch)

到目前为止,我们已经将单词序列转换为整数序列,将 or 的标签pos转换neg为 1 或 0。但是,我们需要解决一个问题 - 序列当前具有不同的长度(如执行结果所示以下代码为四个示例)。尽管通常 RNN 可以处理不同长度的序列,但我们仍然需要确保 mini-batch 中的所有序列都具有相同的长度,以便将它们有效地存储在张量中。

PyTorch提供了一种有效的方法,pad_sequence()它会自动使用占位符值 (0) 填充要组合到批次中的连续元素,以便批次中的所有序列都具有相同的形状。在前面的代码中,我们已经从训练数据集中创建了一个小批量的数据加载器并应用了该collate_batch函数,该函数本身包含一个pad_sequence()调用。

但是,为了说明填充的工作原理,我们将获取第一批并打印各个元素的大小,然后再将它们组合成小批量,以及生成的小批量的尺寸:

>>> text_batch, label_batch, length_batch = next(iter(dataloader))
>>> print(text_batch)
tensor([[   35,  1742,     7,   449,   723,     6,   302,     4,
...
0,     0,     0,     0,     0,     0,     0,     0]],
>>> print(label_batch)
tensor([1., 1., 1., 0.])
>>> print(length_batch)
tensor([165,  86, 218, 145])
>>> print(text_batch.shape)
torch.Size([4, 218])

从打印的张量形状中可以看出,第一批的列数为 218,这是将前四个示例组合成一个批次并使用这些示例的最大大小的结果。这意味着该批次中的其他三个示例(其长度分别为 165、86 和 145)将根据需要填充以匹配此大小。

最后,让我们将所有三个数据集划分为批量大小为 32 的数据加载器:

>>> batch_size = 32
>>> train_dl = DataLoader(train_dataset, batch_size=batch_size,
...                       shuffle=True, collate_fn=collate_batch)
>>> valid_dl = DataLoader(valid_dataset, batch_size=batch_size,
...                       shuffle=False, collate_fn=collate_batch)
>>> test_dl = DataLoader(test_dataset, batch_size=batch_size,
...                      shuffle=False, collate_fn=collate_batch)

现在,数据的格式适合 RNN 模型,我们将在以下小节中实现。然而,在下一小节中,我们将首先讨论特征嵌入,这是一个可选但强烈推荐的预处理步骤,用于降低词向量的维数。

用于句子编码的嵌入层

在此期间在上一步的数据准备中,我们生成了相同长度的序列。这些序列的元素是对应于唯一词索引的整数。这些词索引可以通过几种不同的方式转换为输入特征。一种天真的方法是应用 one-hot 编码将索引转换为 0 和 1 的向量。然后,每个词将被映射到一个向量,其大小是整个数据集中唯一词的数量。鉴于唯一词的数量(词汇表的大小)可以在 10 4  – 10 5的数量级,这也将是数字在我们的输入特征中,在这些特征上训练的模型可能会遭受维度灾难。此外,这些特征非常稀疏,因为除了一之外都为零。

一种更优雅的方法是将每个单词映射到具有实值元素(不一定是整数)的固定大小的向量。与 one-hot 编码向量相比,我们可以使用有限大小的向量来表示无限数量的实数。(理论上,我们可以从给定的区间中提取无限实数,例如 [–1, 1]。)

这就是嵌入背后的想法,这是一种特征学习技术,我们可以在这里利用它来自动学习显着特征来表示我们数据集中的单词。给定唯一词的数量n 个词,我们可以选择嵌入向量的大小(也就是嵌入维度)远小于唯一词的数量(embedding_dim  <<  n 个词)来表示整个词汇表作为输入特征.

嵌入的优势one-hot编码如下:

  • 降低特征空间的维数以减少维数灾难的影响
  • 可以优化(或学习)神经网络中嵌入层以来的显着特征提取

以下示意图表示通过将标记索引映射到可训练的嵌入矩阵来显示嵌入的工作原理:【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

图 15.10:嵌入工作原理的分解

给定一组大小为n  + 2 的标记(n是标记集的大小,加上索引 0 为填充占位符保留,1 用于标记集中不存在的单词),大小为 ( n  + 2) ×  embedding_dim将被创建,其中该矩阵的每一行表示与令牌相关的数字特征。因此,当一个整数索引i作为嵌入的输入时,它将在索引i处查找矩阵的相应行并返回数字特征。嵌入矩阵用作我们的 NN 模型的输入层。在实践中,创建嵌入层可以简单地使用nn.Embedding. 让我们看一个例子,我们将创建一个嵌入层并应用它到一批两个样品,如下:

>>> embedding = nn.Embedding(
...     num_embeddings=10,
...     embedding_dim=3,
...     padding_idx=0)
>>> # a batch of 2 samples of 4 indices each
>>> text_encoded_input = torch.LongTensor([[1,2,4,5],[4,3,2,0]])
>>> print(embedding(text_encoded_input))
tensor([[[-0.7027,  0.3684, -0.5512],
         [-0.4147,  1.7891, -1.0674],
         [ 1.1400,  0.1595, -1.0167],
         [ 0.0573, -1.7568,  1.9067]],
        [[ 1.1400,  0.1595, -1.0167],
         [-0.8165, -0.0946, -0.1881],
         [-0.4147,  1.7891, -1.0674],
         [ 0.0000,  0.0000,  0.0000]]], grad_fn=<EmbeddingBackward>)

该模型的输入(嵌入层)必须具有秩 2,维度为batchsize  ×  input_length,其中input_length是序列的长度(此处为 4)。例如,小批量中的输入序列可以是 <1, 5, 9, 2>,其中该序列的每个元素都是唯一词的索引。输出的维度为batchsize  ×  input_length  ×  embedding_dim,其中embedding_dim是嵌入特征的大小(此处设置为 3)。提供给嵌入层的另一个参数num_embeddings对应于模型将作为输入接收的唯一整数值​​(例如,n + 2,在此处设置为 10)。因此,这种情况下的嵌入矩阵的大小为 10×6。

padding_idx表示填充的标记索引(此处为 0),如果指定,则不会对训练期间的梯度更新做出贡献。在我们的例子中,第二个样本的原始序列的长度是 3,我们用 1 个元素 0 填充它。填充元素的嵌入输出是 [0, 0, 0]。

构建 RNN 模型

现在我们准备好了建立一个RNN模型。使用nn.Module该类,我们可以组合嵌入层、RNN 的循环层和全连接的非循环层。对于循环层,我们可以使用以下任何实现:

  • RNN:一个常规的RNN层,即全连接的循环层
  • LSTM:一个长短期记忆RNN,可用于捕获长期依赖关系
  • GRU:具有门控循环单元的循环层,如K. Cho等人在 2014 年使用 RNN 编码器-解码器进行统计机器翻译的学习短语表示中提出的那样( https://arxiv.org/abs/1406.1078v3),如LSTM 的替代品

为了了解如何使用这些循环层之一构建多层 RNN 模型,在以下示例中,我们将创建一个具有两个类型为 的循环层的 RNN 模型RNN。最后,我们将添加一个非循环的全连接层作为输出层,它将返回单个输出值作为预测:

>>> class RNN(nn.Module):
...     def __init__(self, input_size, hidden_size):
...         super().__init__()
...         self.rnn = nn.RNN(input_size, hidden_size, num_layers=2,
...                           batch_first=True)
...         # self.rnn = nn.GRU(input_size, hidden_size, num_layers,
...         #                   batch_first=True)
...         # self.rnn = nn.LSTM(input_size, hidden_size, num_layers,
...         #                    batch_first=True)
...         self.fc = nn.Linear(hidden_size, 1)
...
...     def forward(self, x):
...         _, hidden = self.rnn(x)
...         out = hidden[-1, :, :] # we use the final hidden state
...                                # from the last hidden layer as
...                                # the input to the fully connected
...                                # layer
...         out = self.fc(out)
...         return out
>>>
>>> model = RNN(64, 32)
>>> print(model)
>>> model(torch.randn(5, 3, 64))
RNN(
  (rnn): RNN(64, 32, num_layers=2, batch_first=True)
  (fc): Linear(in_features=32, out_features=1, bias=True)
)
tensor([[ 0.0010],
        [ 0.2478],
        [ 0.0573],
        [ 0.1637],
        [-0.0073]], grad_fn=<AddmmBackward>)

如您所见,构建一个使用这些循环层的 RNN 模型非常简单。在下一小节中,我们将回到我们的情感分析任务并构建一个 RNN 模型来解决这个问题。

为情感分析任务构建 RNN 模型

既然我们有很长的序列,我们是将使用 LSTM 层来解决长期影响。我们将创建一个用于情感分析的 RNN 模型,从一个嵌入层开始,生成特征大小为 20 ( embed_dim=20) 的词嵌入。然后,将添加一个 LSTM 类型的循环层。最后,我们将添加一个全连接层作为隐藏层,另一个全连接层作为输出层,这将通过逻辑 sigmoid 激活返回单个类成员概率值作为预测:

>>> class RNN(nn.Module):
...     def __init__(self, vocab_size, embed_dim, rnn_hidden_size,
...                  fc_hidden_size):
...         super().__init__()
...         self.embedding = nn.Embedding(vocab_size,
...                                       embed_dim,
...                                       padding_idx=0)
...         self.rnn = nn.LSTM(embed_dim, rnn_hidden_size,
...                            batch_first=True)
...         self.fc1 = nn.Linear(rnn_hidden_size, fc_hidden_size)
...         self.relu = nn.ReLU()
...         self.fc2 = nn.Linear(fc_hidden_size, 1)
...         self.sigmoid = nn.Sigmoid()
...
...     def forward(self, text, lengths):
...         out = self.embedding(text)
...         out = nn.utils.rnn.pack_padded_sequence(
...             out, lengths.cpu().numpy(), enforce_sorted=False, batch_first=True
...         )
...         out, (hidden, cell) = self.rnn(out)
...         out = hidden[-1, :, :]
...         out = self.fc1(out)
...         out = self.relu(out)
...         out = self.fc2(out)
...         out = self.sigmoid(out)
...         return out
>>> 
>>> vocab_size = len(vocab)
>>> embed_dim = 20
>>> rnn_hidden_size = 64
>>> fc_hidden_size = 64
>>> torch.manual_seed(1)
>>> model = RNN(vocab_size, embed_dim,
                rnn_hidden_size, fc_hidden_size)
>>> model
RNN(
  (embedding): Embedding(69025, 20, padding_idx=0)
  (rnn): LSTM(20, 64, batch_first=True)
  (fc1): Linear(in_features=64, out_features=64, bias=True)
  (relu): ReLU()
  (fc2): Linear(in_features=64, out_features=1, bias=True)
  (sigmoid): Sigmoid()
)

现在我们将开发train训练功能在给定数据集上建立一个 epoch 的模型并返回分类精度和损失:

>>> def train(dataloader):
...     model.train()
...     total_acc, total_loss = 0, 0
...     for text_batch, label_batch, lengths in dataloader:
...         optimizer.zero_grad()
...         pred = model(text_batch, lengths)[:, 0]
...         loss = loss_fn(pred, label_batch)
...         loss.backward()
...         optimizer.step()
...         total_acc += (
...             (pred >= 0.5).float() == label_batch
...         ).float().sum().item()
...         total_loss += loss.item()*label_batch.size(0)
...     return total_acc/len(dataloader.dataset), \
...            total_loss/len(dataloader.dataset)

同样,我们将开发evaluate功能测量模型在给定数据集上的性能:

>>> def evaluate(dataloader):
...     model.eval()
...     total_acc, total_loss = 0, 0
...     with torch.no_grad():
...         for text_batch, label_batch, lengths in dataloader:
...             pred = model(text_batch, lengths)[:, 0]
...             loss = loss_fn(pred, label_batch)
...             total_acc += (
...                 (pred>=0.5).float() == label_batch
...             ).float().sum().item()
...             total_loss += loss.item()*label_batch.size(0)
...     return total_acc/len(dataloader.dataset), \
...            total_loss/len(dataloader.dataset)

下一步是创建损失函数和优化器(Adam 优化器)。对于具有单个类成员概率输出的二元分类,我们使用二元交叉熵损失 (· BCELoss) 作为损失函数:

>>> loss_fn = nn.BCELoss()
>>> optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

现在我们将训练模型 10 个 epoch 并显示训练和验证性能:

>>> num_epochs = 10
>>> torch.manual_seed(1)
>>> for epoch in range(num_epochs):
...     acc_train, loss_train = train(train_dl)
...     acc_valid, loss_valid = evaluate(valid_dl)
...     print(f'Epoch {epoch} accuracy: {acc_train:.4f}'
...           f' val_accuracy: {acc_valid:.4f}')
Epoch 0 accuracy: 0.5843 val_accuracy: 0.6240
Epoch 1 accuracy: 0.6364 val_accuracy: 0.6870
Epoch 2 accuracy: 0.8020 val_accuracy: 0.8194
Epoch 3 accuracy: 0.8730 val_accuracy: 0.8454
Epoch 4 accuracy: 0.9092 val_accuracy: 0.8598
Epoch 5 accuracy: 0.9347 val_accuracy: 0.8630
Epoch 6 accuracy: 0.9507 val_accuracy: 0.8636
Epoch 7 accuracy: 0.9655 val_accuracy: 0.8654
Epoch 8 accuracy: 0.9765 val_accuracy: 0.8528
Epoch 9 accuracy: 0.9839 val_accuracy: 0.8596

训练结束后这个模型 10 个 epoch,我们将根据测试数据对其进行评估:

>>> acc_test, _ = evaluate(test_dl)
>>> print(f'test_accuracy: {acc_test:.4f}')
test_accuracy: 0.8512

它显示了 85% 的准确率。(请注意,与 IMDb 数据集上使用的最先进的方法相比,这个结果并不是最好的。目的只是展示 RNN 在 PyTorch 中的工作原理。)

更多关于双向 RNN

此外,我们将设置to的bidirectional配置,这将使循环层从两个方向、开始到结束以及相反方向通过输入序列:LSTMTrue

>>> class RNN(nn.Module):
...     def __init__(self, vocab_size, embed_dim,
...                  rnn_hidden_size, fc_hidden_size):
...         super().__init__()
...         self.embedding = nn.Embedding(
...             vocab_size, embed_dim, padding_idx=0
...         )
...         self.rnn = nn.LSTM(embed_dim, rnn_hidden_size,
...                            batch_first=True, bidirectional=True)
...         self.fc1 = nn.Linear(rnn_hidden_size*2, fc_hidden_size)
...         self.relu = nn.ReLU()
...         self.fc2 = nn.Linear(fc_hidden_size, 1)
...         self.sigmoid = nn.Sigmoid()
...
...     def forward(self, text, lengths):
...         out = self.embedding(text)
...         out = nn.utils.rnn.pack_padded_sequence(
...             out, lengths.cpu().numpy(), enforce_sorted=False, batch_first=True
...         )
...         _, (hidden, cell) = self.rnn(out)
...         out = torch.cat((hidden[-2, :, :],
...                          hidden[-1, :, :]), dim=1)
...         out = self.fc1(out)
...         out = self.relu(out)
...         out = self.fc2(out)
...         out = self.sigmoid(out)
...         return out
>>> 
>>> torch.manual_seed(1)
>>> model = RNN(vocab_size, embed_dim,
...             rnn_hidden_size, fc_hidden_size)
>>> model
RNN(
  (embedding): Embedding(69025, 20, padding_idx=0)
  (rnn): LSTM(20, 64, batch_first=True, bidirectional=True)
  (fc1): Linear(in_features=128, out_features=64, bias=True)
  (relu): ReLU()
  (fc2): Linear(in_features=64, out_features=1, bias=True)
  (sigmoid): Sigmoid()
)

双向 RNN 层对每个输入序列进行两次传递:前向传递和反向或反向传递(请注意,不要将这与反向传播上下文中的正向和反向传递混淆)。这些前向和后向传递的结果隐藏状态通常连接成单个隐藏状态。其他合并模式包括求和、乘法(将两遍的结果相乘)和平均(取两者的平均值)。

我们还可以尝试其他类型的循环层,例如常规的RNN. 然而,事实证明,使用常规循环层构建的模型将无法达到良好的预测性能(即使是在训练数据上)。例如,如果您尝试将前面代码中的双向 LSTM 层替换为单向nn.RNN(而不是nn.LSTM)层并在全长序列上训练模型,您可能会观察到在训练期间损失甚至不会减少。原因是该数据集中的序列太长,因此具有RNN层的模型无法学习长期依赖关系,并且可能会遇到梯度消失或爆炸的问题。

项目二——PyTorch 中的字符级语言建模

语言建模是一种令人着迷的应用程序,使机器能够执行人类与语言相关的任务,例如生成英语句子。该领域的一项有趣研究是Ilya SutskeverJames MartensGeoffrey E. Hinton使用递归神经网络生成文本,第 28 届机器学习国际会议 (ICML-11) 论文集,2011 ( https://pdfs .semanticscholar.org/93c2/0e38c85b69fc2d2eb314b3c1217913f7db11.pdf)。

在我们现在要构建的模型中,输入是一个文本文档,我们的目标是开发一个模型,该模型可以生成与输入文档风格相似的新文本。这种输入的示例是特定编程语言的书或计算机程序。

在字符级语言建模中,输入被分解成一系列字符,一次一个字符地输入我们的网络。网络将处理每个新字符结合之前看到的字符的记忆来预测下一个。

图 15.11显示了字符级语言的示例建模(注意 EOS 代表“序列结束”):【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

图 15.11:字符级语言建模

我们可以将此实现分解为三个独立的步骤:准备数据、构建 RNN 模型以及执行下一个字符预测和采样以生成新​​文本。

预处理数据集

在本节中,我们将为字符级语言建模准备数据。

要获取输入数据,请访问 Project Gutenberg 网站https://www.gutenberg.org/,该网站提供数千本免费电子书。对于我们的示例,您可以从https://www.gutenberg.org/files/1268/1268-0.txt以纯文本格式下载儒勒·凡尔纳 (Jules Verne) 的书The Mysterious Island(出版于 1874 年)。

请注意,此链接将直接带您到下载页面。如果您使用的是 macOS 或 Linux 操作系统,您可以在终端中使用以下命令下载文件:

curl -O https://www.gutenberg.org/files/1268/1268-0.txt

如果该资源在未来变得不可用,该文本的副本也会包含在本书代码存储库中的本章代码目录中,网址为GitHub - rasbt/machine-learning-book: Code Repository for Machine Learning with PyTorch and Scikit-Learn

下载数据集后,我们可以将其作为纯文本读入 Python 会话。使用以下代码,我们将直接从下载的文件中读取文本并从开头和结尾删除部分(这些包含对古腾堡项目的某些描述)。然后,我们将创建一个 Python 变量 ,char_set它表示在此文本中观察到的一组唯一字符:

>>> import numpy as np
>>> ## Reading and processing text
>>> with open('1268-0.txt', 'r', encoding="utf8") as fp:
...     text=fp.read()
>>> start_indx = text.find('THE MYSTERIOUS ISLAND')
>>> end_indx = text.find('End of the Project Gutenberg')
>>> text = text[start_indx:end_indx]
>>> char_set = set(text)
>>> print('Total Length:', len(text))
Total Length: 1112350
>>> print('Unique Characters:', len(char_set))
Unique Characters: 80

下载和预处理文本,我们有一个由总共 1,112,350 个字符和 80 个唯一字符组成的序列。然而,大多数 NN 库和 RNN 实现无法处理字符串格式的输入数据,这就是为什么我们必须将文本转换为数字格式的原因。为此,我们将创建一个简单的 Python 字典,将每个字符映射到一个整数char2int. 我们还需要反向映射来将模型的结果转换回文本。尽管可以使用将整数键与字符值相关联的字典来完成相反的操作,但使用 NumPy 数组并索引数组以将索引映射到这些唯一字符更有效。图 15.12显示了将字符转换为整数的示例,反之则为单词"Hello""world":

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

图 15.12:字符和整数映射

构建字典以将字符映射为整数,并通过索引 NumPy 数组进行反向映射,如上图所示,如下所示:

>>> chars_sorted = sorted(char_set)
>>> char2int = {ch:i for i,ch in enumerate(chars_sorted)}
>>> char_array = np.array(chars_sorted)
>>> text_encoded = np.array(
...     [char2int[ch] for ch in text],
...     dtype=np.int32
... )
>>> print('Text encoded shape:', text_encoded.shape)
Text encoded shape: (1112350,)
>>> print(text[:15], '== Encoding ==>', text_encoded[:15])
>>> print(text_encoded[15:21], '== Reverse ==>',
...       ''.join(char_array[text_encoded[15:21]]))
THE MYSTERIOUS == Encoding ==> [44 32 29  1 37 48 43 44 29 42 33 39 45 43  1]
[33 43 36 25 38 28] == Reverse ==> ISLAND

text_encodedNumPy _数组包含文本中所有字符的编码值。现在,我们将从这个数组中打印出前五个字符的映射:

>>> for ex in text_encoded[:5]:
...     print('{} -> {}'.format(ex, char_array[ex]))
44 -> T
32 -> H
29 -> E
1 ->  
37 -> M

现在,让我们退后一步,看看我们正在努力做的事情的大局。对于文本生成任务,我们可以将问题表述为分类任务。

假设我们有一组不完整的文本字符序列,如图 15.13所示:

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

图 15.13:预测文本序列的下一个字符

图 15.13中,我们可以将左侧框中显示的序列视为输入。为了生成新文本,我们的目标是设计一个模型,可以预测给定输入序列的下一个字符,其中输入序列表示不完整的文本。例如,在看到“深度学习”之后,模型应该预测“i”作为下一个字符。鉴于我们有 80 个独特的字符,这个问题就变成了一个多类分类任务。

从长度为 1 的序列(即一个字母)开始,我们可以基于这种多类分类方法迭代生成新文本,如图 15.14 所示

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

图 15.14:基于这种多类分类方法生成下一个文本

实施PyTorch 中的文本生成任务,让我们首先将序列长度裁剪为 40。这意味着输入张量x由 40 个标记组成。在实践中,序列长度会影响生成文本的质量。更长的序列可以产生更有意义的句子。然而,对于较短的序列,该模型可能专注于正确捕获单个单词,而在大多数情况下忽略上下文。尽管较长的序列通常会产生更有意义的句子,但如前所述,对于长序列,RNN 模型在捕获远程依赖关系时会遇到问题。因此,在实践中,为序列长度找到最佳位置和良好值是一个超参数优化问题,我们必须根据经验进行评估。在这里,我们将选择 40,因为它提供了一个很好的权衡。

如上图所示,输入x和目标y偏移一个字符。因此,我们将文本分成大小为 41 的块:前 40 个字符将形成输入序列x,最后 40 个元素将形成目标序列y

我们已经按照原始顺序将整个编码文本存储在text_encoded. 我们将首先创建由 41 个字符组成的文本块。如果最后一个块少于 41 个字符,我们将进一步删除它。因此,名为 的新分块数据集text_chunks将始终包含大小为 41 的序列。然后将使用 41 个字符的块来构造序列x(即输入)以及序列y(即, 目标), 两者都有 40 个元素。例如,序列x将由索引为 [0, 1, ..., 39] 的元素组成。此外,由于序列y将相对于x移动一个位置,其对应的索引将是 [1, 2, ..., 40]。Dataset然后,我们将通过应用自定义Dataset类将结果转换为对象:

>>> import torch
>>> from torch.utils.data import Dataset
>>> seq_length = 40
>>> chunk_size = seq_length + 1
>>> text_chunks = [text_encoded[i:i+chunk_size]
...                for i in range(len(text_encoded)-chunk_size)]
>>> from torch.utils.data import Dataset
>>> class TextDataset(Dataset):
...     def __init__(self, text_chunks):
...         self.text_chunks = text_chunks
...
...     def __len__(self):
...         return len(self.text_chunks)
...
...     def __getitem__(self, idx):
...         text_chunk = self.text_chunks[idx]
...         return text_chunk[:-1].long(), text_chunk[1:].long()
>>>
>>> seq_dataset = TextDataset(torch.tensor(text_chunks))

让我们来一个看看这个转换后的数据集中的一些示例序列:

>>> for i, (seq, target) in enumerate(seq_dataset):
...     print(' Input (x): ',
...           repr(''.join(char_array[seq])))
...     print('Target (y): ',
...           repr(''.join(char_array[target])))
...     print()
...     if i == 1:
...         break
 Input (x): 'THE MYSTERIOUS ISLAND ***\n\n\n\n\nProduced b'
Target (y): 'HE MYSTERIOUS ISLAND ***\n\n\n\n\nProduced by'
 Input (x): 'HE MYSTERIOUS ISLAND ***\n\n\n\n\nProduced by'
Target (y): 'E MYSTERIOUS ISLAND ***\n\n\n\n\nProduced by '

最后,准备数据集的最后一步是将此数据集转换为小批量:

>>> from torch.utils.data import DataLoader
>>> batch_size = 64
>>> torch.manual_seed(1)
>>> seq_dl = DataLoader(seq_dataset, batch_size=batch_size,
...                     shuffle=True, drop_last=True)

构建字符级 RNN 模型

现在,数据集已准备就绪,构建模型将相对简单:

>>> import torch.nn as nn
>>> class RNN(nn.Module):
...     def __init__(self, vocab_size, embed_dim, rnn_hidden_size):
...         super().__init__()
...         self.embedding = nn.Embedding(vocab_size, embed_dim)
...         self.rnn_hidden_size = rnn_hidden_size
...         self.rnn = nn.LSTM(embed_dim, rnn_hidden_size,
...                            batch_first=True)
...         self.fc = nn.Linear(rnn_hidden_size, vocab_size)
...
...     def forward(self, x, hidden, cell):
...         out = self.embedding(x).unsqueeze(1)
...         out, (hidden, cell) = self.rnn(out, (hidden, cell))
...         out = self.fc(out).reshape(out.size(0), -1)
...         return out, hidden, cell
...
...     def init_hidden(self, batch_size):
...         hidden = torch.zeros(1, batch_size, self.rnn_hidden_size)
...         cell = torch.zeros(1, batch_size, self.rnn_hidden_size)
...         return hidden, cell

请注意,我们需要将 logits 作为模型的输出,以便我们可以从模型预测中采样以生成新​​文本。我们稍后会谈到这个采样部分。

然后,我们可以指定模型参数并创建 RNN 模型:

>>> vocab_size = len(char_array)
>>> embed_dim = 256
>>> rnn_hidden_size = 512
>>> torch.manual_seed(1)
>>> model = RNN(vocab_size, embed_dim, rnn_hidden_size)
>>> model
RNN(
  (embedding): Embedding(80, 256)
  (rnn): LSTM(256, 512, batch_first=True)
  (fc): Linear(in_features=512, out_features=80, bias=True)
  (softmax): LogSoftmax(dim=1)
)

下一步是创建损失函数和优化器(Adam 优化器)。vocab_size=80对于每个目标字符具有单个 logits 输出的多类分类(我们有类),我们将CrossEntropyLoss其用作损失函数:

>>> loss_fn = nn.CrossEntropyLoss()
>>> optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

现在我们将训练模型 10,000 个 epoch。在每个 epoch 中,我们将只使用从数据加载器中随机选择的一批,seq_dl. 我们还将显示每 500 个 epoch 的训练损失:

>>> num_epochs = 10000
>>> torch.manual_seed(1)
>>> for epoch in range(num_epochs):
...     hidden, cell = model.init_hidden(batch_size)
...     seq_batch, target_batch = next(iter(seq_dl))
...     optimizer.zero_grad()
...     loss = 0
...     for c in range(seq_length):
...         pred, hidden, cell = model(seq_batch[:, c], hidden, cell)
...         loss += loss_fn(pred, target_batch[:, c])
...     loss.backward()
...     optimizer.step()
...     loss = loss.item()/seq_length
...     if epoch % 500 == 0:
...         print(f'Epoch {epoch} loss: {loss:.4f}')
Epoch 0 loss: 1.9689
Epoch 500 loss: 1.4064
Epoch 1000 loss: 1.3155
Epoch 1500 loss: 1.2414
Epoch 2000 loss: 1.1697
Epoch 2500 loss: 1.1840
Epoch 3000 loss: 1.1469
Epoch 3500 loss: 1.1633
Epoch 4000 loss: 1.1788
Epoch 4500 loss: 1.0828
Epoch 5000 loss: 1.1164
Epoch 5500 loss: 1.0821
Epoch 6000 loss: 1.0764
Epoch 6500 loss: 1.0561
Epoch 7000 loss: 1.0631
Epoch 7500 loss: 0.9904
Epoch 8000 loss: 1.0053
Epoch 8500 loss: 1.0290
Epoch 9000 loss: 1.0133
Epoch 9500 loss: 1.0047

接下来,我们可以评估模型以生成新文本,从给定的短字符串开始。在下一节中,我们将定义一个函数来评估训练好的模型。

评估阶段——生成新的文本段落

RNN 模型我们在上一节中训练为每个唯一字符返回大小为 80 的 logits。通过 softmax 函数,这些 logits 可以很容易地转换为概率,即某个特定字符将作为下一个字符遇到。要预测序列中的下一个字符,我们可以简单地选择logit值最大的元素,相当于选择概率最高的字符。但是,我们不是总是选择可能性最高的字符,而是希望(随机)从输出中采样;否则,模型将始终生成相同的文本。PyTorch 已经提供了一个类,torch.distributions.categorical.Categorical,我们可以使用它从分类分布中抽取随机样本。为了看看它是如何工作的,让我们从三个类别 [0, 1, 2] 中生成一些随机样本,输入 logits [1, 1, 1]:

>>> from torch.distributions.categorical import Categorical
>>> torch.manual_seed(1)
>>> logits = torch.tensor([[1.0, 1.0, 1.0]])
>>> print('Probabilities:',
...       nn.functional.softmax(logits, dim=1).numpy()[0])
Probabilities: [0.33333334 0.33333334 0.33333334]
>>> m = Categorical(logits=logits)
>>> samples = m.sample((10,))
>>> print(samples.numpy())
[[0]
 [0]
 [0]
 [0]
 [1]
 [0]
 [1]
 [2]
 [1]
 [1]]

如您所见,随着给定 logits,类别具有相同的概率(即等概率类别)。因此,如果我们使用大样本量(num_samples  → ∞),我们预计每个类别的出现次数将达到样本量的 ≈ 1/3。如果我们将 logits 更改为 [1, 1, 3],那么我们期望观察到类别 2 的更多出现(当从该分布中抽取大量示例时):

>>> torch.manual_seed(1)
>>> logits = torch.tensor([[1.0, 1.0, 3.0]])
>>> print('Probabilities:', nn.functional.softmax(logits, dim=1).numpy()[0])
Probabilities: [0.10650698 0.10650698 0.78698605]
>>> m = Categorical(logits=logits)
>>> samples = m.sample((10,))
>>> print(samples.numpy())
[[0]
 [2]
 [2]
 [1]
 [2]
 [1]
 [2]
 [2]
 [2]
 [2]]

使用Categorical,我们可以根据模型计算的 logits 生成示例。

我们将定义一个函数 ,sample()它接收一个简短的起始字符串 ,starting_str并生成一个新字符串 ,generated_str它最初设置为输入字符串。starting_str被编码为整数序列,encoded_inputencoded_input一次一个字符地传递给 RNN 模型以更新隐藏状态。的最后一个字符encoded_input被传递给模型以生成一个新字符。请注意,RNN 模型的输出表示模型观察输入序列后下一个字符的 logits(这里是大小为 80 的向量,即可能字符的总数)。

在这里,我们只使用logits输出(即( T )),将其传递给Categorical类以生成新的样本。这个新样本被转换为一个字符,然后将其附加到生成的字符串的末尾,generated_text将其长度增加 1。然后,重复此过程,直到生成的字符串的长度达到所需的值。使用生成的序列作为输入来生成新元素的过程是称为自回归

sample()函数的代码如下:

>>> def sample(model, starting_str,
...            len_generated_text=500,
...            scale_factor=1.0):
...     encoded_input = torch.tensor(
...         [char2int[s] for s in starting_str]
...     )
...     encoded_input = torch.reshape(
...         encoded_input, (1, -1)
...     )
...     generated_str = starting_str
...
...     model.eval()
...     hidden, cell = model.init_hidden(1)
...     for c in range(len(starting_str)-1):
...         _, hidden, cell = model(
...             encoded_input[:, c].view(1), hidden, cell
...         )
...    
...     last_char = encoded_input[:, -1]
...     for i in range(len_generated_text):
...         logits, hidden, cell = model(
...             last_char.view(1), hidden, cell
...         )
...         logits = torch.squeeze(logits, 0)
...         scaled_logits = logits * scale_factor
...         m = Categorical(logits=scaled_logits)
...         last_char = m.sample()
...         generated_str += str(char_array[last_char])
...
...     return generated_str

现在让我们生成一些新文本:

>>> torch.manual_seed(1)
>>> print(sample(model, starting_str='The island'))
The island had been made
and ovylore with think, captain?" asked Neb; "we do."
It was found, they full to time to remove. About this neur prowers, perhaps ended? It is might be
rather rose?"
"Forward!" exclaimed Pencroft, "they were it? It seems to me?"
"The dog Top--"
"What can have been struggling sventy."
Pencroft calling, themselves in time to try them what proves that the sailor and Neb bounded this tenarvan's feelings, and then
still hid head a grand furiously watched to the dorner nor his only

如您所见,该模型生成的词大多是正确的,并且在某些情况下,这些句子是部分有意义的。您可以进一步调整训练参数,例如用于训练的输入序列的长度和模型架构。

此外,为了控制生成样本的可预测性(即,根据训练文本中的学习模式生成文本而不是添加更多随机性),RNN 模型计算的 logits 可以在传递给Categorical采样之前进行缩放。比例因子

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

可以解释为物理学中温度的模拟。与较低温度下更可预测的行为相比,较高的温度会导致更多的熵或随机性。通过对 logits 进行缩放,softmax 函数计算的概率变得更加一致,如下面的代码所示:

>>> logits = torch.tensor([[1.0, 1.0, 3.0]])
>>> print('Probabilities before scaling:        ',
...       nn.functional.softmax(logits, dim=1).numpy()[0])
>>> print('Probabilities after scaling with 0.5:',
...       nn.functional.softmax(0.5*logits, dim=1).numpy()[0])
>>> print('Probabilities after scaling with 0.1:',
...       nn.functional.softmax(0.1*logits, dim=1).numpy()[0])
Probabilities before scaling:         [0.10650698 0.10650698 0.78698604]
Probabilities after scaling with 0.5: [0.21194156 0.21194156 0.57611688]
Probabilities after scaling with 0.1: [0.31042377 0.31042377 0.37915245]

如您所见,缩放logits by

【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

结果接近均匀的概率 [0.31, 0.31, 0.38]。现在,我们可以将生成的文本与和进行比较,如下几点所示:【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

  • :
    >>> torch.manual_seed(1)
    >>> print(sample(model, starting_str='The island',
    ...              scale_factor=2.0))
    The island is one of the colony?" asked the sailor, "there is not to be able to come to the shores of the Pacific."
    "Yes," replied the engineer, "and if it is not the position of the forest, and the marshy way have been said, the dog was not first on the shore, and
    found themselves to the corral.
    The settlers had the sailor was still from the surface of the sea, they were not received for the sea. The shore was to be able to inspect the windows of Granite House.
    The sailor turned the sailor was the hor
  • 【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)

    :
    >>> torch.manual_seed(1)
    >>> print(sample(model, starting_str='The island',
    ...              scale_factor=0.5))
    The island
    deep incomele.
    Manyl's', House, won's calcon-sglenderlessly," everful ineriorouins., pyra" into
    truth. Sometinivabes, iskumar gave-zen."
    Bleshed but what cotch quadrap which little cedass
    fell oprely
    by-andonem. Peditivall--"i dove Gurgeon. What resolt-eartnated to him
    ran trail.
    Withinhe)tiny turns returned, after owner plan bushelsion lairs; they were
    know? Whalerin branch I
    pites, Dougg!-iteun," returnwe aid masses atong thoughts! Dak,
    Hem-arches yone, Veay wantzer? Woblding,
    Herbert, omep

结果表明用【NLP】使用递归神经网络对序列数据进行建模 (Pytorch)(增加温度)缩放 logits 会生成更多随机文本。在生成的文本的新颖性和正确性之间需要权衡取舍。

在本节中,我们使用字符级文本生成,这是一个序列到序列 (seq2seq) 建模任务。虽然这个例子本身可能不是很有用,但很容易为这些类型的模型想到几个有用的应用程序;例如,可以将类似的 RNN 模型训练为聊天机器人,以帮助用户进行简单的查询。

概括

在本章中,您首先了解了序列的特性,这些特性使它们不同于其他类型的数据,例如结构化数据或图像。然后,我们介绍了用于序列建模的 RNN 的基础。您了解了基本 RNN 模型的工作原理,并讨论了它在捕获序列数据中的长期依赖关系方面的局限性。接下来,我们介绍了 LSTM 单元,它由一个门控机制组成,以减少梯度爆炸和消失问题的影响,这在基本 RNN 模型中很常见。

在讨论了 RNN 背后的主要概念之后,我们使用 PyTorch 实现了几个具有不同循环层的 RNN 模型。特别是,我们实现了一个用于情感分析的 RNN 模型,以及一个用于生成文本的 RNN 模型。

在下一章中,我们将看到如何使用注意力机制来增强 RNN,这有助于它在翻译任务中建模长期依赖关系。然后,我们将介绍一种称为Transformer的新深度学习架构,该架构最近已被用于进一步推动自然语言处理领域的最新技术。