当前位置:   article > 正文

PyTorch 与 Sklearn 机器学习指南(六)

PyTorch 与 Sklearn 机器学习指南(六)

原文:zh.annas-archive.org/md5/2a872f7dd98f6fbe3043a236f689e451

译者:飞龙

协议:CC BY-NC-SA 4.0

第十五章:使用循环神经网络对序列数据进行建模

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

我们将涵盖以下主题:

  • 引入序列数据

  • 用于建模序列的 RNN

  • 长短期记忆(Long short-term memory)

  • 截断的时间反向传播

  • 在 PyTorch 中实现多层 RNN 进行序列建模

  • 项目一:对 IMDb 电影评论数据集进行 RNN 情感分析

  • 项目二:使用 LSTM 单元进行 RNN 字符级语言建模,使用朱尔斯·凡尔纳的《神秘岛》文本数据

  • 使用梯度裁剪以避免梯度爆炸

引入序列数据

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

建模序列数据 - 顺序至关重要

与其他类型数据相比,使序列独特的是序列中的元素按一定顺序出现,并且彼此不独立。典型的监督学习机器学习算法假设输入数据为独立同分布IID)数据,这意味着训练样本是相互独立且具有相同的基础分布。在这方面,基于相互独立的假设,训练样本被输入模型的顺序是无关紧要的。例如,如果我们有一个由n个训练样本组成的样本,x^((1)), x^((2)), …, x(n^),那么使用数据训练我们的机器学习算法的顺序就不重要。在我们之前处理的鸢尾花数据集中就是一个例子。在鸢尾花数据集中,每朵花的测量是独立进行的,一朵花的测量不会影响另一朵花的测量。

然而,当我们处理序列时,这种假设就不成立了——根据定义,顺序是重要的。例如,预测特定股票的市场价值就是这种情况的一个例子。假设我们有一个包含 n 个训练示例的样本,其中每个训练示例代表某一天某只股票的市场价值。如果我们的任务是预测接下来三天的股市价值,考虑以日期排序的先前股价以推断趋势会比随机顺序处理这些训练示例更合理。

顺序数据与时间序列数据的区别

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

另一方面,并非所有的顺序数据都具有时间维度。例如,在文本数据或 DNA 序列中,示例是有序的,但文本或 DNA 并不符合时间序列数据的定义。正如你将在本章中看到的那样,我们将专注于自然语言处理 (NLP) 和文本建模的示例,这些不属于时间序列数据。然而,请注意,RNNs 也可以用于时间序列数据,这超出了本书的范围。

表示序列

我们已经确认在顺序数据中,数据点的顺序是重要的,因此我们接下来需要找到一种方法来利用这些顺序信息在机器学习模型中进行应用。在本章中,我们将把序列表示为 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。上标索引表示实例的顺序,序列的长度为 T。作为序列的一个合理示例,考虑时间序列数据,其中每个示例点 x(t^) 都属于特定的时间 t图 15.1 展示了时间序列数据的一个示例,其中输入特征 (x’s) 和目标标签 (y’s) 都按照它们的时间轴自然地遵循顺序;因此,x’s 和 y’s 都是序列。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.1:时间序列数据示例

正如我们之前提到的,迄今为止我们涵盖的标准神经网络模型,如多层感知机 (MLPs) 和用于图像数据的 CNNs,假定训练示例是相互独立的,因此不包括顺序信息。我们可以说这类模型没有对先前观察到的训练示例有所“记忆”。例如,样本通过前向传播和反向传播步骤,权重独立于处理训练示例的顺序而更新。

相比之下,RNNs 是专为建模序列设计的,能够记忆过去的信息并根据新事件进行处理,这在处理序列数据时是一个明显的优势。

序列建模的不同类别

序列建模有许多迷人的应用,例如语言翻译(例如,将文本从英语翻译成德语)、图像字幕和文本生成。然而,为了选择合适的架构和方法,我们必须理解并能够区分这些不同的序列建模任务。图 15.2基于 Andrey Karpathy 在 2015 年撰写的优秀文章循环神经网络的非理性有效性karpathy.github.io/2015/05/21/rnn-effectiveness/),总结了依赖于输入和输出数据关系类别的最常见序列建模任务。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.2:最常见的序列任务

让我们详细讨论输入和输出数据之间不同的关系类别,这些类别在之前的图中已经描述过。如果输入和输出数据都不表示序列,那么我们处理的是标准数据,可以简单地使用多层感知器(或本书中先前介绍过的其他分类模型)来对这些数据进行建模。然而,如果输入或输出是序列之一,建模任务很可能属于以下某一类别:

  • 多对一:输入数据是一个序列,但输出是一个固定大小的向量或标量,而不是序列。例如,在情感分析中,输入是基于文本的(例如电影评论),而输出是一个类别标签(例如表示评论者是否喜欢电影)。

  • 一对多:输入数据是标准格式而不是序列,但输出是一个序列。这一类别的一个例子是图像字幕,输入是图像,输出是总结该图像内容的英文短语。

  • 多对多:输入和输出数组都是序列。这一类别可以进一步根据输入和输出是否同步进行划分。同步多对多建模任务的一个例子是视频分类,其中标记每个视频帧。延迟多对多建模任务的例子是语言翻译,例如,机器必须先读取并处理整个英语句子,然后才能生成其德语翻译。

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

用于序列建模的 RNN

在这一节中,在我们开始在 PyTorch 中实现 RNN 之前,我们将讨论 RNN 的主要概念。我们将首先查看典型 RNN 的结构,其中包括一个递归组件来建模序列数据。然后,我们将检查典型 RNN 中如何计算神经元的激活。这将为我们讨论训练 RNN 时面临的常见挑战创造一个背景,然后我们将讨论这些挑战的解决方案,例如 LSTM 和门控循环单元(GRUs)。

理解 RNN 中的数据流动

让我们从 RNN 的架构开始。图 15.3并排显示了标准前馈 NN 和 RNN 的数据流动,以便进行比较:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

确定 RNN 的输出类型

这种通用的 RNN 架构可以对应两种序列建模类别,其中输入是一个序列。通常,递归层可以返回一个序列作为输出,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,或者仅返回最后一个输出(在t = T时,即o(T))。因此,它可能是多对多,或者如果例如我们仅使用最后一个元素**o**(T)作为最终输出,那么它可能是多对一。

我们将在后面看到,当我们详细研究具有返回序列输出的递归层行为时,这是如何在 PyTorch 的torch.nn模块中处理的。

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

隐藏层中相邻时间步的信息流使得网络能够记住过去的事件。这种信息流通常显示为一个循环,也称为图表中的递归边缘,这也是这种通用 RNN 架构得名的方式。

类似于多层感知器,RNN 可以由多个隐藏层组成。请注意,将只有一个隐藏层的 RNN 称为单层 RNN是一种常见约定,不应与没有隐藏层的单层 NN(如 Adaline 或逻辑回归)混淆。图 15.4展示了具有一个隐藏层(顶部)和具有两个隐藏层(底部)的 RNN:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

为了检验 RNN 的架构和信息流动,可以展开具有递归边缘的紧凑表示,您可以在图 15.4中看到。

正如我们所知,标准 NN 中的每个隐藏单元只接收一个输入——来自输入层的净激活。相比之下,RNN 中的每个隐藏单元接收两个不同的输入集——来自输入层的净激活以及前一个时间步t – 1 的相同隐藏层的激活。

在第一个时间步t = 0 时,隐藏单元被初始化为零或小的随机值。然后,在时间步t > 0 时,隐藏单元接收来自当前时间点数据点x(t)以及上一个时间步中的隐藏单元值**h**(t(–1))的输入。

类似地,在多层 RNN 的情况下,信息流可以总结如下:

  • layer = 1:在这里,隐藏层表示为外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,它从数据点x(t^)以及同一层中但前一个时间步的隐藏值外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传接收输入。

  • layer = 2:第二隐藏层,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,从当前时间步下方图层的输出接收输入(外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传),以及其自身在前一个时间步的隐藏值,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

在 RNN 中计算激活值

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

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

  • W[xh]:输入x(t^)与隐藏层h之间的权重矩阵

  • W[hh]:与递归边关联的权重矩阵

  • W[ho]:隐藏层与输出层之间的权重矩阵

这些权重矩阵如图 15.5 所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.5:应用权重到单层 RNN

在某些实现中,您可能会注意到权重矩阵W[xh]和W[hh]被连接成一个组合矩阵W[h] = [W[xh]; W[hh]]。在本节后面,我们将也会使用这种表示法。

计算激活与标准的多层感知器和其他类型的前馈神经网络非常相似。对于隐藏层,净输入 z[h](预激活)通过线性组合计算;即,我们计算权重矩阵与相应向量的乘积的总和,并添加偏置单元:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

然后,在时间步 t 处计算隐藏单元的激活如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,b[h] 是隐藏单元的偏置向量,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是隐藏层的激活函数。

如果要使用连接的权重矩阵,W[h] = [W[xh]; W[hh]],则计算隐藏单元的公式将发生变化,如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

一旦计算了当前时间步的隐藏单元的激活,那么输出单元的激活将按以下方式计算:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

为了进一步澄清,Figure 15.6 显示了使用这两种形式计算这些激活的过程:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.6:计算激活

使用时间反向传播(BPTT)训练 RNNs

RNNs 的学习算法首次在 1990 年引入:通过时间的反向传播:它的作用及实现方法Paul WerbosIEEE 会议录,78(10):1550-1560,1990)。

梯度的推导可能有些复杂,但基本思想是总体损失 L 是时间 t = 1 到 t = T 所有损失函数的总和:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

由于时间 t 处的损失依赖于所有之前时间步骤 1 : t 的隐藏单元,梯度将按以下方式计算:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是相邻时间步长的乘积计算:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

隐藏层的递归与输出的递归

到目前为止,您已经看到具有隐藏层递归属性的递归网络。然而,请注意,还存在另一种模型,其中递归连接来自输出层。在这种情况下,从上一个时间步的输出层的净激活 ot(–1) 可以通过两种方式之一相加:

  • 到当前时间步的隐藏层,h^t(如在 Figure 15.7 中显示的输出到隐藏递归)

  • 到当前时间步的输出层,o^t(如在 Figure 15.7 中显示的输出到输出递归)

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.7:不同的递归连接模型

图 15.7所示,这些架构之间的差异在递归连接中可以清楚地看到。根据我们的符号约定,与递归连接相关的权重将由隐藏到隐藏的递归表示为 W[hh],由输出到隐藏的递归表示为 W[oh],由输出到输出的递归表示为 W[oo]。在一些文献中,递归连接相关的权重也被表示为 W[rec]。

为了看看这在实践中是如何工作的,让我们手动计算其中一种递归类型的前向传播。使用torch.nn模块,可以通过RNN定义一个递归层,它类似于隐藏到隐藏的递归。在以下代码中,我们将从RNN创建一个递归层,并对长度为 3 的输入序列执行前向传播以计算输出。我们还将手动计算前向传播并将结果与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]) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

这一层的输入形状为(batch_size, sequence_length, 5),其中第一维是批处理维度(因为我们设置了batch_first=True),第二维对应于序列,最后一维对应于特征。请注意,我们将输出一个序列,对于长度为 3 的输入序列,将产生输出序列 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。此外,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]] 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39

在我们的手动前向计算中,我们使用了双曲正切(tanh)激活函数,因为它也用于RNN(默认激活函数)。正如您从打印的结果中看到的那样,手动前向计算的输出在每个时间步长上与RNN层的输出完全匹配。希望这个实际任务能让您对递归网络的奥秘有所启发。

学习长程交互的挑战

BPTT(之前简要提到过)引入了一些新的挑战。由于梯度计算中的乘法因子,称为外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,导致了所谓的消失爆炸梯度问题的产生。

这些问题通过图 15.8中的例子进行了解释,显示了一个仅具有一个隐藏单元的简单 RNN:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

基本上,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传t – k个乘法;因此,将权重w乘以它自己t – k次得到因子wt–^k。因此,如果|w| < 1,当t – k很大时,这个因子变得非常小。另一方面,如果递归边的权重是|w| > 1,则当t – k很大时wt–^k 变得非常大。请注意,大t – k指的是长程依赖性。我们可以看到,避免梯度消失或爆炸的一个朴素解决方案是确保|w| = 1。如果您有兴趣并且希望更详细地研究这一点,请阅读R. PascanuT. MikolovY. Bengio在 2012 年发表的论文*《On the difficulty of training recurrent neural networks》*(arxiv.org/pdf/1211.5063.pdf)。

在实践中,至少有三种解决方案:

  • 梯度裁剪

  • 截断时间反向传播TBPTT

  • LSTM

使用梯度裁剪,我们为梯度指定了一个截断或阈值,并将超过此值的梯度赋予此截断值。相比之下,TBPTT 仅限制了每次前向传递后信号能够反向传播的时间步数。例如,即使序列有 100 个元素或步骤,我们也只能反向传播最近的 20 个时间步。

尽管梯度裁剪和 TBPTT 都可以解决梯度爆炸问题,但截断限制了梯度能够有效流动和适当更新权重的步数。另一方面,1997 年由 Sepp Hochreiter 和 Jürgen Schmidhuber 设计的 LSTM 通过使用记忆细胞在建模长程依赖性时更为成功地解决了梯度消失和爆炸问题。让我们更详细地讨论 LSTM。

长短期记忆细胞

正如之前所述,LSTM 最初是为了解决梯度消失问题而引入的(长短期记忆,由S. HochreiterJ. SchmidhuberNeural Computation,9(8):1735-1780,1997 年提出)。LSTM 的构建模块是一个记忆细胞,它本质上表示或替代标准 RNN 的隐藏层。

在每个记忆细胞中,都有一个具有理想权重w = 1 的递归边,正如我们讨论过的,用来解决梯度消失和爆炸问题。与这个递归边相关联的值被统称为细胞状态。现代 LSTM 细胞的展开结构如图15.9所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.9:LSTM 细胞的结构

请注意,从上一个时间步骤的细胞状态 C(t^(–1)) 修改为获取当前时间步骤的细胞状态 C(t^),而不直接乘以任何权重因子。这个记忆单元中的信息流由几个计算单元(通常称为)控制,这些将在这里描述。在图中,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 指代逐元素乘积外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 表示逐元素求和。此外,x(t^) 指时间 t 的输入数据,h(t^(–1)) 表示时间 t – 1 的隐藏单元。有四个框指示激活函数,可以是 sigmoid 函数(外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传)或 tanh,以及一组权重;这些框通过在它们的输入(h(t^(–1)) 和 x(t^))上执行矩阵-向量乘法来应用线性组合。这些具有 sigmoid 激活函数的计算单元,其输出单元通过 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 传递,称为门。

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

遗忘门f[t])允许记忆单元在不无限增长的情况下重置细胞状态。实际上,遗忘门决定了哪些信息允许通过,哪些信息被抑制。现在,f[t] 计算如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

输入门i[t])和候选值外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传)负责更新细胞状态。它们的计算如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

时间 t 的细胞状态计算如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

给定这一点,当前时间步骤的隐藏单元计算如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

其他高级 RNN 模型

LSTM 提供了一种基本方法来建模序列中的长期依赖性。然而,值得注意的是,文献中描述了许多 LSTM 的变体(Rafal JozefowiczWojciech ZarembaIlya SutskeverAn Empirical Exploration of Recurrent Network ArchitecturesICML 会议论文,2015 年,第 2342-2350 页)。还值得注意的是,2014 年提出了更近期的方法,门控循环单元GRU)。GRU 比 LSTM 具有更简单的架构,因此在计算上更高效,而在某些任务(如多声部音乐建模)中,它们的性能与 LSTM 相当。如果您有兴趣了解这些现代 RNN 架构的更多信息,请参考 Junyoung Chung 等人的论文,Empirical Evaluation of Gated Recurrent Neural Networks on Sequence Modeling,2014 年(arxiv.org/pdf/1412.3555v1.pdf)。

在 PyTorch 中实现序列建模的 RNN

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

  1. 情感分析

  2. 语言建模

我们将在接下来的页面一起步入的这两个项目,既有趣又复杂。因此,我们将代码实现分成几个步骤,并详细讨论代码,而不是一次性提供所有代码。如果您想要有一个全局视图,并在深入讨论之前先看到所有代码,请首先查看代码实现。

第一个项目——预测 IMDb 电影评论的情感

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

在接下来的部分,我们将实现一个用于语言建模的多对多 RNN 应用。虽然所选择的示例故意简单,以介绍 RNN 的主要概念,但语言建模有广泛的有趣应用,例如构建聊天机器人——让计算机直接与人类进行对话和交互。

准备电影评论数据

第八章 中,我们对评论数据集进行了预处理和清洗。现在我们将做同样的事情。首先,我们将导入必要的模块,并从 torchtext 中读取数据(我们将通过 pip install torchtext 进行安装;截至 2021 年末,版本为 0.10.0)如下:

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

每个集合包含 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]) 
  • 1
  • 2
  • 3
  • 4
  • 5

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

为了准备数据以输入到 NN,我们需要将其编码为数字值,如 步骤 23 中提到的那样。为此,我们首先将找到训练数据集中的唯一单词(标记)。虽然查找唯一标记是一个可以使用 Python 数据集完成的过程,但使用 Python 标准库中的 collections 包中的 Counter 类可能更有效。

在下面的代码中,我们将实例化一个新的 Counter 对象(token_counts),它将收集唯一单词的频率。请注意,在这种特定的应用程序中(与词袋模型相反),我们只关注唯一单词集合,而不需要单词计数,这些计数是作为副产品创建的。为了将文本分割成单词(或标记),我们将重用在 第八章 中开发的 tokenizer 函数,该函数还会移除 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 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

如果您想了解更多关于 Counter 的信息,请参阅其文档:docs.python.org/3/library/collections.html#collections.Counter

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

>>> ## 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) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

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

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

请注意,验证或测试数据中可能有些标记不在训练数据中,因此未包含在映射中。如果我们有 q 个标记(即传递给 Vocabtoken_counts 的大小,在本例中为 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. 
  • 1
  • 2
  • 3
  • 4

我们将使用 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) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

到目前为止,我们已经将单词序列转换为整数序列,并将 posneg 的标签转换为 1 或 0。然而,我们需要解决一个问题——当前序列的长度不同(如在执行以下代码对四个示例进行操作后所示)。尽管通常 RNN 可以处理不同长度的序列,但我们仍然需要确保一个小批量中的所有序列具有相同的长度,以便在张量中有效地存储它们。

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]) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

正如您从打印的张量形状中可以观察到的那样,第一个批次的列数为 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) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

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

用于句子编码的嵌入层

在前一步骤的数据准备过程中,我们生成了相同长度的序列。这些序列的元素是整数,对应于唯一单词的索引。这些单词索引可以以多种方式转换为输入特征。一个天真的方法是应用独热编码,将索引转换为由零和一组成的向量。然后,每个单词将被映射到一个向量,其大小为整个数据集中唯一单词的数量。考虑到唯一单词的数量(词汇表的大小)可能在 10⁴ 至 10⁵的数量级,这也将是我们输入特征的数量,模型在这些特征上训练可能会受到维度诅咒的影响。此外,这些特征非常稀疏,因为除了一个之外,所有都是零。

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

这就是嵌入的概念,它是一种特征学习技术,我们可以利用它来自动学习表示数据集中单词的显著特征。鉴于唯一单词的数量,n[words],我们可以选择嵌入向量的大小(即嵌入维度),远小于唯一单词的数量(embedding_dim << n[words]),以表示整个词汇表作为输入特征。

嵌入与独热编码相比的优势如下:

  • 通过减少特征空间的维度来减少维度诅咒的影响

  • 由于 NN 中的嵌入层可以被优化(或学习),所以可以提取显著特征

以下示意图表示了嵌入如何工作,通过将标记索引映射到可训练的嵌入矩阵:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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>) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

此模型的输入(嵌入层)必须具有二维的 rank,维度为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:具有门控递归单元的递归层,作为 LSTM 的替代方案,由K. Cho等人在Learning Phrase Representations Using RNN Encoder–Decoder for Statistical Machine Translation中提出(2014 年)(arxiv.org/abs/1406.1078v3)

要查看如何使用这些递归层之一构建多层 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>) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32

正如您所看到的,使用这些循环层构建 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()
) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43

现在我们将开发 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) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

同样地,我们将开发 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) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

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

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

现在我们将对模型进行 10 个 epochs 的训练,并显示训练和验证的表现:

>>> 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 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

在对测试数据进行 10 个 epochs 的训练后,我们将对其进行评估:

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

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

关于双向 RNN 的更多信息

此外,我们将设置 LSTMbidirectional 配置为 True,这将使递归层通过输入序列的正向和反向两个方向进行传递:

>>> 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()
) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40

双向 RNN 层使每个输入序列经过两次传递:正向传递和反向传递(请注意,这与反向传播的正向和反向传递的上下文不同)。这些正向和反向传递的隐藏状态通常被连接成一个单一的隐藏状态。其他合并模式包括求和、乘积(将两次传递的结果相乘)和平均值(取两者的平均值)。

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

项目二 – 在 PyTorch 中进行字符级语言建模

语言建模是一种迷人的应用,它使机器能够执行与人类语言相关的任务,例如生成英文句子。在这个领域的一个有趣研究是Ilya SutskeverJames MartensGeoffrey E. Hinton的文章Generating Text with Recurrent Neural Networks,发表于 2011 年的第 28 届国际机器学习会议(ICML-11)(pdfs.semanticscholar.org/93c2/0e38c85b69fc2d2eb314b3c1217913f7db11.pdf)。

在我们即将构建的模型中,输入是一个文本文档,我们的目标是开发一个能够生成与输入文档风格类似的新文本的模型。这样的输入示例包括书籍或特定编程语言的计算机程序。

在字符级语言建模中,输入被分解为一个字符序列,逐个字符输入到我们的网络中。网络将每个新字符与先前看到的字符的记忆一起处理,以预测下一个字符。

图 15.11显示了字符级语言建模的一个示例(注意 EOS 代表“序列结束”):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.11:字符级语言建模

我们可以将这个实现分解成三个单独的步骤:准备数据,构建 RNN 模型,以及进行下一个字符预测和抽样,以生成新的文本。

数据集预处理

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

要获取输入数据,请访问古腾堡计划网站 www.gutenberg.org/,该网站提供数千本免费电子书。例如,您可以从 www.gutenberg.org/files/1268/1268-0.txt 下载儒勒·凡尔纳(于 1874 年出版)的书籍《神秘岛》的纯文本格式版本。

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

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

如果将来此资源不可用,本章节代码目录中也包含了此文本的副本,位于书籍代码库的github.com/rasbt/machine-learning-book

一旦我们下载了数据集,我们可以将其作为纯文本读入 Python 会话。使用以下代码,我们将直接从下载的文件中读取文本,并删除开头和结尾的部分(这些部分包含 Gutenberg 项目的某些描述)。然后,我们将创建一个 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
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

在下载和预处理文本之后,我们总共有 1,112,350 个字符的序列,其中包含 80 个唯一字符。然而,大多数神经网络库和 RNN 实现无法处理字符串格式的输入数据,因此我们必须将文本转换为数值格式。为此,我们将创建一个简单的 Python 字典,将每个字符映射到一个整数char2int。我们还需要一个反向映射,将我们模型的结果转换回文本。虽然可以使用一个将整数键与字符值关联的字典来执行反向映射,但是使用 NumPy 数组并索引该数组以将索引映射到这些唯一字符更为高效。图 15.12展示了将字符转换为整数以及对单词"Hello""world"进行反向映射的示例:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

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

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

现在,让我们退后一步,看看我们试图做的大局。对于文本生成任务,我们可以将问题描述为一个分类任务。

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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)) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

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

>>> 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 ' 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

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

>>> 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) 
  • 1
  • 2
  • 3
  • 4
  • 5

构建字符级 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 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

请注意,我们需要将模型的输出设定为 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)
) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

接下来的步骤是创建损失函数和优化器(Adam 优化器)。对于单个 logits 输出的多类别分类(我们有 vocab_size=80 类),我们使用 CrossEntropyLoss 作为损失函数:

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

现在我们将对模型进行 10,000 个周期的训练。在每个周期中,我们将从数据加载器 seq_dl 中随机选择一个批次进行训练。我们还将每 500 个周期显示一次训练损失:

>>> 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 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35

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

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

我们在前一节训练的 RNN 模型为每个独特字符返回大小为 80 的对数。 这些对数可以通过 softmax 函数轻松转换为概率,即特定字符将被遇到作为下一个字符的概率。 为了预测序列中的下一个字符,我们可以简单地选择具有最大对数值的元素,这相当于选择具有最高概率的字符。 但是,我们不希望总是选择具有最高可能性的字符,而是希望(随机)从输出中抽样;否则,模型将始终生成相同的文本。 PyTorch 已经提供了一个类,torch.distributions.categorical.Categorical,我们可以使用它从分类分布中绘制随机样本。 看看这是如何工作的,让我们从三个类别 [0, 1, 2] 中生成一些随机样本,使用输入对数 [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]] 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

正如您所见,给定的对数,各类别具有相同的概率(即,等概率类别)。 因此,如果我们使用大样本量(num_samples → ∞),我们期望每个类别的出现次数达到样本大小的 ≈ 1/3。 如果我们将对数更改为 [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]] 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

使用 Categorical,我们可以基于模型计算的对数生成示例。

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

在这里,我们仅使用 logits 输出(即,o(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 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

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

>>> 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 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

正如您所见,模型生成的大多数词汇是正确的,有时甚至部分句子是有意义的。您可以进一步调整训练参数,例如训练的输入序列长度和模型架构。

此外,为了控制生成样本的可预测性(即根据训练文本学习模式生成文本与增加更多随机性之间的权衡),RNN 模型计算的 logits 可以在传递给Categorical进行抽样之前进行缩放。缩放因子外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传可以类比于物理学中的温度。较高的温度导致更多的熵或随机性,而较低的温度则导致更可预测的行为。通过外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传缩放 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] 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

正如您所见,通过外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传缩放 logits,生成的概率几乎是均匀的[0.31, 0.31, 0.38]。现在,我们可以将生成的文本与外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传进行比较,如下所示:

  • 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    >>> 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 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
  • 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    >>> 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 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

结果显示,使用外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(增加温度)来缩放 logits 会生成更随机的文本。生成的文本的新颖性与其正确性之间存在权衡。

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

概要

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

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

在下一章中,我们将看到如何通过引入注意力机制来增强 RNN,帮助其模拟翻译任务中的长距离依赖关系。然后,我们将介绍一种称为transformer的新深度学习架构,该架构最近在自然语言处理领域推动了技术前沿。

加入我们书籍的 Discord 空间

加入该书的 Discord 工作区,与作者进行每月的问我任何事会话:

packt.link/MLwPyTorch

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

第十六章:变压器 - 使用注意机制改进自然语言处理

在上一章中,我们了解了关于循环神经网络RNNs)及其在自然语言处理NLP)中的应用,通过一个情感分析项目。然而,最近出现了一种新的架构,已被证明在几个 NLP 任务中优于基于 RNN 的序列到序列seq2seq)模型。这就是所谓的变压器架构。

变压器已经彻底改变了自然语言处理,并在许多引人注目的应用中处于前沿,从自动语言翻译(ai.googleblog.com/2020/06/recent-advances-in-google-translate.html)到对蛋白质序列的基本属性建模(www.pnas.org/content/118/15/e2016239118.short)以及创建帮助人们编写代码的 AI(github.blog/2021-06-29-introducing-github-copilot-ai-pair-programmer)。

在本章中,您将了解注意力自注意力的基本机制,并看到它们如何在原始变压器架构中使用。然后,掌握了变压器的工作原理后,我们将探索从这种架构中出现的一些最有影响力的 NLP 模型,并学习如何在 PyTorch 中使用大规模语言模型,即所谓的 BERT 模型。

我们将涵盖以下主题:

  • 使用注意机制改进 RNN

  • 引入独立的自注意力机制

  • 理解原始变压器架构

  • 比较基于变压器的大规模语言模型

  • 为情感分类微调 BERT

将注意机制添加到 RNN 中

在本节中,我们讨论开发注意机制背后的动机,这有助于预测模型更专注于输入序列的某些部分,以及它最初是如何在 RNN 的背景下使用的。请注意,本节提供了一个历史视角,解释了为什么开发注意机制。如果个别数学细节显得复杂,可以放心跳过,因为这些对于接下来的章节并不需要,而后者将重点介绍变压器中的自注意力机制解释。

注意帮助 RNN 访问信息

要理解注意机制的发展,请考虑传统的 RNN 模型,例如用于语言翻译等seq2seq 任务,它在生成翻译之前会解析整个输入序列(例如一个或多个句子),如图 16.1所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.1:用于 seq2seq 建模任务的传统 RNN 编码器-解码器架构

为什么 RNN 在生成第一个输出之前要解析整个输入句子?这是因为逐字翻译句子很可能导致语法错误,如图 16.2 所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.2:逐字翻译句子可能导致语法错误

然而,正如图 16.2 所示,这种 seq2seq 方法的一个局限性在于 RNN 试图通过一个单一的隐藏单元记住整个输入序列再进行翻译。将所有信息压缩到一个隐藏单元中可能会导致信息丢失,特别是对于长序列。因此,类似于人类翻译句子的方式,每个时间步骤都可以访问整个输入序列可能是有益的。

与普通的循环神经网络相比,注意力机制允许循环神经网络在每个时间步访问所有输入元素。然而,每个时间步访问所有输入序列元素可能会很复杂。因此,为了帮助循环神经网络集中精力处理输入序列中最相关的元素,注意力机制为每个输入元素分配不同的注意力权重。这些注意力权重指定了在特定时间步骤上给定输入序列元素的重要性或相关性。例如,重新审视图 16.2,单词“mir, helfen, zu”可能比“kannst, du, Satz”对生成输出词“help”更相关。

下一小节介绍了一种带有注意力机制的 RNN 架构,以帮助处理用于语言翻译的长序列。

用于 RNN 的原始注意力机制

在本小节中,我们将总结最初用于语言翻译的注意力机制的机制,并首次出现在以下论文中:Neural Machine Translation by Jointly Learning to Align and Translate,作者为Bahdanau, D., Cho, K., and Bengio, Y., 2014,arxiv.org/abs/1409.0473

给定一个输入序列 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,注意力机制为每个元素 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(或者更具体地说,它的隐藏表示)分配一个权重,并帮助模型确定它应该专注于输入的哪一部分。例如,假设我们的输入是一个句子,具有较大权重的单词对我们理解整个句子更有贡献。图 16.3 中带有注意力机制的 RNN(模仿前述论文)说明了生成第二个输出词的整体概念:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.3:带有注意力机制的 RNN

图中描述的基于注意力的架构由两个 RNN 模型组成,我们将在下一小节中解释。

使用双向 RNN 处理输入

图 16.3 中基于注意力的 RNN 中的第一个 RNN(RNN #1)是一个双向 RNN,生成上下文向量 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。可以将上下文向量视为输入向量 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的增强版本。换句话说,输入向量 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 还通过注意力机制从所有其他输入元素获取信息。如 图 16.3 所示,RNN #2 然后使用由 RNN #1 准备的这个上下文向量生成输出。在本小节的其余部分,我们将讨论 RNN #1 的工作原理,并在下一小节重新审视 RNN #2。

双向 RNN #1 处理输入序列 x 的正向(外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传)和反向(外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传)两个方向。以反向顺序解析序列与反转原始输入序列效果相同,可以将其理解为逆序阅读句子。这样做的原理是为了捕捉额外的信息,因为当前的输入可能依赖于句子中之前或之后的序列元素,或者两者都有。

因此,通过两次读取输入序列(即正向和反向),我们为每个输入序列元素得到两个隐藏状态。例如,对于第二个输入序列元素 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,我们从正向传递得到隐藏状态 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,从反向传递得到隐藏状态 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。然后,这两个隐藏状态被拼接成隐藏状态 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。例如,如果 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 都是 128 维向量,则拼接后的隐藏状态 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 将包含 256 个元素。我们可以将这个拼接的隐藏状态视为源词的“注释”,因为它包含了双向阅读中第 j 个词的信息。

在接下来的小节中,我们将看到如何进一步处理和使用第二个 RNN 生成输出的这些拼接隐藏状态。

从上下文向量生成输出

图 16.3 中,我们可以将 RNN #2 视为生成输出的主要 RNN。除了隐藏状态外,它还接收所谓的上下文向量作为输入。上下文向量 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是拼接隐藏状态 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的加权版本,这些隐藏状态是我们在前一小节从 RNN #1 获取的。我们可以计算第 i 个输入的上下文向量为加权求和:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在这里,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 表示对输入序列 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的注意力权重,用于第 i 个输入序列元素的上下文。注意,每个第 i 个输入序列元素都有一组唯一的注意力权重。我们将在下一小节讨论注意力权重 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的计算方法。

在本小节的其余部分,让我们讨论通过前述图中的第二个 RNN(RNN #2)如何使用上下文向量。就像普通的 RNN 一样,RNN #2 也使用隐藏状态。考虑到前述的“注释”和最终输出之间的隐藏层,让我们将时间 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 处的隐藏状态表示为 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。现在,RNN #2 在每个时间步 i 接收上述的上下文向量 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 作为输入。

图 16.3中,我们看到隐藏状态 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 取决于前一个隐藏状态 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传、前一个目标词 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 和上下文向量 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,这些用于生成目标词 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 在时间 i 处的预测输出。请注意,序列向量 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 指的是代表输入序列 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的正确翻译的序列向量,在训练期间可用。在训练期间,真实标签(单词) 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 被馈送到下一个状态 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传;由于这个真实标签信息在预测(推断)时不可用,我们改为馈送预测输出 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,如前图所示。

总结我们刚刚讨论的内容,基于注意力的 RNN 由两个 RNN 组成。RNN #1 从输入序列元素准备上下文向量,而 RNN #2 将上下文向量作为输入接收。上下文向量通过对输入进行加权求和来计算,其中权重是注意力权重 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。下一小节讨论如何计算这些注意力权重。

计算注意力权重

最后,让我们访问我们谜题中的最后一块遗失的部分——注意力权重。因为这些权重成对连接输入(注释)和输出(上下文),每个注意力权重 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 都有两个下标:j 指的是输入的索引位置,i 对应输出的索引位置。注意力权重 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是对齐分数 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的归一化版本,其中对齐分数评估了位置 j 周围的输入与位置 i 处的输出匹配的程度。更具体地说,注意力权重通过以下方式计算归一化的对齐分数:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

请注意,这个方程式类似于我们在第十二章PyTorch 中的神经网络训练并行化中讨论过的 softmax 函数,在通过 softmax 函数估计多类分类中的类概率小节。因此,注意力权重 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 总和为 1。

现在,总结一下,我们可以将基于注意力的 RNN 模型结构化为三个部分。第一部分计算输入的双向注释。第二部分包括循环块,与原始 RNN 非常相似,只是使用上下文向量代替原始输入。最后一部分涉及注意力权重和上下文向量的计算,描述每对输入和输出元素之间的关系。

变压器架构也利用注意力机制,但与基于注意力的 RNN 不同,它仅依赖于自注意力机制,并且不包括 RNN 中发现的循环过程。换句话说,变压器模型一次处理整个输入序列,而不是逐个元素地读取和处理序列。在接下来的小节中,我们将介绍自注意力机制的基本形式,然后在下一小节中更详细地讨论变压器架构。

引入自注意力机制

在前一小节中,我们看到注意力机制可以帮助 RNN 在处理长序列时记住上下文。正如我们将在下一小节中看到的那样,我们可以有一个完全基于注意力而非循环部分的架构。这种基于注意力的架构被称为变压器,我们将在后面更详细地讨论它。

实际上,变压器一开始看起来可能有点复杂。因此,在我们在下一小节讨论变压器之前,让我们先深入探讨变压器中使用的自注意力机制。实际上,正如我们将看到的,这种自注意力机制只是我们在前一小节讨论的注意力机制的另一种形式。我们可以将前面讨论的注意力机制视为连接两个不同模块的操作,即 RNN 的编码器和解码器。正如我们将看到的,自注意力仅关注输入,并且仅捕捉输入元素之间的依赖关系,而不连接两个模块。

在第一小节中,我们将介绍一种没有任何学习参数的基本自注意力形式,这非常类似于输入的预处理步骤。然后在第二小节中,我们将介绍变压器架构中使用的常见自注意力版本,涉及可学习参数。

从基本的自注意力开始

为了介绍自注意力,让我们假设我们有一个长度为T的输入序列,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,以及一个输出序列,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。为避免混淆,我们将使用外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传作为整个变压器模型的最终输出,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传作为自注意力层的输出,因为它是模型中的中间步骤。

这些序列中的每个第 i 个元素,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,都是大小为 d 的向量(即 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传),表示位置 i 处输入的特征信息,类似于循环神经网络。然后,对于 seq2seq 任务,自注意力的目标是建模当前输入元素与序列中所有其他输入元素之间的依赖关系。为了实现这一目标,自注意机制由三个阶段组成。首先,我们根据当前元素与序列中所有其他元素之间的相似性导出重要性权重。其次,我们对权重进行归一化,通常涉及使用已熟悉的 softmax 函数。第三,我们使用这些权重与相应的序列元素结合计算注意力值。

更正式地说,自注意力的输出,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,是所有输入序列 T 的加权和,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(其中 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传)。例如,对于第 i 个输入元素,相应的输出值计算如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

因此,我们可以将 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 视为上下文感知的嵌入向量,在输入向量 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 中涉及所有其他输入序列元素,这些元素根据它们各自的注意力权重 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 计算。更具体地说,这种相似性是通过下文中解释的两个步骤来计算的。

首先,我们计算当前输入元素 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 与输入序列中另一个元素 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的点积:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在我们归一化 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 值以获得注意力权重 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 之前,让我们通过代码示例说明如何计算 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 值。在这里,假设我们有一个输入句子“can you help me to translate this sentence”,该句子已经通过字典映射到整数表示,如 第十五章,使用循环神经网络建模顺序数据 中所述:

>>> import torch
>>> sentence = torch.tensor(
>>>     [0, # can
>>>      7, # you     
>>>      1, # help
>>>      2, # me
>>>      5, # to
>>>      6, # translate
>>>      4, # this
>>>      3] # sentence
>>> )
>>> sentence
tensor([0, 7, 1, 2, 5, 6, 4, 3]) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

假设我们已经通过嵌入层将这句话编码成实数向量表示。在这里,我们的嵌入大小是 16,并假设词典大小是 10。以下代码将产生我们八个单词的词嵌入:

>>> torch.manual_seed(123)
>>> embed = torch.nn.Embedding(10, 16)
>>> embedded_sentence = embed(sentence).detach()
>>> embedded_sentence.shape
torch.Size([8, 16]) 
  • 1
  • 2
  • 3
  • 4
  • 5

现在,我们可以计算 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 作为第 i 和第 j 个词嵌入之间的点积。我们可以对所有 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 值进行如下计算:

>>> omega = torch.empty(8, 8)
>>> for i, x_i in enumerate(embedded_sentence):
>>>     for j, x_j in enumerate(embedded_sentence):
>>>         omega[i, j] = torch.dot(x_i, x_j) 
  • 1
  • 2
  • 3
  • 4

尽管上述代码易于阅读和理解,for循环可能非常低效,因此让我们改用矩阵乘法来计算:

>>> omega_mat = embedded_sentence.matmul(embedded_sentence.T) 
  • 1

我们可以使用torch.allclose函数检查该矩阵乘法是否产生预期结果。如果两个张量包含相同的值,torch.allclose将返回True,如我们可以看到的那样:

>>> torch.allclose(omega_mat, omega)
True 
  • 1
  • 2

我们已经学习了如何计算基于相似性的第i个输入及其序列中所有输入的权重(从外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传),“原始”权重(从外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传)。我们可以通过常见的 softmax 函数对外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传值进行标准化来获取注意力权重,如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

注意分母涉及对所有输入元素的求和(外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传)。因此,应用此 softmax 函数后,权重在标准化后将总和为 1,即,

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

我们可以使用 PyTorch 的 softmax 函数计算注意力权重如下:

>>> import torch.nn.functional as F
>>> attention_weights = F.softmax(omega, dim=1)
>>> attention_weights.shape
torch.Size([8, 8]) 
  • 1
  • 2
  • 3
  • 4

注意attention_weights是一个外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传矩阵,其中每个元素表示一个注意力权重,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。例如,如果我们正在处理第i个输入单词,则此矩阵的第i行包含句子中所有单词的对应注意力权重。这些注意力权重指示每个单词与第i个单词的相关性。因此,此注意力矩阵中的列应该总和为 1,我们可以通过以下代码确认:

>>> attention_weights.sum(dim=1)
tensor([1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000, 1.0000]) 
  • 1
  • 2

现在我们已经看到了如何计算注意力权重,让我们回顾和总结自注意操作的三个主要步骤:

  1. 对于给定的输入元素,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,以及集合{1, …, T}中的每个第j个元素,计算点积,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  2. 通过使用 softmax 函数对点积进行标准化来获取注意力权重,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  3. 计算输出,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,作为整个输入序列的加权和:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这些步骤在图 16.4 中进一步说明:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.4:用于说明目的的基本自注意过程

最后,让我们看一个用于计算上下文向量外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传的代码示例,作为输入的注意力加权和(图 16.4 中的步骤 3)。特别是,让我们假设我们正在计算第二个输入单词的上下文向量,即外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

>>> x_2 = embedded_sentence[1, :]
>>> context_vec_2 = torch.zeros(x_2.shape)
>>> for j in range(8):
...     x_j = embedded_sentence[j, :]
...     context_vec_2 += attention_weights[1, j] * x_j 
>>> context_vec_2
tensor([-9.3975e-01, -4.6856e-01,  1.0311e+00, -2.8192e-01,  4.9373e-01, -1.2896e-02, -2.7327e-01, -7.6358e-01,  1.3958e+00, -9.9543e-01,
-7.1288e-04,  1.2449e+00, -7.8077e-02,  1.2765e+00, -1.4589e+00,
-2.1601e+00]) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

同样,我们可以通过矩阵乘法更高效地实现这一点。使用以下代码,我们正在计算所有八个输入单词的上下文向量:

>>> context_vectors = torch.matmul(
...     attention_weights, embedded_sentence) 
  • 1
  • 2

与存储在embedded_sentence中的输入单词嵌入类似,context_vectors矩阵具有维度外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。此矩阵中的第二行包含第二个输入单词的上下文向量,并且我们可以再次使用torch.allclose()检查其实现:

>>> torch.allclose(context_vec_2, context_vectors[1])
True 
  • 1
  • 2

正如我们所见,第二个上下文向量的手动 for 循环和矩阵计算产生了相同的结果。

本节实现了自注意力的基本形式,而在下一节中,我们将修改这一实现,使用可在神经网络训练期间优化的可学习参数矩阵。

参数化自注意力机制:缩放点积注意力

现在您已经了解了自注意力背后的基本概念,本小节总结了更先进的自注意力机制,称为缩放点积注意力,这在变压器架构中被使用。请注意,在前一小节中,在计算输出时我们没有涉及任何可学习的参数。换句话说,使用先前介绍的基本自注意力机制时,变压器模型在如何在给定序列的模型优化过程中更新或更改注意力值方面是相当受限的。为了使自注意力机制更加灵活且有利于模型优化,我们将引入三个额外的权重矩阵,在模型训练过程中可以作为模型参数拟合。我们将这三个权重矩阵表示为 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,和 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。它们用于将输入投影到查询、键和值序列元素,如下所示:

  • 查询序列外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 用于 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 键序列外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 用于 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 值序列外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 用于 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.5 展示了这些单独组件如何用于计算与第二输入元素对应的上下文感知嵌入向量:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.5:计算第二序列元素的上下文感知嵌入向量

查询、键和值术语

在原始变压器论文中使用的查询、键和值术语灵感来自信息检索系统和数据库。例如,如果我们输入一个查询,它将与键值匹配,其中某些值将被检索出来。

在这里,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 都是大小为 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的向量。因此,投影矩阵 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的形状为 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,而 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的形状为 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。(注意,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是每个单词向量的维度,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。)为简单起见,我们可以设计这些向量具有相同的形状,例如使用 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。为了通过代码提供额外的直觉,我们可以初始化这些投影矩阵如下:

>>> torch.manual_seed(123)
>>> d = embedded_sentence.shape[1]
>>> U_query = torch.rand(d, d)
>>> U_key = torch.rand(d, d)
>>> U_value = torch.rand(d, d) 
  • 1
  • 2
  • 3
  • 4
  • 5

使用查询投影矩阵,我们可以计算查询序列。对于此示例,将第二个输入元素,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,作为我们的查询,如图 16.5所示:

>>> x_2 = embedded_sentence[1]
>>> query_2 = U_query.matmul(x_2) 
  • 1
  • 2

以类似的方式,我们可以计算关键序列和值序列,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

>>> key_2 = U_key.matmul(x_2)
>>> value_2 = U_value.matmul(x_2) 
  • 1
  • 2

然而,正如我们从图 16.5中看到的那样,我们还需要计算所有其他输入元素的关键序列和值序列,计算方法如下:

>>> keys = U_key.matmul(embedded_sentence.T).T
>>> values = U_value.matmul(embedded_sentence.T).T 
  • 1
  • 2

在关键矩阵中,第 i 行对应于第 i 个输入元素的关键序列,值矩阵也是如此。我们可以再次使用 torch.allclose() 来确认这一点,它应该返回 True

>>> keys = U_key.matmul(embedded_sentence.T).T
>>> torch.allclose(key_2, keys[1])
>>> values = U_value.matmul(embedded_sentence.T).T
>>> torch.allclose(value_2, values[1]) 
  • 1
  • 2
  • 3
  • 4

在前一节中,我们计算了未归一化权重,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,作为给定输入序列元素,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,和第 j 个序列元素,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,之间的成对点积。现在,在这个参数化的自注意力版本中,我们将 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 计算为查询和关键之间的点积:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

例如,以下代码计算了未归一化的注意力权重,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,即我们的查询与第三个输入序列元素之间的点积:

>>> omega_23 = query_2.dot(keys[2])
>>> omega_23
tensor(14.3667) 
  • 1
  • 2
  • 3

由于我们稍后将需要这些,我们可以将此计算扩展到所有关键序列:

>>> omega_2 = query_2.matmul(keys.T)
>>> omega_2
tensor([-25.1623,   9.3602,  14.3667,  32.1482,  53.8976,  46.6626,  -1.2131, -32.9391]) 
  • 1
  • 2
  • 3

自注意力的下一步是从未归一化的注意力权重,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,转换为归一化的注意力权重,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,使用 softmax 函数。然后我们可以进一步使用 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 来缩放 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,然后通过 softmax 函数进行归一化,如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

请注意,通过缩放 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 乘以 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,其中通常 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,确保权重向量的欧几里得长度大致处于相同的范围内。

以下代码用于实现此归一化,以计算关于第二个输入元素作为查询的整个输入序列的注意力权重:

>>> attention_weights_2 = F.softmax(omega_2 / d**0.5, dim=0)
>>> attention_weights_2
tensor([2.2317e-09, 1.2499e-05, 4.3696e-05, 3.7242e-03, 8.5596e-01, 1.4025e-01, 8.8896e-07, 3.1936e-10]) 
  • 1
  • 2
  • 3

最后,输出是值序列的加权平均值:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,可以按以下方式实现:

>>> context_vector_2 = attention_weights_2.matmul(values)
>>> context_vector_2
tensor([-1.2226, -3.4387, -4.3928, -5.2125, -1.1249, -3.3041, 
-1.4316, -3.2765, -2.5114, -2.6105, -1.5793, -2.8433, -2.4142, 
-0.3998, -1.9917, -3.3499]) 
  • 1
  • 2
  • 3
  • 4
  • 5

在本节中,我们介绍了一个带有可训练参数的自注意力机制,它让我们能够通过涉及所有输入元素的加权注意力分数来计算上下文感知嵌入向量。在接下来的一节中,我们将学习变压器架构,这是围绕本节介绍的自注意力机制的神经网络架构。

注意力是我们所需的一切:介绍原始变压器架构

有趣的是,最初的 Transformer 架构基于一个注意力机制,这个机制最初是在 RNN 中使用的。最初使用注意力机制的目的是在处理长句子时提高 RNN 的文本生成能力。然而,仅仅几年后,在为 RNN 尝试注意力机制后,研究人员发现,在删除循环层后,基于注意力的语言模型甚至更强大。这导致了 Transformer 架构的发展,这也是本章和后续部分的主题。

Transformer 架构最初是由 A. Vaswani 及其同事在 NeurIPS 2017 论文“Attention Is All You Need”中提出的(arxiv.org/abs/1706.03762)。由于自注意力机制,Transformer 模型能够捕捉输入序列中元素之间的长距离依赖关系,例如在 NLP 上下文中,这有助于模型更好地“理解”输入句子的含义。

尽管这种 Transformer 架构最初是为语言翻译设计的,但可以推广到其他任务,如英语成分解析、文本生成和文本分类。稍后,我们将讨论从这种原始 Transformer 架构衍生出的流行语言模型,如 BERT 和 GPT。我们从原始 Transformer 论文中修改的图 16.6,展示了我们将在本节讨论的主要架构和组件:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.6:原始的 Transformer 架构

在接下来的小节中,我们逐步详细介绍这个原始 Transformer 模型,将其分解为两个主要模块:编码器和解码器。编码器接收原始顺序输入并使用多头自注意力模块编码嵌入。解码器接收处理后的输入,并使用掩码形式的自注意力输出结果序列(例如翻译后的句子)。

通过多头注意力编码上下文嵌入

编码器块的总体目标是接收顺序输入 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,并将其映射到连续表示 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,然后传递给解码器。

编码器是六个相同层的堆叠。这里的六不是一个魔法数字,而只是原始 Transformer 论文中的超参数选择。您可以根据模型性能调整层数。在这些相同层的每一层中,有两个子层:一个计算多头自注意力,我们将在下面讨论;另一个是全连接层,您在前几章已经遇到过。

让我们首先讨论多头自注意力,这是对前文中介绍的缩放点积注意力的简单修改。在缩放点积注意力中,我们使用三个矩阵(对应查询、值和键)来转换输入序列。在多头注意力的背景下,我们可以将这组三个矩阵看作一个注意力。正如其名称所示,在多头注意力中,我们现在有多个这样的头(一组查询、值和键矩阵),类似于卷积神经网络可以具有多个卷积核。

为了更详细地解释具有 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 个头的多头自注意力的概念,让我们分解为以下步骤。

首先,我们读取顺序输入 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。假设每个元素被一个长度为 d 的向量嵌入。在这里,输入可以嵌入成一个 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 矩阵。然后,我们创建 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 组查询、键和值学习参数矩阵:

  • 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

因为我们使用这些权重矩阵来投影每个元素 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 以便在矩阵乘法中进行必要的维度匹配,因此 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的形状都是 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,而 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的形状是 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。因此,生成的查询和键序列的长度均为 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,生成的值序列的长度为 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传。实际应用中,人们通常简化选择 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

要在代码中说明多头自注意力堆栈,首先考虑我们如何在前一小节中创建单一查询投影矩阵,参数化自注意力机制:缩放点积注意力

>>> torch.manual_seed(123)
>>> d = embedded_sentence.shape[1]
>>> one_U_query = torch.rand(d, d) 
  • 1
  • 2
  • 3

现在假设我们有八个类似于原始变压器的注意力头,即 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

>>> h = 8
>>> multihead_U_query = torch.rand(h, d, d)
>>> multihead_U_key = torch.rand(h, d, d)
>>> multihead_U_value = torch.rand(h, d, d) 
  • 1
  • 2
  • 3
  • 4

正如代码中所示,可以通过简单地增加一个额外的维度来添加多个注意力头。

将数据分配到多个注意力头

实际上,转换器实现中并不是为每个注意力头单独使用一个矩阵,而是使用一个矩阵来处理所有注意力头。然后,这些注意力头在矩阵中被组织成逻辑上的独立区域,可以通过布尔掩码访问。这样可以更有效地实现多头注意力,因为多个矩阵乘法可以合并为单个矩阵乘法。然而,在本节中为简化起见,我们省略了这个实现细节。

初始化投影矩阵后,我们可以计算投影序列,类似于缩放点积注意力中的方式。现在,我们不是计算一组查询、键和值序列,而是需要计算 h 组。更正式地说,例如,涉及到第 i 个数据点在第 j 个头部的查询投影的计算可以写成如下形式:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

然后,我们为所有头部 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 重复这个计算。

在代码中,这看起来像是对第二个输入词作为查询的情况:

>>> multihead_query_2 = multihead_U_query.matmul(x_2)
>>> multihead_query_2.shape
torch.Size([8, 16]) 
  • 1
  • 2
  • 3

multihead_query_2 矩阵有八行,每行对应第 j 个注意力头。

类似地,我们可以计算每个头部的键和值序列:

>>> multihead_key_2 = multihead_U_key.matmul(x_2)
>>> multihead_value_2 = multihead_U_value.matmul(x_2)
>>> multihead_key_2[2]
tensor([-1.9619, -0.7701, -0.7280, -1.6840, -1.0801, -1.6778,  0.6763,  0.6547,
         1.4445, -2.7016, -1.1364, -1.1204, -2.4430, -0.5982, -0.8292, -1.4401]) 
  • 1
  • 2
  • 3
  • 4
  • 5

代码输出显示了第三个注意力头中第二个输入元素的键向量。

但是,请记住,我们需要为所有输入序列元素重复键和值的计算,而不仅仅是 x_2 —— 我们需要这样做来后续计算自注意力。一个简单且生动的方法是将输入序列嵌入扩展到大小为 8 作为第一维度,即注意力头的数量。我们使用 .repeat() 方法来实现这一点:

>>> stacked_inputs = embedded_sentence.T.repeat(8, 1, 1)
>>> stacked_inputs.shape
torch.Size([8, 16, 8]) 
  • 1
  • 2
  • 3

然后,我们可以通过 torch.bmm() 进行批次矩阵乘法,使用注意力头来计算所有键:

>>> multihead_keys = torch.bmm(multihead_U_key, stacked_inputs)
>>> multihead_keys.shape
torch.Size([8, 16, 8]) 
  • 1
  • 2
  • 3

在这段代码中,我们现在有一个张量,其第一维度指向八个注意力头。第二和第三维度分别指向嵌入大小和单词数量。让我们交换第二和第三维度,以便键具有更直观的表示方式,即与原始输入序列 embedded_sentence 相同的维度:

>>> multihead_keys = multihead_keys.permute(0, 2, 1)
>>> multihead_keys.shape
torch.Size([8, 8, 16]) 
  • 1
  • 2
  • 3

重新排列后,我们可以按如下方式访问第二个注意力头中的第二个键值:

>>> multihead_keys[2, 1] 
tensor([-1.9619, -0.7701, -0.7280, -1.6840, -1.0801, -1.6778,  0.6763,  0.6547,
         1.4445, -2.7016, -1.1364, -1.1204, -2.4430, -0.5982, -0.8292, -1.4401]) 
  • 1
  • 2
  • 3

我们可以看到,这与我们之前通过 multihead_key_2[2] 得到的键值是相同的,这表明我们复杂的矩阵操作和计算是正确的。因此,让我们重复一下值序列的计算:

>>> multihead_values = torch.matmul(
        multihead_U_value, stacked_inputs)
>>> multihead_values = multihead_values.permute(0, 2, 1) 
  • 1
  • 2
  • 3

我们按照单头注意力计算的步骤来计算上下文向量,如 自注意机制的参数化:缩放点积注意力 部分所述。出于简洁起见,我们将跳过中间步骤,并假设我们已经计算了第二个输入元素的上下文向量作为查询和八个不同注意力头,我们将其表示为 multihead_z_2,通过随机数据:

>>> multihead_z_2 = torch.rand(8, 16) 
  • 1

注意,第一维度索引了八个注意力头,上下文向量类似于输入句子,是 16 维向量。如果这看起来很复杂,请将 multihead_z_2 视为 图 16.5 中显示的 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的八个副本;也就是说,我们为每个注意力头有一个 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

然后,我们将这些向量连接成一个长度为 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的长向量,并使用线性投影(通过全连接层)将其映射回长度为 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的向量。这个过程在 图 16.7 中有所说明:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.7:将缩放点积注意力向量连接成一个向量并通过线性投影传递

在代码中,我们可以按以下方式实现连接和压缩:

>>> linear = torch.nn.Linear(8*16, 16)
>>> context_vector_2 = linear(multihead_z_2.flatten())
>>> context_vector_2.shape
torch.Size([16]) 
  • 1
  • 2
  • 3
  • 4

总结一下,多头自注意力是并行多次重复缩放点积注意力计算,并将结果合并。它在实践中表现非常好,因为多头帮助模型从输入的不同部分捕获信息,这与卷积网络中多个核产生多个通道以捕获不同特征信息的方式非常相似。最后,虽然多头注意力听起来计算量昂贵,但请注意计算可以全部并行进行,因为多头之间没有依赖关系。

学习语言模型:解码器和掩码多头注意力

与编码器类似,解码器也包含几个重复的层。除了我们在前述编码器部分介绍的两个子层(多头自注意力层和全连接层)之外,每个重复层还包含一个掩码多头注意力子层。

掩码注意力是原始注意力机制的一种变体,其中掩码注意力只通过“掩码”屏蔽掉一定数量的词来将有限的输入序列传递给模型。例如,在使用标记数据集构建语言翻译模型时,在训练过程中的序列位置 i,我们只传递来自位置 1 到 i-1 的正确输出词。所有其他词(例如,当前位置之后的词)对于模型是隐藏的,以防止模型“作弊”。这也与文本生成的性质一致:虽然在训练过程中我们知道真实的翻译词,但在实际应用中我们对地面真相一无所知。因此,我们只能将模型已经生成的解决方案传递给它,在位置 i

图 16.8 说明了解码器块中层的排列方式:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.8:解码器部分中的层排列

首先,将先前的输出词(输出嵌入)传递到掩码多头注意力层。然后,第二层同时接收来自编码器块的编码输入和掩码多头注意力层的输出,传递到多头注意力层。最后,我们将多头注意力的输出传递到一个全连接层,生成整体模型输出:与输出词对应的概率向量。

注意,我们可以使用 argmax 函数从这些单词概率中获取预测单词,这与我们在第十五章 使用递归神经网络建模序列数据中采用的整体方法类似。

将解码器与编码器块进行比较,主要区别在于模型可以关注的序列元素范围。在编码器中,对于每个给定的单词,都会计算整个句子中所有单词的注意力,这可以被视为一种双向输入解析形式。解码器还接收来自编码器的双向解析输入。然而,在输出序列方面,解码器仅考虑那些在当前输入位置之前的元素,这可以被解释为一种单向输入解析形式。

实现细节:位置编码和层归一化

在本小节中,我们将讨论一些转换器的实现细节,这些细节我们迄今为止只是粗略地提及,但是值得一提。

首先,让我们考虑原始转换器架构中的位置编码,这些编码是来自图 16.6的一部分。位置编码有助于捕获输入序列顺序信息,对于转换器而言至关重要,因为缩放的点积注意力层和全连接层都是置换不变的。这意味着,没有位置编码,单词的顺序会被忽略,并且对基于注意力的编码没有任何影响。然而,我们知道单词顺序对于理解一个句子是至关重要的。例如,考虑以下两个句子:

  1. 玛丽给约翰一朵花

  2. 约翰给玛丽一朵花

两个句子中出现的单词完全相同;然而,它们的含义却大不相同。

转换器通过在编码器和解码器块的开头向输入嵌入添加一个小值向量,使同一单词在不同位置具有略微不同的编码。特别地,原始的转换器架构使用所谓的正弦编码:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这里 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 是单词的位置,k 表示编码向量的长度,我们选择 k 与输入单词嵌入的维度相同,以便将位置编码和单词嵌入相加。使用正弦函数可以防止位置编码过大。例如,如果我们使用绝对位置 1,2,3… n 作为位置编码,它们会主导单词编码并使单词嵌入值变得可以忽略。

一般来说,有两种类型的位置编码,一种是绝对的(如前面的公式所示),另一种是相对的。前者记录单词的绝对位置,并对句子中的单词移动敏感。也就是说,绝对位置编码是每个给定位置的固定向量。另一方面,相对编码仅保持单词的相对位置,对句子移动是不变的。

接下来,让我们看看层归一化机制,这是由 J. Ba、J.R. Kiros 和 G.E. Hinton 在 2016 年同名论文 Layer Normalization(URL:arxiv.org/abs/1607.06450)中首次引入的。虽然批归一化在计算机视觉环境中很受欢迎,我们将在 第十七章《生成对抗网络用于合成新数据》中更详细地讨论它,但在自然语言处理(NLP)环境中,句子长度可能会变化,因此层归一化是首选。图 16.9 显示了层归一化和批归一化的主要区别:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.9:批归一化与层归一化的比较

虽然传统上层归一化是在每个特征的所有元素之间执行的,但在 transformers 中使用的层归一化扩展了这一概念,并为每个训练样本独立地计算所有特征值的归一化统计。

由于层归一化对每个训练样本计算均值和标准差,它放宽了小批量大小的限制或依赖关系。与批归一化相比,层归一化能够从具有小批量大小和不同长度数据中学习。但需要注意的是,原始的 Transformer 架构并没有变长输入(需要时会对句子进行填充),并且与 RNN 不同,模型中没有循环。那么,我们如何能够在这种情况下证明层归一化优于批归一化的使用呢?Transformers 通常在非常大的文本语料库上进行训练,这需要并行计算;这对于批归一化来说可能是具有挑战性的,因为训练样本之间存在依赖关系。层归一化没有这种依赖关系,因此对于 transformers 来说是一个更自然的选择。

利用未标记数据构建大规模语言模型

在这一部分中,我们将讨论从原始变压器中产生的流行大规模变压器模型。这些变压器之间的一个共同主题是它们都是在非常大的未标记数据集上进行预训练,然后针对各自的目标任务进行微调的。首先,我们将介绍基于变压器的模型的常见训练过程,并解释它如何与原始变压器不同。然后,我们将重点介绍流行的大规模语言模型,包括生成式预训练变压器GPT)、来自变压器的双向编码器表示BERT)和双向自回归变压器BART)。

预训练和微调变压器模型

在一个早期的章节中,注意力就是我们需要的:介绍原始变压器架构,我们讨论了如何利用原始变压器架构进行语言翻译。语言翻译是一个监督任务,需要一个带标签的数据集,这可能非常昂贵。缺乏大型的标记数据集是深度学习中一个长期存在的问题,特别是对于像变压器这样更加依赖数据的模型。然而,考虑到每天会产生大量的文本(书籍、网站和社交媒体帖子),一个有趣的问题是我们如何利用这些未标记的数据来改进模型训练。

我们是否可以利用变压器中的无标签数据的答案是yes,而技巧就是一个称为自监督学习的过程:我们可以从纯文本本身生成“标签”来进行监督学习。例如,给定一个大型的未标记文本语料库,我们训练模型执行下一个词预测,这使得模型能够学习单词的概率分布,并可以形成强大的语言模型的基础。

自监督学习传统上也被称为无监督预训练,对于现代基于变压器的模型的成功至关重要。无监督预训练中的“无监督”据说是指我们使用未标记的数据;然而,由于我们使用数据的结构生成标签(例如前面提到的下一个词预测任务),因此它仍然是一个监督学习过程。

进一步详细说明无监督预训练和下一个词预测的工作原理,如果我们有一个包含n个单词的句子,预训练过程可以分解为以下三个步骤:

  1. 步骤 1时,输入地面真实的单词 1,…,i-1。

  2. 要求模型预测位置i处的单词,并将其与地面真实单词i进行比较。

  3. 更新模型和时间步长,i := i + 1。回到步骤 1 并重复,直到所有单词都被处理完。

我们应该注意,在下一次迭代中,我们总是向模型提供正确的单词而不是上一轮模型生成的内容。

预训练的主要思想是利用普通文本,然后转移并微调模型,以执行某些具有(较小)标记数据集的特定任务。现在,有许多不同类型的预训练技术。例如,先前提到的下一个词预测任务可以被视为单向预训练方法。稍后,我们将介绍在不同语言模型中使用的其他预训练技术,以实现各种功能。

一个基于 Transformer 模型的完整训练过程包括两部分:(1)在大规模未标记数据集上进行预训练,以及(2)使用标记数据集对模型进行训练(即微调),以适应特定的下游任务。在第一步中,预训练模型并不针对特定任务设计,而是被训练为一个“通用”语言模型。随后,在第二步中,通过常规监督学习在标记数据集上,它可以泛化到任何定制任务中。

利用从预训练模型中获得的表示,主要有两种策略将模型转移并应用于特定任务:(1)基于特征的方法和(2)微调方法。(在这里,我们可以将这些表示视为模型最后层的隐藏层激活。)

特征驱动方法使用预训练表示作为标记数据集的附加特征。这要求我们学习如何从预训练模型中提取句子特征。一个早期以特征提取方法闻名的模型是 2018 年由 Peters 和同事在论文《深度上下文化的词表示》(URL:arxiv.org/abs/1802.05365)中提出的ELMo(从语言模型中嵌入)。ELMo 是一个预训练的双向语言模型,在预训练过程中以一定比例屏蔽单词。特别地,它在预训练期间随机屏蔽输入单词的 15%,建模任务是填补这些空白,即预测丢失(屏蔽)的单词。这与我们之前介绍的单向方法不同,后者在时间步i隐藏所有未来单词。双向屏蔽使模型能够从两端学习,因此可以捕获更全面的句子信息。预训练的 ELMo 模型能够生成高质量的句子表示,后续可作为特定任务的输入特征。换句话说,我们可以将特征驱动方法视为一种类似于主成分分析的基于模型的特征提取技术,我们在《第五章》,《通过降维压缩数据》中进行了讨论。

另一种微调方法是通过反向传播以常规监督方式更新预训练模型参数。与基于特征的方法不同,我们通常还会向预训练模型添加另一个完全连接的层,以完成诸如分类等特定任务,然后根据在标记训练集上的预测性能更新整个模型。遵循此方法的一个流行模型是 BERT,一个大规模的变压器模型,预先训练为双向语言模型。我们将在接下来的小节中更详细地讨论 BERT。此外,在本章的最后一节中,我们将看到一个代码示例,展示如何使用我们在第八章应用机器学习进行情感分析,和第十五章使用递归神经网络建模序列数据中使用的电影评论数据集,对预训练的 BERT 模型进行情感分类的微调。

在我们进入下一节并开始讨论基于 Transformer 的流行语言模型之前,以下图表总结了 Transformer 模型训练的两个阶段,并说明了基于特征和微调方法之间的区别:

­­­­外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.10:采用预训练变压器进行下游任务的两种主要方式

利用 GPT 进行无标签数据的操作

生成预训练变压器GPT)是由 OpenAI 开发的一系列流行的大规模语言模型,用于生成文本。最近的模型 GPT-3,于 2020 年 5 月发布(语言模型是少样本学习者),正在产生令人惊讶的结果。GPT-3 生成的文本质量很难与人类生成的文本区分开。在本节中,我们将讨论 GPT 模型在高层次上的工作原理及其多年来的发展。

正如表 16.1中所列,GPT 模型系列中的一个明显演变是参数数量的增加:

模型发布年份参数数量标题论文链接
GPT-120181.1 亿通过生成预训练来提升语言理解能力链接
GPT-2201915 亿语言模型是无监督的多任务学习者链接
GPT-320201750 亿语言模型是少样本学习者链接

表 16.1:GPT 模型概览

但是,让我们先不要过于超前,首先更仔细地看看 2018 年发布的 GPT-1 模型的情况,它的训练过程可以分解为两个阶段:

  1. 在大量未标记的纯文本上进行预训练

  2. 监督微调

正如图 16.11(改编自 GPT-1 论文)所示,我们可以将 GPT-1 视为一个由(1)解码器(没有编码器块)和(2)稍后添加的额外层组成的变压器,用于进行监督微调以完成特定任务:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.11:GPT-1 变压器

在图中,请注意,如果我们的任务是文本预测(预测下一个词),那么模型在预训练步骤后就已经准备好了。否则,例如,如果我们的任务与分类或回归相关,则需要进行监督微调。

在预训练期间,GPT-1 利用变压器解码器结构,在给定的词位置,模型仅依赖于前面的词来预测下一个词。GPT-1 利用单向自注意机制,与 BERT 中的双向自注意机制相对,因为 GPT-1 专注于文本生成而不是分类。在文本生成期间,它以自然的从左到右的方向逐个生成单词。这里有一个值得强调的另一个方面:在训练过程中,对于每个位置,我们始终向模型提供来自前一位置的正确单词。但在推理过程中,我们只是向模型提供它已经生成的任何单词,以便生成新的文本。

在获得预训练模型(前一图中标记为Transformer的块)之后,我们将其插入到输入预处理块和线性层之间,其中线性层充当输出层(类似于本书前面讨论过的其他深度神经网络模型)。对于分类任务,微调就像首先对输入进行标记化,然后将其输入到预训练模型和新添加的线性层中,接着是 softmax 激活函数。然而,对于诸如问答之类的更复杂任务,输入以某种不一定匹配预训练模型的格式组织,这需要为每个任务定制的额外处理步骤。鼓励对特定修改感兴趣的读者阅读 GPT-1 论文以获取更多细节(链接在上表中提供)。

GPT-1 在零次任务上的表现也令人惊讶,这证明了它作为一个通用语言模型的能力,可以通过最少的任务特定微调定制不同类型的任务。零次学习通常描述的是机器学习中的一个特殊情况,在测试和推理过程中,模型需要对未在训练中观察到的类别的样本进行分类。在 GPT 的上下文中,零次设置指的是未见任务。

GPT 的适应能力激发了研究人员摒弃特定任务的输入和模型设置,从而推动了 GPT-2 的发展。与其前身不同,GPT-2 在输入或微调阶段不再需要任何额外的修改。模型不再需要重新排列序列以匹配所需格式,而是可以区分不同类型的输入,并在少量提示(所谓的“上下文”)下执行相应的下游任务。这是通过在输出概率上进行建模,条件是输入和任务类型,外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传,而不仅仅是条件于输入。例如,如果上下文包括 translate to French, English text, French text,则期望模型能识别翻译任务。

这听起来比 GPT 更“人工智能”,实际上除了模型大小外,这是最显著的改进之一。正如其相应论文的标题所示(语言模型是无监督多任务学习者),无监督语言模型可能是零次学习的关键,而 GPT-2 充分利用了零次任务转移来构建这种多任务学习器。

与 GPT-2 相比,GPT-3 在某种意义上不太“雄心勃勃”,它将注意力从零次转移到一次和少次学习,通过上下文学习。虽然不提供特定任务的训练示例似乎过于严格,但少次学习不仅更现实,而且更像人类:人类通常需要看几个例子才能学会一个新任务。正如其名称所示,少次学习意味着模型看到少量任务示例,而一次学习则限于一个示例。

图 16.12 展示了零次、一次、少次和微调过程之间的区别:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.12:零次、一次和少次学习的比较

GPT-3 的模型架构基本与 GPT-2 相同,只是参数规模增加了 100 倍,并使用了稀疏 transformer。在我们之前讨论过的原始(密集)注意力机制中,每个元素都关注输入中的所有其他元素,这会随着 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 的复杂性增加。稀疏注意力通过仅关注大小有限的元素子集来提高效率,通常与 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 成比例。有兴趣的读者可以通过访问稀疏 transformer 论文了解更多有关特定子集选择的信息:Generating Long Sequences with Sparse Transformers by Rewon Child 等人,2019(URL:arxiv.org/abs/1904.10509)。

使用 GPT-2 生成新的文本

在我们继续下一个 transformer 架构之前,让我们看看如何使用最新的 GPT 模型生成新的文本。请注意,GPT-3 目前仍然相对较新,并且目前仅通过 OpenAI API 的 beta 版本提供服务,网址为 openai.com/blog/openai-api/。但是,Hugging Face 提供了 GPT-2 的实现(一家知名的自然语言处理和机器学习公司;huggingface.co),我们将使用它。

我们将通过 transformers 访问 GPT-2,这是由 Hugging Face 创建的非常全面的 Python 库,提供各种基于 transformer 的模型进行预训练和微调。用户还可以在论坛上讨论和分享他们定制的模型。如果您有兴趣,请随时访问社区并参与其中:discuss.huggingface.co

安装 transformers 版本 4.9.1

由于这个软件包正在迅速发展,您可能无法在以下子章节中复制结果。作为参考,本教程使用的是 2021 年 6 月发布的 4.9.1 版本。要安装本书中使用的版本,您可以在终端中执行以下命令从 PyPI 安装它:

pip install transformers==4.9.1 
  • 1

我们还建议查阅官方安装页面上的最新说明:

huggingface.co/transformers/installation.html

一旦我们安装了 transformers 库,我们可以运行以下代码来导入一个预训练的 GPT 模型,该模型可以生成新的文本:

>>> from transformers import pipeline, set_seed
>>> generator = pipeline('text-generation', model='gpt2') 
  • 1
  • 2

然后,我们可以用一个文本片段提示模型,并要求它基于该输入片段生成新的文本:

>>> set_seed(123)
>>> generator("Hey readers, today is",
...           max_length=20,
...           num_return_sequences=3)
[{'generated_text': "Hey readers, today is not the last time we'll be seeing one of our favorite indie rock bands"},
 {'generated_text': 'Hey readers, today is Christmas. This is not Christmas, because Christmas is so long and I hope'},
 {'generated_text': "Hey readers, today is CTA Day!\n\nWe're proud to be hosting a special event"}] 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

从输出中可以看出,基于我们的文本片段,模型生成了三个合理的句子。如果您想探索更多示例,请随意更改随机种子和最大序列长度。

此外,正如在图 16.10中所示,我们可以使用 transformer 模型为训练其他模型生成特征。以下代码说明了如何使用 GPT-2 根据输入文本生成特征:

>>> from transformers import GPT2Tokenizer
>>> tokenizer = GPT2Tokenizer.from_pretrained('gpt2')
>>> text = "Let us encode this sentence"
>>> encoded_input = tokenizer(text, return_tensors='pt')
>>> encoded_input
{'input_ids': tensor([[ 5756,   514, 37773,   428,  6827]]), 'attention_mask': tensor([[1, 1, 1, 1, 1]])} 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

这段代码将输入句子文本编码成了 GPT-2 模型的标记化格式。正如我们所见,它将字符串映射到整数表示,并将注意力掩码设置为全 1,这意味着在将编码输入传递给模型时将处理所有单词,如下所示:

>>> from transformers import GPT2Model
>>> model = GPT2Model.from_pretrained('gpt2')
>>> output = model(**encoded_input) 
  • 1
  • 2
  • 3

变量output存储了最后的隐藏状态,即我们基于 GPT-2 的输入句子特征编码:

>>> output['last_hidden_state'].shape
torch.Size([1, 5, 768]) 
  • 1
  • 2

为了抑制冗长的输出,我们仅展示了张量的形状。其第一维是批处理大小(我们只有一个输入文本),其后是句子长度和特征编码的大小。在这里,每个五个单词被编码为一个 768 维向量。

现在,我们可以将这种特征编码应用于给定的数据集,并基于基于 GPT-2 的特征表示训练一个下游分类器,而不是使用如第八章“应用机器学习进行情感分析”中讨论的词袋模型。

此外,另一种使用大型预训练语言模型的方法是微调,正如我们之前讨论过的。在本章稍后我们将看到一个微调的例子。

如果您对使用 GPT-2 的详细信息感兴趣,我们建议查阅以下文档页面:

使用 BERT 进行双向预训练

BERT,全名为双向编码器表示转换器,由 Google 研究团队于 2018 年创建(BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding by J. Devlin, M. Chang, K. Lee, and K. Toutanova, arxiv.org/abs/1810.04805)。值得参考的是,虽然我们不能直接将 GPT 和 BERT 进行比较,因为它们是不同的架构,但 BERT 有 3.45 亿个参数(这使它比 GPT-1 略大一些,其大小仅为 GPT-2 的五分之一)。

正如其名,BERT 具有基于 transformer 编码器的模型结构,利用了双向训练过程。(更准确地说,我们可以认为 BERT 使用“非定向”训练,因为它一次性读取所有输入元素。)在此设置下,某个单词的编码取决于其前后的单词。回想一下,在 GPT 中,输入元素按自然的从左到右顺序读取,这有助于形成强大的生成语言模型。双向训练禁用了 BERT 逐词生成句子的能力,但提供了更高质量的输入编码,用于其他任务,如分类,因为该模型现在可以双向处理信息。

回想一下,在 Transformer 的编码器中,令牌编码是位置编码和令牌嵌入的总和。在 BERT 编码器中,还有一个额外的段嵌入,指示此令牌属于哪个段。这意味着每个令牌表示包含三个部分,正如图 16.13所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.13:为 BERT 编码器准备输入

为什么我们需要 BERT 中的额外段信息?这段信息的需求源于 BERT 的特殊预训练任务,称为下一句预测。在此预训练任务中,每个训练示例包括两个句子,因此需要特殊的段符号来表示它是属于第一个还是第二个句子。

现在,让我们更详细地看一下 BERT 的预训练任务。与所有其他基于 Transformer 的语言模型类似,BERT 有两个训练阶段:预训练和微调。预训练包括两个无监督任务:掩码语言建模下一句预测

掩码语言模型MLM)中,标记被随机替换为所谓的掩码标记 [MASK],模型需要预测这些隐藏的单词。与 GPT 中的下一个单词预测相比,BERT 中的 MLM 更类似于“填空”,因为模型可以关注句子中的所有标记(除了掩码标记)。然而,简单地屏蔽单词可能导致预训练和微调之间的不一致,因为[MASK]标记不会出现在常规文本中。为了减轻这一问题,对于选定的要屏蔽的单词,还有进一步的修改。例如,在 BERT 中,15%的单词被标记为屏蔽。这 15%的随机选择单词接下来会进一步处理为:

  1. 10%的时间保持单词不变

  2. 10%的时间将原始词令牌替换为随机单词

  3. 80%的时间将原始词令牌替换为掩码令牌 [MASK]

除了在引入[MASK]令牌到训练过程中避免上述预训练和微调之间的不一致性之外,这些修改还有其他好处。首先,未更改的单词包括保持原始令牌信息的可能性;否则,模型只能从上下文中学习,而不是从掩码的单词中学习。其次,10%的随机单词防止模型变得懒惰,例如,仅仅返回所给的内容而没有学到任何东西。掩码、随机化和保持单词不变的概率由消融研究选择(参见 GPT-2 论文);例如,作者测试了不同的设置,并发现这种组合效果最好。

图 16.14 描述了一个示例,在这个示例中,单词fox被屏蔽,并且有一定概率保持不变,或者被替换为[MASK]coffee。然后,模型需要预测屏蔽(突出显示)的单词是什么,如图 16.14所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.14:MLM 示例

下一个句子预测是对 BERT 的双向编码进行自然修改,考虑到下一个词预测任务的关系。实际上,许多重要的自然语言处理任务,如问答,依赖于文档中两个句子之间的关系。由于输入长度限制,通常单句预测训练很难捕捉这种关系。

在下一个句子预测任务中,模型会得到两个句子 A 和 B,格式如下:

[CLS] A [SEP] B [SEP]

[CLS] 是分类标记,用作解码器输出中预测标签的占位符,同时也是表示句子开头的标记。另一方面,[SEP] 标记附加在每个句子的末尾。然后,模型需要分类是否 B 是 A 的下一个句子(“IsNext”)。为了向模型提供平衡的数据集,50%的样本标记为“IsNext”,而剩余的样本标记为“NotNext”。

BERT 同时在这两个任务上进行预训练,即屏蔽句子和下一个句子预测。在这里,BERT 的训练目标是最小化这两个任务的组合损失函数。

从预训练模型开始,需要针对微调阶段中不同的下游任务进行特定的修改。每个输入示例都需要匹配特定的格式;例如,如果包含多个句子,则应以[CLS]标记开头并使用[SEP]标记分隔。

大致而言,BERT 可以在四类任务上进行微调:(a) 句对分类;(b) 单句分类;© 问答;(d) 单句标注。

其中,(a) 和 (b) 是序列级分类任务,只需在输出表示的[CLS]标记上添加额外的 softmax 层。而 © 和 (d) 则是标记级分类任务。这意味着模型将所有相关标记的输出表示传递给 softmax 层,以预测每个单独标记的类别标签。

问答

任务(c),即问答,似乎与其他流行的分类任务(如情感分类或语音标记)相比少有讨论。在问答中,每个输入示例可以分为两部分,问题和帮助回答问题的段落。模型需要指出段落中的起始和结束标记,形成一个合适的答案。这意味着模型需要为段落中的每个单词生成一个标记,指示该单词是起始标记、结束标记还是其他。值得一提的是,输出可能包含在起始标记之前出现的结束标记,这在生成答案时可能会导致冲突。这种输出将被识别为对问题的“无答案”。

图 16.15所示,模型的微调设置具有非常简单的结构:输入编码器连接到预训练的 BERT,添加一个 softmax 层用于分类。一旦模型结构设置完成,所有参数将随学习过程进行调整。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.15:使用 BERT 微调不同的语言任务

双赢之选:BART

双向自回归变换器,简称BART,由 Facebook AI Research 的研究人员在 2019 年开发:BART:用于自然语言生成、翻译和理解的去噪序列到序列预训练Lewis等人,arxiv.org/abs/1910.13461。回顾前文,我们提到 GPT 利用变换器的解码器结构,而 BERT 利用变换器的编码器结构。这两个模型因此能够很好地执行不同的任务:GPT 的特长是生成文本,而 BERT 在分类任务上表现更好。BART 可以看作是 GPT 和 BERT 的泛化。正如本节标题所示,BART 能够同时完成生成和分类文本的任务。它能够处理这两个任务的原因在于该模型配备了双向编码器和从左到右的自回归解码器。

您可能想知道这与原始变压器的区别在哪里。模型大小有一些变化,以及一些较小的更改,如激活函数的选择。然而,其中一个更有趣的变化是,BART 使用不同的模型输入。原始变压器模型是为语言翻译设计的,因此有两个输入:要翻译的文本(编码器的源序列)和翻译(解码器的目标序列)。此外,解码器还接收编码的源序列,如前文所述在图 16.6中。然而,在 BART 中,输入格式被泛化,只使用源序列作为输入。BART 可以执行更广泛的任务,包括语言翻译,在这种情况下仍然需要目标序列来计算损失并微调模型,但不需要直接将其馈送到解码器中。

现在让我们更详细地看一下 BART 的模型结构。如前所述,BART 由一个双向编码器和一个自回归解码器组成。在接收到一个纯文本的训练示例后,输入将首先被“污染”,然后由编码器编码。这些输入编码将随后传递给解码器,连同生成的标记一起。编码器输出与原始文本之间的交叉熵损失将被计算,然后通过学习过程进行优化。想象一个转换器,我们在解码器中有两种不同语言的文本作为输入:要翻译的初始文本(源文本)和目标语言中生成的文本。BART 可以被理解为用损坏的文本替换前者,用输入文本本身替换后者。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.16:BART 的模型结构

更详细地解释一下损坏步骤,回想一下,BERT 和 GPT 是通过重构掩码单词进行预训练的:BERT 是“填补空白”,而 GPT 是“预测下一个单词”。这些预训练任务也可以被视为重构损坏的句子,因为掩盖单词是损坏句子的一种方式。BART 提供以下可以应用于清洁文本的损坏方法:

  • 标记掩盖

  • 标记删除

  • 文本填充

  • 句子排列

  • 文档旋转

以上列出的一种或多种技术可以应用于同一句子;在最坏的情况下,所有信息都被污染和破坏,文本变得毫无用处。因此,编码器的效用有限,只有解码器模块正常工作时,模型才会更像是单向语言。

BART 可以在广泛的下游任务中进行微调,包括(a)序列分类,(b)标记分类,(c)序列生成和(d)机器翻译。与 BERT 一样,需要对输入进行微小的更改以执行不同的任务。

在序列分类任务中,需要附加一个额外的令牌到输入中,作为生成的标签令牌,这类似于 BERT 中的 [CLS] 令牌。此外,不会破坏输入,而是将未损坏的输入同时馈送到编码器和解码器,以便模型能够充分利用输入。

对于令牌分类,额外的令牌变得不必要,模型可以直接使用每个令牌的生成表示进行分类。

BART 中的序列生成与 GPT 有所不同,这是因为存在编码器。通过 BART 进行序列生成任务不是从头开始生成文本,而更类似于摘要,模型被给定一组上下文并要求生成摘要或对特定问题的抽象回答。为此,整个输入序列被馈送到编码器,而解码器则自回归地生成输出。

最后,考虑到 BART 与原始变压器之间的相似性,自然而然地可以进行机器翻译。但是,与训练原始变压器的完全相同过程不同,研究人员考虑了将整个 BART 模型作为预训练解码器并将新的一组随机初始化参数作为新的附加编码器添加来完成翻译模型。然后,微调阶段可以分为两步:

  1. 首先,冻结除编码器外的所有参数。

  2. 然后,更新模型中的所有参数

BART 在几个基准数据集上进行了评估,用于各种任务,并与其他著名的语言模型(如 BERT)相比,取得了非常有竞争力的结果。特别是在生成任务中,包括抽象问答、对话回复和摘要任务中,BART 实现了最先进的结果。

在 PyTorch 中微调 BERT 模型

现在,我们已经介绍并讨论了所有必要的概念以及原始变压器和流行的基于变压器的模型背后的理论,现在是时候看看更实际的部分了!在本节中,您将学习如何在 PyTorch 中对 BERT 模型进行情感分类的微调。

请注意,虽然有许多其他选择的基于变压器的模型,但 BERT 在模型流行度和具有可管理模型大小之间提供了良好的平衡,因此可以在单个 GPU 上进行微调。还请注意,从头开始预训练 BERT 是痛苦且相当不必要的,考虑到 Hugging Face 提供的 transformers Python 包中包含了一堆准备好进行微调的预训练模型。

在接下来的几节中,您将看到如何准备和标记化 IMDb 电影评论数据集,并对精炼的 BERT 模型进行微调以执行情感分类。尽管有许多其他有趣的语言模型应用,我们故意选择情感分类作为一个简单但经典的例子。此外,通过使用熟悉的 IMDb 电影评论数据集,我们可以通过将其与逻辑回归模型在第八章 应用机器学习进行情感分析中和 RNN 在第十五章 使用递归神经网络建模顺序数据中进行比较,来获取 BERT 模型的预测性能。

加载 IMDb 电影评论数据集

在这个小节中,我们将首先加载所需的包和数据集,并将其分为训练集、验证集和测试集。

在本教程中与 BERT 相关的部分,我们将主要使用 Hugging Face 创建的开源transformers库(huggingface.co/transformers/),该库已在前一节中安装好,使用 GPT-2 生成新文本

本章中我们使用的DistilBERT模型是一个轻量级的 transformer 模型,是通过蒸馏预训练的 BERT 基础模型而来。原始的不区分大小写的 BERT 基础模型包含超过 1.1 亿个参数,而 DistilBERT 的参数少了 40%。此外,DistilBERT 运行速度快了 60%,同时还保留了 GLUE 语言理解基准测试中 BERT 性能的 95%。

以下代码导入了本章中将要使用的所有包,以准备数据并微调 DistilBERT 模型:

>>> import gzip
>>> import shutil
>>> import time
>>> import pandas as pd
>>> import requests
>>> import torch
>>> import torch.nn.functional as F
>>> import torchtext
>>> import transformers
>>> from transformers import DistilBertTokenizerFast
>>> from transformers import DistilBertForSequenceClassification 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

接下来,我们指定一些通用设置,包括网络训练的时代数、设备规范和随机种子。为了复现结果,请确保设置一个特定的随机种子,例如123

>>> torch.backends.cudnn.deterministic = True
>>> RANDOM_SEED = 123
>>> torch.manual_seed(RANDOM_SEED)
>>> DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
>>> NUM_EPOCHS = 3 
  • 1
  • 2
  • 3
  • 4
  • 5

我们将处理 IMDb 电影评论数据集,您已经在第八章第十五章中见过它。以下代码获取了压缩数据集并解压缩它:

>>> url = ("https://github.com/rasbt/"
...        "machine-learning-book/raw/"
...        "main/ch08/movie_data.csv.gz")
>>> filename = url.split("/")[-1]
>>> with open(filename, "wb") as f:
...     r = requests.get(url)
...     f.write(r.content)
>>> with gzip.open('movie_data.csv.gz', 'rb') as f_in:
...     with open('movie_data.csv', 'wb') as f_out:
...         shutil.copyfileobj(f_in, f_out) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

如果您仍然在硬盘上拥有第八章中的movie_data.csv文件,可以跳过此下载和解压缩过程。

然后,我们将数据加载到一个 pandas 的DataFrame中,并确保一切正常:

>>> df = pd.read_csv('movie_data.csv')
>>> df.head(3) 
  • 1
  • 2

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 16.17:IMDb 电影评论数据集的前三行

下一步是将数据集分割为单独的训练、验证和测试集。在这里,我们使用 70%的评论作为训练集,10%作为验证集,剩余的 20%作为测试集:

>>> train_texts = df.iloc[:35000]['review'].values
>>> train_labels = df.iloc[:35000]['sentiment'].values
>>> valid_texts = df.iloc[35000:40000]['review'].values
>>> valid_labels = df.iloc[35000:40000]['sentiment'].values
>>> test_texts = df.iloc[40000:]['review'].values
>>> test_labels = df.iloc[40000:]['sentiment'].values 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

对数据集进行标记化处理

到目前为止,我们已经获取了训练集、验证集和测试集的文本和标签。现在,我们将使用继承自预训练模型类的分词器实现,将文本标记化为单独的单词标记:

>>> tokenizer = DistilBertTokenizerFast.from_pretrained(
...     'distilbert-base-uncased'
... )
>>> train_encodings = tokenizer(list(train_texts), truncation=True, padding=True)
>>> valid_encodings = tokenizer(list(valid_texts), truncation=True, padding=True)
>>> test_encodings = tokenizer(list(test_texts), truncation=True, padding=True) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

选择不同的分词器

如果您有兴趣应用不同类型的分词器,请随时探索tokenizers包(huggingface.co/docs/tokenizers/python/latest/),该包也由 Hugging Face 构建和维护。然而,继承的分词器保持了预训练模型与数据集之间的一致性,这样可以节省我们找到与模型对应的特定分词器的额外工作。换句话说,如果您想要微调一个预训练模型,使用继承的分词器是推荐的方法。

最后,让我们将所有内容打包到一个名为IMDbDataset的类中,并创建相应的数据加载器。这样一个自定义的数据集类允许我们为我们自定义的电影评论数据集中的所有相关特征和函数进行定制:

>>> class IMDbDataset(torch.utils.data.Dataset):
...     def __init__(self, encodings, labels):
...         self.encodings = encodings
...         self.labels = labels
>>>     def __getitem__(self, idx):
...         item = {key: torch.tensor(val[idx]) 
...                 for key, val in self.encodings.items()}
...         item['labels'] = torch.tensor(self.labels[idx])
...         return item
>>>     def __len__(self):
...         return len(self.labels)
>>> train_dataset = IMDbDataset(train_encodings, train_labels)
>>> valid_dataset = IMDbDataset(valid_encodings, valid_labels)
>>> test_dataset = IMDbDataset(test_encodings, test_labels)
>>> train_loader = torch.utils.data.DataLoader(
...     train_dataset, batch_size=16, shuffle=True) 
>>> valid_loader = torch.utils.data.DataLoader(
...     valid_dataset, batch_size=16, shuffle=False) 
>>> test_loader = torch.utils.data.DataLoader(
...     test_dataset, batch_size=16, shuffle=False) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

尽管整体数据加载器设置应该与前几章类似,但一个值得注意的细节是 __getitem__ 方法中的 item 变量。我们之前生成的编码存储了关于标记化文本的大量信息。通过我们用于将字典分配给 item 变量的字典推导式,我们只提取了最相关的信息。例如,生成的字典条目包括 input_ids(词汇表中对应于标记的唯一整数)、labels(类标签)和 attention_mask。这里,attention_mask 是一个具有二进制值(0 和 1)的张量,指示模型应该关注哪些标记。特别地,0 对应于用于填充序列以达到相等长度的标记,并且被模型忽略;1 对应于实际文本标记。

加载和微调预训练的 BERT 模型

在处理数据准备后,在本小节中,您将看到如何加载预训练的 DistilBERT 模型并使用我们刚刚创建的数据集进行微调。加载预训练模型的代码如下所示:

>>> model = DistilBertForSequenceClassification.from_pretrained(
...     'distilbert-base-uncased')
>>> model.to(DEVICE)
>>> model.train()
>>> optim = torch.optim.Adam(model.parameters(), lr=5e-5) 
  • 1
  • 2
  • 3
  • 4
  • 5

DistilBertForSequenceClassification 指定了我们希望在其上微调模型的下游任务,即在本例中进行的序列分类。正如前面提到的,'distilbert-base-uncased' 是一个轻量级的 BERT 小写基础模型,大小适中且性能良好。注意,“uncased”表示该模型不区分大小写字母。

使用其他预训练的 transformers

transformers 包还提供了许多其他预训练模型和各种下游任务供微调使用。请访问 huggingface.co/transformers/ 查看详情。

现在是训练模型的时候了。我们可以将其分为两部分。首先,我们需要定义一个准确率函数来评估模型性能。请注意,这个准确率函数计算传统的分类准确率。为什么这么啰嗦?在这里,我们按批次加载数据集以解决在使用大型深度学习模型时可能遇到的 RAM 或 GPU 内存(VRAM)限制:

>>> def compute_accuracy(model, data_loader, device):
...         with torch.no_grad():
...             correct_pred, num_examples = 0, 0
...             for batch_idx, batch in enumerate(data_loader):
...                 ### Prepare data
...                 input_ids = batch['input_ids'].to(device)
...                 attention_mask = \
...                     batch['attention_mask'].to(device)
...                 labels = batch['labels'].to(device)

...                 outputs = model(input_ids,
...                    attention_mask=attention_mask)
...                 logits = outputs['logits']
...                 predicted_labels = torch.argmax(logits, 1)
...                 num_examples += labels.size(0)
...                 correct_pred += \
...                     (predicted_labels == labels).sum()
...         return correct_pred.float()/num_examples * 100 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

compute_accuracy函数中,我们加载一个给定的批次,然后从输出中获取预测标签。在这个过程中,我们通过num_examples变量跟踪总样本数。类似地,我们通过correct_pred变量跟踪正确预测的数量。最后,在完整数据集上迭代完成后,我们计算准确率作为正确预测标签的比例。

通过compute_accuracy函数,您已经可以大致了解如何使用 transformer 模型获取类标签。也就是说,我们将input_idsattention_mask信息(在这里表示一个标记是实际文本标记还是用于填充序列以达到相等长度的标记)馈送到模型中。然后,model调用返回一个特定于 transformer 库的SequenceClassifierOutput对象的输出。然后,我们通过argmax函数将这个对象中的 logits 转换为类标签,就像我们在前几章中所做的那样。

最后,让我们进入主要部分:训练(或者说,微调)循环。正如您将注意到的那样,从transformers库微调模型与从头开始在纯 PyTorch 中训练模型非常相似:

>>> start_time = time.time()
>>> for epoch in range(NUM_EPOCHS):

...     model.train()

...     for batch_idx, batch in enumerate(train_loader):

...         ### Prepare data
...         input_ids = batch['input_ids'].to(DEVICE)
...         attention_mask = batch['attention_mask'].to(DEVICE)
...         labels = batch['labels'].to(DEVICE)
...         ### Forward pass
...         outputs = model(input_ids, 
...                         attention_mask=attention_mask,
...                         labels=labels)
...         loss, logits = outputs['loss'], outputs['logits']

...         ### Backward pass
...         optim.zero_grad()
...         loss.backward()
...         optim.step()

...         ### Logging
...         if not batch_idx % 250:
...             print(f'Epoch: {epoch+1:04d}/{NUM_EPOCHS:04d}' 
...                     f' | Batch' 
...                     f'{batch_idx:04d}/'
...                     f'{len(train_loader):04d} | '
...                     f'Loss: {loss:.4f}')

...     model.eval()
...     with torch.set_grad_enabled(False):
...         print(f'Training accuracy: '
...              f'{compute_accuracy(model, train_loader, DEVICE):.2f}%'
...              f'\nValid accuracy: '
...              f'{compute_accuracy(model, valid_loader, DEVICE):.2f}%')

...     print(f'Time elapsed: {(time.time() - start_time)/60:.2f} min')

... print(f'Total Training Time: {(time.time() - start_time)/60:.2f} min')
... print(f'Test accuracy: {compute_accuracy(model, test_loader, DEVICE):.2f}%') 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41

上述代码生成的输出如下(请注意,代码并非完全确定性,因此您得到的结果可能略有不同):

Epoch: 0001/0003 | Batch 0000/2188 | Loss: 0.6771
Epoch: 0001/0003 | Batch 0250/2188 | Loss: 0.3006
Epoch: 0001/0003 | Batch 0500/2188 | Loss: 0.3678
Epoch: 0001/0003 | Batch 0750/2188 | Loss: 0.1487
Epoch: 0001/0003 | Batch 1000/2188 | Loss: 0.6674
Epoch: 0001/0003 | Batch 1250/2188 | Loss: 0.3264
Epoch: 0001/0003 | Batch 1500/2188 | Loss: 0.4358
Epoch: 0001/0003 | Batch 1750/2188 | Loss: 0.2579
Epoch: 0001/0003 | Batch 2000/2188 | Loss: 0.2474
Training accuracy: 96.32%
Valid accuracy: 92.34%
Time elapsed: 20.67 min
Epoch: 0002/0003 | Batch 0000/2188 | Loss: 0.0850
Epoch: 0002/0003 | Batch 0250/2188 | Loss: 0.3433
Epoch: 0002/0003 | Batch 0500/2188 | Loss: 0.0793
Epoch: 0002/0003 | Batch 0750/2188 | Loss: 0.0061
Epoch: 0002/0003 | Batch 1000/2188 | Loss: 0.1536
Epoch: 0002/0003 | Batch 1250/2188 | Loss: 0.0816
Epoch: 0002/0003 | Batch 1500/2188 | Loss: 0.0786
Epoch: 0002/0003 | Batch 1750/2188 | Loss: 0.1395
Epoch: 0002/0003 | Batch 2000/2188 | Loss: 0.0344
Training accuracy: 98.35%
Valid accuracy: 92.46%
Time elapsed: 41.41 min
Epoch: 0003/0003 | Batch 0000/2188 | Loss: 0.0403
Epoch: 0003/0003 | Batch 0250/2188 | Loss: 0.0036
Epoch: 0003/0003 | Batch 0500/2188 | Loss: 0.0156
Epoch: 0003/0003 | Batch 0750/2188 | Loss: 0.0114
Epoch: 0003/0003 | Batch 1000/2188 | Loss: 0.1227
Epoch: 0003/0003 | Batch 1250/2188 | Loss: 0.0125
Epoch: 0003/0003 | Batch 1500/2188 | Loss: 0.0074
Epoch: 0003/0003 | Batch 1750/2188 | Loss: 0.0202
Epoch: 0003/0003 | Batch 2000/2188 | Loss: 0.0746
Training accuracy: 99.08%
Valid accuracy: 91.84%
Time elapsed: 62.15 min
Total Training Time: 62.15 min
Test accuracy: 92.50% 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38

在此代码中,我们遍历多个 epochs。在每个 epoch 中,我们执行以下步骤:

  1. 将输入加载到我们正在工作的设备上(GPU 或 CPU)。

  2. 计算模型输出和损失。

  3. 通过反向传播损失来调整权重参数。

  4. 对训练集和验证集上的模型性能进行评估。

请注意,不同设备上的训练时间可能会有所不同。在三个 epochs 后,测试数据集的准确率达到约 93%,与 RNN 在第十五章中达到的 85%的测试准确率相比,这是一个显著的改进。

使用 Trainer API 更方便地微调 transformer 模型。

在前面的小节中,我们手动在 PyTorch 中实现了训练循环,以说明微调 transformer 模型与从头开始训练 RNN 或 CNN 模型实际上并没有太大不同。但是,请注意,transformers库包含一些额外的便利功能,例如我们将在本小节中介绍的 Trainer API。

Hugging Face 提供的 Trainer API 针对具有广泛的训练选项和各种内置功能的 Transformer 模型进行了优化。使用 Trainer API 时,我们可以跳过自己编写训练循环的工作,训练或微调 Transformer 模型就像调用函数(或方法)一样简单。让我们看看实际操作中是如何工作的。

在加载预训练模型之后:

>>> model = DistilBertForSequenceClassification.from_pretrained(
...     'distilbert-base-uncased')
>>> model.to(DEVICE)
>>> model.train(); 
  • 1
  • 2
  • 3
  • 4

可以用以下代码替换上一节的训练循环:

>>> optim = torch.optim.Adam(model.parameters(), lr=5e-5)
>>> from transformers import Trainer, TrainingArguments
>>> training_args = TrainingArguments(
...     output_dir='./results', 
...     num_train_epochs=3,     
...     per_device_train_batch_size=16, 
...     per_device_eval_batch_size=16,   
...     logging_dir='./logs',
...     logging_steps=10,
... )
>>> trainer = Trainer(
...    model=model,
...    args=training_args,
...    train_dataset=train_dataset,
...    optimizers=(optim, None) # optim and learning rate scheduler
... ) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

在前述代码片段中,我们首先定义了相对简单的训练参数设置,这些设置涉及输入和输出位置、epoch 数量和批处理大小。我们尽量保持设置尽可能简单;然而,还有许多其他可用的设置,建议查阅TrainingArguments文档页面以获取更多详细信息:huggingface.co/transformers/main_classes/trainer.html#trainingarguments

然后,我们将这些TrainingArguments设置传递给Trainer类,以实例化一个新的trainer对象。在用设置初始化了trainer之后,需要调用trainer.train()方法来训练模型(稍后我们将进一步使用此方法)。使用 Trainer API 就像前面的代码示例中展示的那样简单,不需要进一步的样板代码。

但是,您可能已经注意到这些代码片段中没有涉及测试数据集,并且在本小节中我们还未指定任何评估指标。这是因为 Trainer API 仅显示训练损失,并且默认情况下不提供模型在训练过程中的评估。有两种方法可以显示最终模型的性能,我们将在接下来进行说明。

评估最终模型的第一种方法是为另一个Trainer实例定义一个评估函数作为compute_metrics参数。compute_metrics函数操作模型测试预测的 logits(这是模型的默认输出)和测试标签。为了实例化此函数,建议通过pip install datasets安装 Hugging Face 的datasets库,并按以下方式使用:

>>> from datasets import load_metric
>>> import numpy as np
>>> metric = load_metric("accuracy")
>>> def compute_metrics(eval_pred):
...       logits, labels = eval_pred
...       # note: logits are a numpy array, not a pytorch tensor
...       predictions = np.argmax(logits, axis=-1)
...       return metric.compute(
...           predictions=predictions, references=labels) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

更新后的Trainer实例化(现在包括compute_metrics)如下所示:

>>> trainer=Trainer(
...     model=model,        
...     args=training_args,
...     train_dataset=train_dataset,
...     eval_dataset=test_dataset,
...     compute_metrics=compute_metrics,
...     optimizers=(optim, None) # optim and learning rate scheduler
... ) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

现在,让我们再次训练模型(请注意,代码不是完全确定性的,这就是为什么可能会得到稍微不同结果的原因):

>>> start_time = time.time()
>>> trainer.train()
***** Running training *****
  Num examples = 35000
  Num Epochs = 3
  Instantaneous batch size per device = 16
  Total train batch size (w. parallel, distributed & accumulation) = 16
  Gradient Accumulation steps = 1
  Total optimization steps = 6564
Step  Training Loss
10    0.705800
20    0.684100
30    0.681500
40    0.591600
50    0.328600
60    0.478300
...
>>> print(f'Total Training Time: ' 
...       f'{(time.time() - start_time)/60:.2f} min')
Total Training Time: 45.36 min 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

训练完成后(具体时间取决于您的 GPU,可能需要长达一小时),我们可以调用trainer.evaluate()来获取模型在测试集上的性能:

>>> print(trainer.evaluate())
***** Running Evaluation *****
Num examples = 10000
Batch size = 16
100%|█████████████████████████████████████████| 625/625 [10:59<00:00,  1.06s/it]
{'eval_loss': 0.30534815788269043,
 'eval_accuracy': 0.9327,
 'eval_runtime': 87.1161,
 'eval_samples_per_second': 114.789,
 'eval_steps_per_second': 7.174,
 'epoch': 3.0} 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

正如我们所见,评估准确率约为 94%,与我们之前使用的 PyTorch 训练循环相似。(请注意,我们已跳过训练步骤,因为在之前的trainer.train()调用后,model已经经过微调。)我们的手动训练方法与使用Trainer类有一些小差异,因为Trainer类使用了一些不同和一些额外的设置。

我们可以采用第二种方法来计算最终的测试集准确率,即重新使用我们在前一节中定义的compute_accuracy函数。我们可以通过运行以下代码直接评估经过微调的模型在测试数据集上的性能:

>>> model.eval()
>>> model.to(DEVICE)
>>> print(f'Test accuracy: {compute_accuracy(model, test_loader, DEVICE):.2f}%')
Test accuracy: 93.27% 
  • 1
  • 2
  • 3
  • 4

实际上,如果您想在训练过程中定期检查模型的性能,可以通过以下方式定义训练参数,要求训练器在每个 epoch 后打印模型评估:

>>> from transformers import TrainingArguments
>>> training_args = TrainingArguments("test_trainer", 
...     evaluation_strategy="epoch", ...) 
  • 1
  • 2
  • 3

然而,如果您计划更改或优化超参数,并重复几次微调过程,我们建议使用验证集来实现此目的,以保持测试集的独立性。我们可以通过使用valid_dataset实例化Trainer来实现这一点:

>>> trainer=Trainer(
...     model=model,        
...     args=training_args,
...     train_dataset=train_dataset,
...     eval_dataset=valid_dataset,
...     compute_metrics=compute_metrics,
... ) 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

在本节中,我们看到了如何为分类任务微调 BERT 模型。这与使用其他深度学习架构(如通常从头开始训练的 RNN)有所不同。然而,除非我们正在进行研究并试图开发新的 transformer 架构(这是一项非常昂贵的工作),否则预训练 transformer 模型并不是必要的。由于 transformer 模型是在通用的未标记数据集资源上训练的,自己进行预训练可能并不是一个很好的时间和资源利用方式;微调才是正确的方法。

总结

在本章中,我们介绍了自然语言处理的一种全新模型架构,即 transformer 架构。transformer 架构建立在一种称为自注意力的概念之上,并逐步介绍了这一概念。首先,我们研究了带有注意力机制的 RNN,以提高其对长句子的翻译能力。然后,我们温柔地引入了自注意力的概念,并解释了它在 transformer 中如何在多头注意力模块中使用。

自从 2017 年首次发布 transformer 架构以来,已经涌现出许多不同的衍生变体并不断演化。在本章中,我们重点介绍了一些最受欢迎的变体:GPT 模型系列、BERT 和 BART。GPT 是一个单向模型,特别擅长生成新的文本。BERT 采用双向方法,更适合其他类型的任务,例如分类。最后,BART 结合了 BERT 的双向编码器和 GPT 的单向解码器。有兴趣的读者可以通过以下两篇调研文章了解更多基于 transformer 的架构:

  1. 自然语言处理的预训练模型:一项调查,由Qiu和同事们,2020 年。可在 arxiv.org/abs/2003.08271 获取。

  2. AMMUS:自然语言处理中基于 Transformer 预训练模型的调查,由Kayan和同事们,2021 年。可在 arxiv.org/abs/2108.05542 获取。

Transformer 模型通常比 RNNs 更需要数据,并且需要大量数据进行预训练。预训练利用大量未标记数据构建一个通用语言模型,然后通过在较小的有标签的数据集上微调它,使其专门用于特定任务。

为了看到这在实践中是如何运作的,我们从 Hugging Face 的 transformers 库中下载了一个预训练的 BERT 模型,并对其在 IMDb 电影评论数据集上进行了情感分类微调。

在接下来的章节中,我们将讨论生成对抗网络。正如其名称所示,生成对抗网络是可以用于生成新数据的模型,类似于我们在本章讨论的 GPT 模型。然而,我们现在将自然语言建模的主题抛在身后,将在计算机视觉的背景下研究生成对抗网络并生成新图像——这正是这些网络最初设计用于的任务。

加入我们书籍的 Discord 空间

加入这本书的 Discord 工作区,与作者进行每月一次的问答会话:

packt.link/MLwPyTorch

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/IT小白/article/detail/1003402
推荐阅读
相关标签
  

闽ICP备14008679号