👋欢迎来到黄铜扳手图书馆

自然语言处理(四):自然语言处理中的神经网络基础

自然语言处理中常用的神经网络

自然语言处理中的神经网络基础

  本章首先介绍在自然语言处理中常用的四种神经网络模型,即多层感知器模型、卷积神经网络、循环神经网络和以Transformer为代表的自注意力模型。然后,介绍如何通过优化模型参数训练这些模型。除介绍每种模型的PyTorch调用方式外,还将介绍如何使用以上模型完成两个综合性的实战项目,即:以情感分类为代表的文本分类任务和以词性标注为代表的序列标注任务。

多层感知器模型

感知器

  感知器(Perceptron)是最简单也是最早出现的机器学习模型,其灵感直接来源于生产生活的实践。例如,在公司面试时,经常由多位面试官对一位面试者打分,最终将多位面试官的打分求和,如果分数超过一定的阈值,则录用该面试者,否则不予录用。假设有几位面试官,每人的打分分别为 $x_1,x_2,\cdots,x_n$ ,则总分 $s=x_1+x_2+\cdots+x_n$ ,如果 $s \ge t$ ,则给予录用,其中 $t$ 被称为阈值,$x_1x_2,\cdots,x_n$ 被称为输入,可以使用向量 $\bm{x} = [x_1,x_2,\cdots,x_n]$ 表示。然而,在这些面试官中,有一些经验比较丰富,而有一些则是刚入门的新手,如果简单地将它们的打分进行相加,最终的得分显然不够客观,因此可以通过对面试官的打分进行加权的方法解决,即为经验丰富的面试官赋予较高的权重,而为新手赋予较低的权重。假设几位面试官的权重分别为 $w_1,w_2,\cdots,w_n$ ,则最终的分数为 $s=w_1x_1+w_2x_2+\cdots+w_nx_n$ ,同样可以使用向量 $\bm{w}=[w_1,w_2,\cdots,w_n]$ 表示 $n$ 个权重,则分数可以写成权重向量和输入向量的点积,即 $s = \bm{w}\cdot \bm{x}$ 于是最终的输出 $y$ 为:

$$\begin{aligned} y = \begin{cases} 1, &\text{如果}s \ge t \\[5pt] 0, &否则 \end{cases} = \begin{cases} 1, &\text{如果}\bm{w}\cdot\bm{x} \ge t \\[5pt] 0, &否则 \end{cases} \tag{1}\end{aligned}$$

  式中,输入 $y=1$ 表示录用,$y=0$ 表示不录用。这就是感知器模型,其还可以写成以下的形式:

$$\begin{aligned} y = \begin{cases} 1, &\text{如果}\bm{w}\cdot\bm{x}+b\ge0 \\[5pt] 0, &否则 \end{cases} \tag{2}\end{aligned}$$

  式中,$b = -t$ ,又被称为偏差项。
  当使用感知器模型时,有两个棘手的问题需要加以解决。首先是如何将一个问 题的原始输入(Raw Input)转换成输入向量 $\bm{x}$ ,此过程又被称为特征提取(Feature Extraction)。在自然语言处理中,其实就是如何用数值向量表示文本,可以使用文本的表示介绍的文本表示方法;其次是如何合理地设置权重 $\bm{w}$ 和偏差项 $b$ (它们也被称为模型参数),此过程又被称为参数学习(也称参数优化或模型训练)。很多现实生活中遇到的问题都可以使用感知器模型加以解决,比如识别一个用户评论句子的情感极性是褒义还是贬义等,在自然语言处理中,这些问题又被归为文本分类问题。

线性回归

  前一节介绍的感知器是一个分类模型,即输出结果为离散的类别(如褒义或贬义)。除了分类模型,还有一大类机器学习模型被称为回归(Regression)模型,其与分类模型的本质区别在于输出的结果不是离散的类别,而是连续的实数值。在实际生活中,回归模型也有大量的应用,如预测股票的指数、天气预报中温度的预测等。类似地,在情感分析中,如果目标不是预测文本的情感极性,而是一个情感强弱的分数,如电商或影评网站中用户对商品或电影的评分等,则是一个回归问题。
  线性回归(LinearRegression)是最简单的回归模型。与感知器类似,线性回归模型将输出 $y$ 建模为对输入 $\bm{x}$ 中各个元素的线性加权和,最后也可以再加上偏差项 $b$ ,即 $y=w_1x_1+w_2x_2+\cdots + w_nx_n+b=\bm{w}\cdot \bm{x} + b$ 。

Logistic回归

  线性回归输出值的大小(值域)是任意的,有时需要将其限制在一定的范围 内。有很多函数能够实现此功能,它们又被称为激活函数(Activation Function),其中Logistic函数经常被用到,其形式为:

$$\begin{aligned} y = \frac{L}{1+e^{-k(z-z_0)}} \tag{3}\end{aligned}$$

  该函数能将 $y$ 的值限制在 $0(z \to -\infin)$ 到 $L(z \to +\infin)$ 之间,当 $z=z_0$ 时,$\displaystyle y=\frac{L}{2}$ ; $k$ 控制了函数的陡峭程度。若 $z=w_1x_1+w_2x_2+\cdots+w_nx_n+b$ ,此模型又被称为Logistic回归(Logistic Regression)模型。
  虽然被称为回归模型,但是Logistic回归经常被用于分类问题。这是如何做到的呢?如果将Logistic函数中的参数进行如下设置,$L=1,k=1,z_0=0$ 。此时函数形式为:

$$\begin{aligned} y = \frac{1}{1+e^{-z}} \tag{4}\end{aligned}$$

  该函数又被称为Sigmoid函数,图4-1展示了该函数的形状(呈S形,所以被称为Sigmoid函数),其值域恰好在 $0\backsim1$ 之间,所以经过Sigmoid函数归一化的模型输出可以看作一个输入属于某一类别的概率值(假设只有两个类别,因此也被称为二元分类问题)。除了可以输出概率值,Sigmoid函数另一个较好的性质是其导数比较容易求得( $y'=y(1-y)$ ),这为后续使用基于梯度的参数优化算法带来了一定的便利。

图1 Sigmoid函数图示

Softmax回归

  Sigmoid回归虽然可以用于处理二元分类问题,但是很多现实问题的类别可 能不止两个,如手写体数字的识别,输出属于0〜9共10个数字中的一个,即有10个类别。在自然语言处理中,如文本分类、词性标注等问题,均属于多元分类问题,即使是情感极性识别也一样,除了褒义和贬义,还可以增加一个中性类别。那么,如何处理多元分类问题呢?其中一种方法和Sigmoid回归的思想类似,即对第 $i$ 个类别使用线性回归打一个分数,$z_i=w_{in}x_n+w_{in}x_n+\cdots+w_{in}x_n+b_n$ 。式中,$w_{ij}$ 表示第 $i$ 个类别对应的第 $j$ 个输入的权重。然后,对多个分数使用指数函数进行归一化计算,并获得一个输入属于某个类别的概率。该方法又称Softmax回归,具体公式为:

$$\begin{aligned} y_i = \text{Softmax}(\bm{z})_i=\frac{e^{z_i}}{e^{z_1}+e^{z_2}+\cdots+e^{z_m}} \tag{5}\end{aligned}$$

  式中,$\bm{z}$ 表示向量 $[z_1,z_2,\cdots,z_m]$ ; $m$ 表示类别数; $y_i$ 表示第分个类别的概率。 图2展示了Softmax回归模型示意图。

Softmax

图2 Softmax回归模型示意图

当 $m=2$ ,即处理二元分类问题时,(5)可以写为:

$$\begin{aligned} y_1=\frac{e^{z_1}}{e^{z_1}+e^{z_2}}=\frac{1}{1+e^{-z_1-z_2}} \tag{6}\end{aligned}$$

  此公式即Sigmoid函数形式,也就是Sigmoid函数是Softmax函数在处理二元分类问题时的一个特例。
  进一步地,将Softmax回归模型公式展开,其形式为:

$$\begin{aligned} \begin{bmatrix} y_1 \\[5pt] y_2 \\[5pt] \vdots \\[5pt] y_m \\[2pt] \end{bmatrix} =\text{Softmax} \begin{pmatrix} w_{11}x_1+w_{12}x_2+\cdots+w_{1n}x_n+b_1 \\[5pt] w_{21}x_1+w_{22}x_2+\cdots+w_{2n}x_n+b_2 \\[5pt] \vdots \\[5pt] w_{m1}x_1+w_{m2}x_2+\cdots+w_{mn}x_n+b_m \end{pmatrix} \tag{7}\end{aligned}$$

  然后,可以用矩阵乘法的形式重写该公式,具体为:

$$\begin{aligned} \begin{bmatrix} y_1 \\[5pt] y_2 \\[5pt] \vdots \\[5pt] y_m \\[2pt] \end{bmatrix} =\text{Softmax} \left( \begin{bmatrix} w_{11} & w_{12} & \cdots & w_{1n} \\[5pt] w_{21} & w_{22} & \cdots & w_{2n} \\[5pt] \vdots & \vdots & & \vdots \\[5pt] w_{m1} & w_{m2} & \cdots & w_{mn} \end{bmatrix} \cdot \begin{bmatrix} x_1 \\[5pt] x_2 \\[5pt] \vdots \\[5pt] x_n \\[2pt] \end{bmatrix} + \begin{bmatrix} b_1 \\[5pt] b_2 \\[5pt] \vdots \\[5pt] b_m \\[2pt] \end{bmatrix} \right) \tag{8}\end{aligned}$$

  跟进一步地,可以使用张量表示输入、输出以及其中的参数,即

$$\begin{aligned} \bm{y}=\text{Softmax}(\bm{W}\bm{x}+\bm{b}) \tag{9}\end{aligned}$$

(9)中,$\displaystyle \bm{x}=[x_1,x_2,\cdots,x_n]^T,\bm{y}=[y_1,y_2,\cdots,y_m]^T,\bm{W}=\begin{bmatrix} w_{11} & w_{12} & \cdots & w_{1n} \\[5pt] w_{21} & w_{22} & \cdots & w_{2n} \\[5pt] \vdots & \vdots & & \vdots \\[5pt] w_{m1} & w_{m2} & \cdots & w_{mn} \end{bmatrix},\bm{b}=[b_1,b_2,\cdots,b_m]^T$ 。对向量 $\bm{x}$ 执行 $\bm{W}\bm{x}+\bm{b}$ 运算又被称为对 $\bm{x}$ 进行线性映射线性变换

多层感知器

  以上介绍的模型本质上都是线性模型,然而现实世界中很多真实的问题不都是线性可分的,即无法使用一条直线、平面或者超平面分割不同的类别,其中典型的例子是异或问题(Exclusive OR, XOR),即假设输入为 $x_1$ 和 $x_2$ ,如果它们相同,即当 $x_1=0,x_2=0$ 或为 $x_1=1,x_2=1$ 时,输出 $y=0$ ;如果它们不相同,即当 $x_1=0,x_2=1$ 或 $x_1=1,x_2=0$ 时,输出 $y=1$ ,如图3所示。此时,无法使用线性分类器恰当地将输入划分到正确的类别。

1 0 0 1 1 1 0 0

图3 异或问题示例

  多层感知器(Multi-layer Perceptron, MLP)是解决线性不可分问题的一种解决方案。多层感知器指的是堆叠多层线性分类器,并在中间层(也叫隐含层,Hidden layer)增加非线性激活函数。例如,可以设计如下的多层感知器:

$$\begin{aligned} \bm{z}=\bm{W}^{[1]}\bm{x}+\bm{b}^{[1]} \tag{10}\end{aligned}$$ $$\begin{aligned} \bm{h}=\text{ReLU}(\bm{z}) \tag{11}\end{aligned}$$ $$\begin{aligned} \bm{y}=\bm{W}^{[2]}\bm{h}+\bm{b}^{[2]} \tag{12}\end{aligned}$$

  式中,ReLU(Rectified Linear Unit)是一种非线性激活函数,其定义为当某一项输入小于0时,输出为0;否则输出相应的输入值,即 $\text{ReLU}(\bm{z})=\max(0,\bm{z})$ 。$\bm{W}^{[i]}$ 和分别表示第 $\bm{b}^{[i]}$ 层感知器的权重和偏置项。
  如果将相应的参数进行如下的设置: $\displaystyle\bm{W}^{[1]}=\begin{bmatrix}1 & 1 \\[5pt] 1 & 1\end{bmatrix},\bm{b}^{[1]}=[0,-1]^T,\bm{W}^{[2]}=[1,-2],\bm{b}^{[2]}=[0]$ 即可解决异或问题。该多层感知器的网络结构如图4所示。

1 1 1 1 +0 -1 -2 1 +0

图4 一种解决异或问题的多层感知器结构

那么,该网络是如何解决异或问题的呢?其主要通过两个关键的技术,即增加了一个含两个节点的隐含层( $\bm{h}$ )以及引入非线性激活函数(ReLU)。通过设置恰当的参数值,将在原始输入空间中线性不可分的问题映射到新的隐含层空间,使其在该空间内线性可分。如图5所示,原空间内 $\bm{x}=[0,0]$ 和 $\bm{x}=[1,1]$ 两个点,分别被映射到 $\bm{h}=[0,0]$ 和 $\bm{h}=[2,1]$ ;而 $\bm{x}=[0,1]$ 和 $\bm{x}=[1,0]$ 两个点,都被映射到了 $\bm{h}=[1,0]$ 。此时就可以使用一条直线将两类点分割,即成功转换为线性可分问题。

2 0 0 2 1 0 0 1 1

图5 多层感知器隐藏层空间示例

图6展示了更一般的多层感知器,其中引入了更多的隐含层(没有画出非线性激活函数),并将输出层设置为多类分类层(使用Softmax函数)。输入层和输出层的大小一般是固定的,与输入数据的维度以及所处理问题的类别相对应,而隐含层的大小、层数和激活函数的类型等需要根据经验以及实验结果设置,它们又被称为超参数(Hyper-parameter)。一般来讲,隐含层越大、层数越多,即模型的参数越多、容量越大,多层感知器的表达能力就越强,但是此时较难优化网络的参数。而如果隐含层太小、层数过少,则模型的表达能力会不足。为了在模型容量和学习难度中间寻找到一个平衡点,需要根据不同的问题和数据,通过调参过程寻找合适的超参数组合。

输入层 隐含层1 隐含层2 输出层

图6 多层感知器示意图

模型实现

神经网络层与激活函数

  上面介绍了从简单的线性回归到复杂的多层感知器等多种神经网络模型,接下来介绍如何使用PyTorch实现这些模型。实际上,使用PyTorch提供的基本张量存储及运算功能,就可以实现这些模型,但是这种实现方式不但难度高,而且容易出错。因此,PyTorch将常用的神经网络模型封装到了torch.nn包内,从而可以方便灵活地加以调用。如通过以下代码,就可以创建一个线性映射模型(也叫线性层)。

1
2
from torch import nn
linera = nn.Linear(in_features, out_features)

  代码中的in_features是输入特征的数目,out_features是输出特征的数目。可以使用该线性映射层实现线性回归模型,只要将输出特征的数目设置为1即可。当实际调用线性层时,可以一次性输入多个样例,一般叫作一个批次(Batch),并同时获得每个样例的输出。所以,如果输入张量的形状是(batch, in_features),则输出张量的形状是(batch, out_features)。采用批次操作的好处是可以充分利用GPU等硬件的多核并行计算能力,大幅提高计算的效率。具体示例如下。

1
2
3
4
5
6
import torch
from torch import nn
linear = nn.Linear(32, 2) # 输入32维,输出2维
inputs = torch.rand(3, 32) # 创建一个形状为(3,32)的随机张量,3为批次大小
outputs = linear(inputs)
print(outputs)
1
2
3
tensor([[-0.1184,  0.2223],
        [ 0.1142,  0.1268],
        [ 0.1400,  0.1259]], grad_fn=<AddmmBackward0>)

  Sigmoid、Softmax等各种激活函数包含在torch.nn.functional中,实现对输入按元素进行非线性运算,调用方式如下。

1
2
3
from torch.nn import functional as F
activation = F.sigmoid(outputs)
print(activation)
1
2
3
tensor([[0.4704, 0.5553],
        [0.5285, 0.5316],
        [0.5350, 0.5314]], grad_fn=<SigmoidBackward0>)
1
2
activation = F.softmax(outputs, dim=1) # 沿着第二维进行Softmax计算
print(activation)
1
2
3
tensor([[0.4156, 0.5844],
        [0.4969, 0.5031],
        [0.5035, 0.4965]], grad_fn=<SoftmaxBackward0>)
1
2
activation = F.relu(outputs)
print(activation)
1
2
3
tensor([[0.0000, 0.2223],
        [0.1142, 0.1268],
        [0.1400, 0.1259]], grad_fn=<ReluBackward0>)

  除了 Sigmoid、Softmax和ReLU函数,PyTorch还提供了tanh等多种激活函数。

自定义神经网络模型

  通过对上文介绍的神经网络层以及激活函数进行组合,就可以搭建更复杂的 神经网络模型。在PyTorch中构建一个自定义神经网络模型非常简单,就是从torch.nn中的Module类派生一个子类,并实现构造函数和forward函数。其中,构造函数定义了模型所需的成员对象,如构成该模型的各层,并对其中的参数进行初始化等。而forward函数用来实现该模块的前向过程,即对输入进行逐层的处理,从而得到最终的输出结果。下面以多层感知器模型为例,介绍如何自定义一个神经网络模型,其代码如下。

 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
import torch
from torch import nn
from torch.nn import functional as F

class MLP(nn.Module):
    def __init__(self, input_dim, hidden_dim, num_class):
        super(MLP, self).__init__()
        # 线性变换:输入层->隐含层
        self.linear1 = nn.Linear(input_dim, hidden_dim)
        # 使用ReLU激活函数
        self.activate = F.relu
        # 线性变换:隐含层->输出层
        self.linear2 = nn.Linear(hidden_dim, num_class)

    def forward(self, inputs):
        hidden = self.linear1(inputs)
        activation = self.activate(hidden)
        outputs = self.linear2(activation)
        probs = F.softmax(outputs, dim=1) # 获得每个输入属于某一类别的概率
        return probs

mlp = MLP(input_dim=4, hidden_dim=5, num_class=2)
inputs = torch.rand(3, 4) # 输入形状为(3, 4)的张量,其中3表示有3个输入,
                          # 4表示每个输入的维度
probs = mlp(inputs) # 自动调用forward函数
print(probs) # 输出3个输入对应输出的概率

  最终输出如下。

1
2
3
tensor([[0.4566, 0.5434],
        [0.4257, 0.5743],
        [0.4413, 0.5587]], grad_fn=<SoftmaxBackward0>)

卷积神经网络

模型结构

  在多层感知器中,每层输入的各个元素都需要乘以一个独立的参数(权重),这一层又叫作全连接层(Fully Connected Layer)或稠密层(Dense Layer)。然而,对于某些类型的任务,这样做并不合适,如在图像识别任务中,如果对每个像素赋予独立的参数,一旦待识别物体的位置出现轻微移动,识别结果可能会发生较大的变化。在自然语言处理任务中也存在类似的问题,如对于情感分类任务,句子的情感极性往往由个别词或短语决定,而这些决定性的词或短语在句子中的位置并不固定,使用全连接层很难捕捉这种关键的局部信息。
  为了解决以上问题,一个非常直接的想法是使用一个小的稠密层提取这些局部特征,如图像中固定大小的像素区域、文本中词的N-gram等。为了解决关键信息位置不固定的问题,可以依次扫描输入的每个区域,该操作又被称为卷积(Convolution)操作。其中,每个小的、用于提取局部特征的稠密层又被称为卷积核(Kernel)或者滤波器(Filter)。
  卷积操作输出的结果还可以进行进一步聚合,这一过程被称为池化(Pooling)操作。常用的池化操作有最大池化、平均池化和加和池化等。以最大池化为例,其含义是仅保留最有意义的局部特征。如在情感分类任务中,保留的是句子中对于分类最关键的N-gram信息。池化操作的好处是可以解决样本的输入大小不一致的问题,如对于情感分类,有的句子比较长,有的句子比较短,因此不同句子包含的N-gram数目并不相同,导致抽取的局部特征个数也不相同,然而经过池化操作后,可以保证最终输出相同个数的特征。
  然而,如果仅使用一个卷积核,则只能提取单一类型的局部特征。而在实际问题中,往往需要提取很多种局部特征,如在情感分类中不同的情感词或者词组等。因此,在进行卷积操作时,可以使用多个卷积核提取不同种类的局部特征。卷积核的构造方式大致有两种,一种是使用不同组的参数,并且使用不同的初始化参数,获得不同的卷积核;另一种是提取不同尺度的局部特征,如在情感分类中提取不同大小的N-gram。
  既然多个卷积核输出多个特征,那么这些特征对于最终分类结果的判断,到底哪些比较重要,哪些不重要呢?其实只要再经过一个全连接的分类层就可以做出最终的决策。
  最后,还可以将多个卷积层加池化层堆叠起来,形成更深层的网络,这些网络统称为卷积神经网络(Convolutional Neural Network, CNN)。
  图4-7给出了一个卷积神经网络的示意图,用于对输入的句子分类。其中,输人为“我喜欢自然语言处理。”6个词。根据词嵌入表示介绍的方法,首先将每个词映射为一个词向量,此处假设每个词向量的维度为5(图中输入层的每列表示一个词向量,每个方框表示向量的一个元素)。然后,分别使用4个卷积核对输入进行局部特征提取,其中前两个卷积核的宽度(N-gram中N的大小)为4(三角形和四边形),后两个卷积核的宽度为3(五边形和六边形),卷积操作每次滑动1个词,则每个卷积核的输出长度为 $L-N+1$ ,其中 $L$ 为单词的个数,$N$ 为卷积核的宽度,简单计算可以得到前两组卷积核的输出长度为3,后两组卷积核的输出长度为4。接下来,经过全序列的最大池化操作,将不同卷积核的输出分别聚合为1个输出,并拼接为一个特征向量,最终经过全连接层分类。

输入层 卷积层 全连接层 池化层

图7 卷积神经网络示意图

  上面这种沿单一方向滑动的卷积操作又叫作一维卷积,适用于自然语言等序列数据。而对于图像等数据,由于卷积核不但需要横向滑动,还需要纵向滑动,此类卷积叫作二维卷积。类似的还有三维卷积。
  与上一节介绍的多层感知器模型类似,卷积神经网络中的信息也是从输入层经过隐含层,然后传递给输出层,按照一个方向流动,因此它们都被称为前馈神经网络(Feed-Forward Network, FFN)。

模型实现

  PyTorch的torch.nn包中使用Conv1dConv2dConv3d类实现卷积层,它们分别表示一维卷积、二维卷积和三维卷积。此处仅介绍自然语言处理中常用的一维卷积(Convld),其构造函数至少需要提供三个参数:in_channels为输入通道的个数,在输入层对应词向量的维度;out_channels为输出通道的个数,对应卷积核的个数;kernel_size为每个卷积核的宽度。当调用该Conv1d对象时,输入数据形状为(batch, in_channels, seq_len),输出数据形状为(batch, out_channels, seq_len),其中在输入数据和输出数据中,seq_len分别表示输入的序列长度和输出的序列长度。与图7相对应的网络构建代码如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import torch
from torch.nn import Conv1d
conv1 = Conv1d(5, 2, 4)
## 定义一个一维卷积,输入通道大小为5,输出通道大小为2,卷积核宽度为4
conv2 = Conv1d(5, 2, 3)
## 定义一个一维卷积,输入通道大小为5,输出通道大小为2,卷积核宽度为3
inputs = torch.rand(2, 5, 6)
## 输入数据批次大小为2,即有两个序列,每个序列的长度为6,每个输入的维度为5
outputs1 = conv1(inputs)
outputs2 = conv2(inputs)
print(outputs1) # 第1个输出为两个序列,每个序列长度为3,大小为2
1
2
3
4
5
tensor([[[ 0.1447,  0.1070, -0.1014],
         [-0.1717, -0.2389,  0.0744]],

        [[ 0.3299,  0.7082,  0.2230],
         [-0.4287, -0.3545, -0.4889]]], grad_fn=<ConvolutionBackward0>)
1
print(outputs2) # 第2个输出也为两个序列,每个序列长度为4,大小为2
1
2
3
4
5
6
tensor([[[ 0.0893,  0.0070, -0.0548,  0.1080],
         [ 0.1346,  0.0520,  0.1142,  0.0594]],

        [[-0.0101,  0.1701,  0.1135, -0.0718],
         [-0.1920,  0.0350, -0.1963,  0.0099]]],
       grad_fn=<ConvolutionBackward0>)
1
2
3
4
5
6
7
from torch.nn import MaxPool1d
pool1 = MaxPool1d(3) # 第1个池化层核的大小为3,即卷积层的输出序列长度
pool2 = MaxPool1d(4) # 第2个池化层核的大小为4
outputs_pool1 = pool1(outputs1)
    # 执行一维最大池化操作,即取每行输入的最大值
outputs_pool2 = pool2(outputs2)
print(outputs_pool1)
1
2
3
4
5
tensor([[[ 0.1447],
         [ 0.0744]],

        [[ 0.7082],
         [-0.3545]]], grad_fn=<SqueezeBackward1>)
1
print(outputs_pool2)
1
2
3
4
5
tensor([[[0.1080],
         [0.1346]],

        [[0.1701],
         [0.0350]]], grad_fn=<SqueezeBackward1>)

  除了使用池化层对象实现池化,PyTorch还在torch.nn.functional中实现了池化函数,如max_pool1d等,即无须定义一个池化层对象,就可以直接调用池化功能。这两种实现方式基本一致,一个显著的区别在于使用池化函数实现无须事先指定池化层核的大小,只要在调用时提供即可。当处理不定长度的序列时,此种实现方式更加适合,具体示例如下。

1
2
3
4
import torch.nn.functional as F
outputs_pool1 = F.max_pool1d(outputs1, kernel_size=outputs1.shape[2])
    # outputs1的最后一维恰好为其序列的长度
print(outputs_pool1)
1
2
3
4
5
tensor([[[ 0.1447],
         [ 0.0744]],

        [[ 0.7082],
         [-0.3545]]], grad_fn=<SqueezeBackward1>)
1
2
outputs_pool2 = F.max_pool1d(outputs2, kernel_size=outputs2.shape[2])
print(outputs_pool2)
1
2
3
4
5
tensor([[[0.1080],
         [0.1346]],

        [[0.1701],
         [0.0350]]], grad_fn=<SqueezeBackward1>)

  由于outputs_pool1outputs_pool2是两个独立的张量,为了进行下一步操作,还需要调用torch.cat函数将它们拼接起来。在此之前,还需要调用squeeze函数将最后一个为1的维度删除,即将2行1列的矩阵变为1个向量。

1
2
outputs_pool_squeeze1 = outputs_pool1.squeeze(dim=2)
print(outputs_pool_squeeze1)
1
2
tensor([[0.1447,  0.0744],
        [0.7082, -0.3545]], grad_fn=<SqueezeBackward1>)
1
2
outputs_pool_squeeze2 = outputs_pool2.squeeze(dim=2)
print(outputs_pool_squeeze2)
1
2
tensor([[0.1080, 0.1346],
        [0.1701, 0.0350]], grad_fn=<SqueezeBackward1>)
1
2
outputs_pool = torch.cat([outputs_pool_squeeze1, outputs_pool_squeeze2], dim=1)
print(outputs_pool)
1
2
tensor([[0.1447,  0.0744,  0.1080,  0.1346],
        [0.7082, -0.3545,  0.1701,  0.0350]], grad_fn=<CatBackward0>)

  池化后,再连接一个全连接层,实现分类功能。

1
2
3
4
from torch.nn import Linear
linear = Linear(4, 2) #全连接层,输入维度为4,即池化层输出的维度
outputs_linear = linear(outputs_pool)
print(outputs_linear)
1
2
tensor([[0.1608, -0.3389],
        [0.1105, -0.4042]], grad_fn=<AddmmBackward0>)

循环神经网络

  以上介绍的多层感知器与卷积神经网络均为前馈神经网络,信息按照一个方 向流动。本节介绍另一类在自然语言处理中常用的神经网络——循环神经网络(Recurrent Neural Network, RNN),即信息循环流动。在此主要介绍两种循环神经网络——原始的循环神经网络和目前常用的长短时记忆网络(Long Short-Term Memory, LSTM)。

模型结构

  循环神经网络指的是网络的隐含层输出又作为其自身的输入,其结构如图4- 8所示,图中 $\bm{W}^{xh}、\bm{b}^{xh},\bm{W}^{hh},\bm{b}^{hh}$ 和 $\bm{W}^{hy}、\bm{b}^{hy}$ 分别是输入层到隐含层、隐含层到隐含层和隐含层到输出层的参数。当实际使用循环神经网络时,需要设定一个有限的循环次数,将其展开后相当于堆叠多个共享隐含层参数的前馈神经网络。

图8 循环神经网络示意图

  当使用循环神经网络处理一个序列输入时,需要将循环神经网络按输入时刻展开,然后将序列中的每个输入依次对应到网络不同时刻的输入上,并将当前时刻网络隐含层的输出也作为下一时刻的输入图9展示了循环神经网络处理序列输入的示意图,其中序列的长度为 $n$ 。

图9 循环神经网络处理序列输入示意图

  按时刻展开的循环神经网络可以使用如下公式描述:

$$\begin{aligned} \bm{h}_t=\tanh(\bm{W}^{xh}\bm{x}_t+\bm{b}^{xh}+\bm{W}^{hh}\bm{h}_{t-1}+\bm{b}^{hh}) \tag{13}\end{aligned}$$ $$\begin{aligned} \bm{y}=\text{Softmax}(\bm{W}^{hy}\bm{h}_n+\bm{b}^{hy}) \tag{14}\end{aligned}$$

  式中,$\displaystyle\tanh(z)=\frac{e^z-e^{-z}}{e^z+e^{-z}}$ 是激活函数,其形状与Sigmoid函数类似,只不过值域在 $-1$ 到 $+1$ 之间; $t$ 是输入序列的当前时刻,其隐含层 $\bm{h}_t$ 不但与当前的输入 $\bm{x}_t$ 有关,而且与上一时刻的隐含层 $\bm{h}_{t-1}$ 有关,这实际上是一种递归形式的定义。每个时刻的输入经过层层递归,对最终的输出产生一定的影响,每个时刻的隐含层 $\bm{h}_t$ 承载了 $1\backsim t$ 时刻的全部输入信息,因此循环神经网络中的隐含层也被称作记忆(Memory)单元。
  以上循环神经网络在最后时刻产生输出结果,此时适用于处理文本分类等问题。除此之外,如图10所示,还可以在每个时刻产生一个输出结果,这种结构适用于处理自然语言处理中常见的序列标注(Sequence Labeling)问题,如词性标注、命名实体识别,甚至分词等。

图10 循环神经网络用于处理序列标注问题的示意图

长短时记忆神经网络

  在原始的循环神经网络中,信息是通过多个隐含层逐层传递到输出层的。直观上,这会导致信息的损失;更本质地,这会使得网络参数难以优化。长短时记忆网络(LSTM)可以较好地解决该问题。
  长短时记忆网络首先将(13)的隐含层更新方式修改为:

$$\begin{aligned} \bm{u}_t=\tanh(\bm{W}^{xh}\bm{x}_t+\bm{b}^{xh}+\bm{W}^{hh}\bm{h}_{t-1}+\bm{b}^{hh}) \tag{15}\end{aligned}$$ $$\begin{aligned} \bm{h}_t=\bm{h}_{t-1}+\bm{u}_t \tag{16}\end{aligned}$$

  这样做的一个直观好处是直接将 $\bm{h}_k$ 与 $\bm{h}_t(k \lt t)$ 进行了连接,跨过了中间的 $t-k$ 层,从而减小了网络的层数,使得网络更容易被优化。其证明方式也比较简单,即:$\bm{h}_t=\bm{h}_{t-1}+\bm{u}_t=\bm{h}_{t-2}+\bm{u}_{t-1}+\bm{u}_t=\cdots=\bm{h}_k+\bm{u}_{k+1}+\bm{u}_{k+2}+\cdots+\bm{u}_{t-1}+\bm{u}_t$ 。
  不过(16)简单地将旧状态 $\bm{h}_{t-1}$ 和新状态 $\bm{u}_t$ 进行相加,这种更新方式过于粗糙,并没有考虑两种状态对自贡献的大小。为解决这一问题,可以通过前一时刻的隐含层和当前输入计算一个系数,并以此系数对两个状态加权求和,具体公式为:

$$\begin{aligned} \bm{f}_t=\sigma(\bm{W}^{f,xh}\bm{x}_t+\bm{b}^{f,xh}+\bm{W}^{f,hh}\bm{h}_{t-1}+\bm{b}^{f,hh}) \tag{17}\end{aligned}$$ $$\begin{aligned} \bm{h}_t=\bm{f}_t \odot \bm{h}_{t-1} + (1 - \bm{f}_t) \odot \bm{u}_t \tag{18}\end{aligned}$$

  式中,$\sigma$ 表示Sigmoid函数,其输出恰好介于 $0$ 到 $1$ 之间,可作为加权求和的系数; $\odot$ 表示Hardamard乘积,即按张量对应元素进行相乘; $\bm{f}_t$ 被称作遗忘门(Forget gate),因为如果其较小时,旧状态 $\bm{h}_{t-1}$ 对当前状态的贡献也较小,也就是将过去的信息都遗忘了。
  然而,这种加权的方式有一个问题,就是旧状态 $\bm{h}_{t-1}$ 和新状态 $\bm{u}_t$ 的贡献是互斥的,也就是如果 $\bm{f}_t$ 较小,则 $1-\bm{f}_t$ 就会较大,反之亦然。但是,这两种状态对当前状态的贡献有可能都比较大或者比较小,因此需要使用独立的系数分别控制。因此,引入新的系数以及新的加权方式,即:

$$\begin{aligned} \bm{i}_t=\sigma(\bm{W}^{i,xh}\bm{x}_t+\bm{b}^{i,xh}+\bm{W}^{i,hh}\bm{h}_{t-1}+\bm{b}^{i,hh}) \tag{19}\end{aligned}$$ $$\begin{aligned} \bm{h}_t=\bm{f}_t \odot \bm{h}_{t-1} + \bm{i}_t \odot \bm{u}_t \tag{20}\end{aligned}$$

  式中,新的系数 $\bm{i}_t$ 用于控制输入状态 $\bm{u}_t$ 对当前状态的贡献,因此又被称作输入门(Input gate)。
  类似地,还可以对输出增加门控机制,即输出门(Output gate):

$$\begin{aligned} \bm{o}_t=\sigma(\bm{W}^{o,xh}\bm{x}_t+\bm{b}^{o,xh}+\bm{W}^{o,hh}\bm{h}_{t-1}+\bm{b}^{o,hh}) \tag{21}\end{aligned}$$ $$\begin{aligned} \bm{c}_t=\bm{f}_t \odot \bm{c}_{t-1} + \bm{i}_t \odot \bm{u}_t \tag{22}\end{aligned}$$ $$\begin{aligned} \bm{h}_t=\bm{o}_t \odot \tanh(\bm{c}_t) \tag{23}\end{aligned}$$

  式中,$\bm{c}_t$ 又被称为记忆细胞(Memory cell),即存储(记忆)了截至当前时刻的重要信息。与原始的循环神经网络一样,既可以使用 $\bm{h}_n$ 预测最终的输出结果,又可以使用 $\bm{h}_n$ 预测每个时刻的输出结果。
  无论是传统的循环神经网络还是LSTM,信息流动都是单向的,在一些应用中这并不合适,如对于词性标注任务,一个词的词性不但与其前面的单词及其自身有关,还与其后面的单词有关,但是传统的循环神经网络并不能利用某一时刻后面的信息。为了解决该问题,可以使用双向循环神经网络或双向LSTM,简称Bi-RNN或Bi-LSTM,其中Bi代表Bidirectional。其思想是将同一个输入序列分别接入向前和向后两个循环神经网络中,然后再将两个循环神经网络的隐含层拼接在一起,共同接入输出层进行预测。双向循环神经网络结构如图11所示。

输入层 隐含层 输出层

图11 双向循环神经网络结构

  另一类对循环神经网络的改进方式是将多个网络堆叠起来,形成堆叠循环神 经网络(StackedRNN),如图12所示。此外,还可以在堆叠循环神经网络的每一层加入一个反向循环神经网络,构成更复杂的堆叠双向循环神经网络。

输入层 隐含层 输出层 隐含层

图12 堆叠循环神经网络示意图

模型实现

  循环神经网络在PyTorch的torch.nn包中也有相应的实现,即RNN类。其构造函数至少需要提供两个参数:input_size表示每个时刻输入的大小,hidden_size表示隐含层的大小。另外,根据习惯,通常将batch_first设为True(其默认值为False),即输入和输出的第1维代表批次的大小(即一次同时处理序列的数目)。当调用该RNN对象时,输入数据形状为(batch,seq_len,input_size),输出数据有两个,分别为隐含层序列和最后一个时刻的隐含层,它们的形状分别为(batch,seq_len,hidden_size)(1,batch,hidden_size)。具体的示例代码如下。

1
2
3
4
5
6
7
8
9
import torch
from torch.nn import RNN
rnn = RNN(input_size=4, hidden_size=5, batch_first=True)
## 定义一个RNN,每个时刻输入大小为4,隐含层大小为5
inputs = torch.randn(2, 3, 4)
## 输入数据批次大小为2,即有两个序列,每个序列的长度为3,每个时刻输入大小为4
outputs, hn = rnn(inputs)
## outputs为输出序列的隐含层,hn为最后一个时刻的隐含层
print(outputs) # 输出两个序列,每个序列长度为3,大小为5
1
2
3
4
5
6
7
8
tensor([[[ 0.6479, -0.2796,  0.1536,  0.0339,  0.0398],
         [ 0.8967, -0.4815, -0.1185, -0.4109, -0.1857],
         [ 0.7613,  0.0605,  0.5813, -0.1605,  0.3751]],

        [[ 0.5777, -0.3939, -0.1024,  0.1918,  0.0447],
         [-0.8094,  0.1017,  0.4030,  0.5598, -0.0181],
         [ 0.2177, -0.7130, -0.0997,  0.4113, -0.7415]]],
       grad_fn=<TransposeBackward1>)
1
print(hn) # 最后一个时刻的隐含层,值与outputs中最后一个时刻相同
1
2
3
tensor([[[ 0.7613,  0.0605,  0.5813, -0.1605,  0.3751],
         [ 0.2177, -0.7130, -0.0997,  0.4113, -0.7415]]],
       grad_fn=<StackBackward0>)
1
2
3
print(outputs.shape, hn.shape) # 输出隐含层序列和最后一个时刻隐含层的形状,
## 分别为(2, 3, 5),即批次大小、序列长度和隐含层大小,以及(1,2,5),
##  即1、批次大小和隐含层大小
1
torch.Size([2, 3, 5]) torch.Size([1, 2, 5])

  当初始化RNN时,还可通过设置其他参数修改网络的结构,如bidirectional=True(双向RNN,默认为False)、num_layers(堆叠的循环神经网络层数,默认为1)等。
  torch.nn包中还提供了LSTM类,其初始化的参数以及输入数据与RNN相同,不同之处在于其输出数据除了最后一个时刻的隐含层hn,还输出了最后一个时刻的记忆细胞cn,代码示例如下。

1
2
3
4
5
6
7
import torch
from torch.nn import LSTM
lstm = LSTM(input_size=4, hidden_size=5, batch_first=True)
inputs = torch.rand(2, 3, 4)
outputs, (hn, cn) = lstm(inputs) # outputs为输出序列的隐含层,hn为最后一个
## 时刻的隐含层,cn为最后一个时刻的记忆细胞
print(outputs) #输出两个序列,每个序列长度为3,大小为5
1
2
3
4
5
6
7
8
tensor([[[-0.0969, -0.0961,  0.1229, -0.0817,  0.3048],
         [-0.2135, -0.1953,  0.2519, -0.1884,  0.3157],
         [-0.2923, -0.2426,  0.3674, -0.2470,  0.3748]],

        [[-0.1657, -0.1259,  0.2150, -0.0962,  0.3359],
         [-0.1281, -0.0537,  0.3348, -0.1132,  0.4143],
         [-0.2543, -0.1409,  0.3875, -0.1847,  0.3766]]],
       grad_fn=<TransposeBackward0>)
1
print(hn) #最后一个时刻的隐含层,值与outputs中最后一个时刻相同
1
2
3
tensor([[[-0.2923, -0.2426,  0.3674, -0.2470,  0.3748],
         [-0.2543, -0.1409,  0.3875, -0.1847,  0.3766]]],
       grad_fn=<StackBackward0>)
1
print(cn)  #最后一个时刻的记忆细胞
1
2
3
tensor([[[-0.5128, -0.5428,  0.6770, -0.6973,  0.6228],
         [-0.4068, -0.3376,  0.7591, -0.4155,  0.6020]]],
       grad_fn=<StackBackward0>)
1
2
print(outputs.shape, hn.shape, cn.shape)
## 输出隐含层序列和最后一个时刻隐含层以及记忆细胞的形状
1
torch.Size([2, 3, 5]) torch.Size([1, 2, 5]) torch.Size([1, 2, 5])

基于循环神经网络的序列到序列模型

  除了能够处理分类问题和序列标注问题,循环神经网络另一个强大的功能是能够处理序列到序列的理解和生成问题,相应的模型被称为序列到序列模型,也被称为编码器-解码器模型。序列到序列模型指的是首先对一个序列(如一个自然语言句子)编码,然后再对其解码,即生成一个新的序列。很多自然语言处理问题都可以看作序列到序列问题,如机器翻译,即首先对源语言的句子编码,然后生成相应的目标语言翻译。
图13展示了一个基于序列到序列模型进行机器翻译的示例。首先编码器使用循环神经网络对源语言句子编码,然后以最后一个单词对应的隐含层作为初始,再调用解码器(另一个循环神经网络)逐词生成目标语言的句子。图中的BOS表示句子起始标记。

I love you BOS

图13 序列到序列模型

  基于循环神经网络的序列到序列模型有一个基本假设,就是原始序列的最后一个隐含状态(一个向量)包含了该序列的全部信息。然而,该假设显然不合理,尤其是当序列比较长时,要做到这一点就更困难。为了解决该问题,注意力模型应运而生。

注意力模型

注意力机制

  为了解决序列到序列模型记忆长序列能力不足的问题,一个非常直观的想法 是,当要生成一个目标语言单词时,不光考虑前一个时刻的状态和已经生成的单词,还考虑当前要生成的单词和源语言句子中的哪些单词更相关,即更关注源语言的哪些词,这种做法就叫作注意力机制(Attention Mechanism)。图14给出了一个示例,假设模型已经生成单词“我”后,要生成下一个单词,显然和源语言句子中的“love”关系最大,因此将源语言句子中"love"对应的状态乘以一个较大的权重,如0.6,而其余词的权重则较小,最终将源语言句子中每个单词对应的状态加权求和,并用作新状态更新的一个额外输入。

I l o v e y o u B O S 0.3 0.6 0.1

图14 基于注意力机制的序列到序列模型示例

  注意力权重的计算公式为:

$$\begin{aligned} \^{\alpha}_s=\text{attn}(\bm{h}_s,\bm{h}_{t-1}) \tag{24}\end{aligned}$$ $$\begin{aligned} \alpha_s=\text{Softmax}(\^{\bm{\alpha}})_s \tag{25}\end{aligned}$$

  式中,$\bm{h}_s$ 表示源序列中 $s$ 时刻的状态; $\bm{h}_{t-1}$ 表示目标序列中前一个时刻的状态; $\text{attn}$ 是注意力计算公式,即通过两个输入状态的向量,计算一个源序列 $s$ 时刻的注意力分数 $\^{\alpha}_s$ ; $\^{\bm{\alpha}}=[\^{\alpha}_1,\^{\alpha}_2,\cdots,\^{\alpha}_L]$ ,其中 $L$ 为源序列的长度;最后对整个源序列每个时刻的注意力分数使用 $\text{Softmax}$ 函数进行归一化,获得最终的注意力权重 $\alpha_s$ 。
  注意力 $\text{attn}$ 的计算方式有多种,如:

$$\begin{aligned} \text{attn}(\bm{q},\bm{k})= \begin{cases} \bm{w}^T\tanh(\bm{W}[\bm{q};\bm{k}]) & \text{多层感知器}\\[5pt] \bm{q}^T \bm{W} \bm{k} & \text{双线性} \\[5pt] \bm{q}^T\bm{k} & \text{点积} \\[5pt] \cfrac{\bm{q}^T\bm{k}}{\sqrt{d}} & \text{避免因向量维度 }d\text{ 过大导致点积结果过大} \end{cases} \tag{26}\end{aligned}$$

  通过引入注意力机制,使得基于循环神经网络的序列到序列模型的准确率有了大幅度的提高。

自注意力模型

  受注意力机制的启发,当要表示序列中某一时刻的状态时,可以通过该状态与其他时刻状态之间的相关性(注意力)计算,即所谓的“观其伴、知其义",这又被称作自注意力机制(Self-attention)。
  具体地,假设输入为 $n$ 个向量组成的序列 $\bm{x}_1,\bm{x}_2,\cdots,\bm{x}_n$ ,输出为每个向量对应的新的向量表示 $\bm{y}_1,\bm{y}_2,\cdots,\bm{y}_n$ ,其中所有向量的大小均为 $d$ 。那么,$\bm{y}_i$ 的计算公式为:

$$\begin{aligned} \bm{y}_i=\sum_{j=1}^{n}\alpha_{ij}\bm{x}_j \tag{27}\end{aligned}$$

  式中,$j$ 是整个序列的索引值; $\alpha_{ij}$ 是 $\bm{x}_i$ 与 $\bm{x}_j$ 之间的注意力(权重),其通过(26)中的 $\text{attn}$ 函数计算,然后再经过Softmax函数进行归一化后获得。直观上的含义是如果 $\bm{x}_i$ 与 $\bm{x}_j$ 越相关,则它们计算的注意力值就越大,那么叫对叫对应的新的表示 $\bm{y}_i$ 的贡献就越大。
  通过自注意力机制,可以直接计算两个距离较远的时刻之间的关系。而在循环神经网络中,由于信息是沿着时刻逐层传递的,因此当两个相关性较大的时刻距离较远时,会产生较大的信息损失。虽然引入了门控机制模型,如LSTM等,可以部分解决这种长距离依赖问题,但是治标不治本。因此,基于自注意力机制的自注意力模型已经逐步取代循环神经网络,成为自然语言处理的标准模型。

Transformer

  然而,要想真正取代循环神经网络,自注意力模型还需要解决如下问题:
  ◦在计算自注意力时,没有考虑输入的位置信息,因此无法对序列进行建模;
  ◦输入向量 $\bm{x}_i$ 同时承担了三种角色,即计算注意力权重时的两个向量以及被加权的向量,导致其不容易学习;
  ◦只考虑了两个输入序列单元之间的关系,无法建模多个输入序列单元之间更复杂的关系;
  ◦自注意力计算结果互斥,无法同时关注多个输入。
  下面分别就这些问题给出相应的解决方案,融合了以下方案的自注意力模型拥有一个非常炫酷的名字——Transformer。这个单词并不容易翻译,从本意上讲,其是将一个向量序列变换成另一个向量序列,所以可以翻译成“变换器”或“转换器”。其还有另一个含义是“变压器”,也就是对电压进行变换,所以翻译成变压器也比较形象。当然,还有一个更有趣的翻译是"变形金刚",这一翻译不但体现了其能变换的特性,还寓意着该模型如同变形金刚一样强大。目前,Transformer还没有一个翻译的共识,绝大部分人更愿意使用其英文名。

融入位置信息

  位置信息对于序列的表示至关重要,原始的自注意力模型没有考虑输入向量的位置信息,导致其与词袋模型类似,两个句子只要包含的词相同,即使顺序不同,它们的表示也完全相同。为了解决这一问题,需要为序列中每个输入的向量引入不同的位置信息以示区分。有两种引入位置信息的方式——位置嵌入(Position Embeddings)和位置编码(Position Encodings)。其中,位置嵌入与词嵌入类似,即为序列中每个绝对位置赋予一个连续、低维、稠密的向量表示。而位置编码则是使用函数 $f:\N \to \R^d$ 直接将一个整数(位置索引值)映射到一个d维向量上。映射公式为:

$$\begin{aligned} \text{PosEnc}(p,i)= \begin{cases} \displaystyle\sin\left(\frac{p}{10000^{\frac{i}{d}}}\right), &\text{如果 }i\text{ 为偶数} \\[15pt] \displaystyle\cos\left(\frac{p}{10000^{\frac{i-1}{d}}}\right), &\text{如果 }i\text{ 为奇数} \end{cases} \tag{28}\end{aligned}$$

  式中,$p$ 为序列中的位置索引值; $0 \le i < d$ 是位置编码向量中的索引值。无论是使用位置嵌入还是位置编码,在获得一个位置对应的向量后,再与该位置对应的词向量进行相加,即可表示该位置的输入向量。这样即使词向量相同,但是如果它们所处的位置不同,其最终的向量表示也不相同,从而解决了原始自注意力模型无法对序列进行建模的问题。

输入向量角色信息

  原始的自注意力模型在计算注意力时直接使用两个输入向量,然后使用得到的注意力对同一个输入向量加权,这样导致一个输入向量同时承担了三种角色: 查询(Query)、键(Key)和值(Value)。更好的做法是,对不同的角色使用不同的向量。为了做到这一点,可以使用不同的参数矩阵对原始的输入向量做线性变换,从而让不同的变换结果承担不同的角色。具体地,分别使用三个不同的参数矩阵 $\bm{W}^q$ 、 $\bm{W}^k$ 和 $W^v$ 将输入向量 $\bm{x}_i$ 映射为三个新的向量 $\bm{q}_i=\bm{W}^q\bm{x}_i$ 、 $\bm{k}_i=\bm{W}^k\bm{x}_i$ 和 $\bm{v}_i=\bm{W}^v\bm{x}_i$ ,分别表示查询、键和值对应的向量。新的输出向量计算公式为:

$$\begin{aligned} \bm{y}_i=\sum_{j=1}^{n}\alpha_{ij}\bm{v}_j \tag{29}\end{aligned}$$ $$\begin{aligned} \alpha_{ij}=\text{Softmax}(\^{\bm{\alpha}}_i)_j \tag{30}\end{aligned}$$ $$\begin{aligned} \^{\alpha}_{ij}=\text{attn}(\bm{q}_i,\bm{k}_j) \tag{31}\end{aligned}$$

式中,$\^{\bm{\alpha}}_i=[\^{\alpha}_{i1},\^{\alpha}_{i2},\cdots,\^{\alpha}_{iL}]$ ,其中 $L$ 为序列的长度。

多层自注意力

  原始的自注意力模型仅考虑了序列中任意两个输入序列单元之间的关系,而在实际应用中,往往需要同时考虑更多输入序列单元之间的关系,即更高阶的关系。如果直接建模高阶关系,会导致模型的复杂度过高。一方面,类似于图模型中的消息传播机制(Message Propogation),这种高阶关系可以通过堆叠多层自注意力模型实现。另一方面,类似于多层感知器,如果直接堆叠多层注意力模型,由于每层的变换都是线性的(注意力计算一般使用线性函数),最终模型依然是线性的。因此,为了增强模型的表示能力,往往在每层自注意力计算之后,增加一个非线性的多层感知器(MLP)模型。另外,如果将自注意力模型看作特征抽取器,那么多层感知器就是最终的分类器。同时,为了使模型更容易学习,还可以使用层归一化(Layer Normalization)、残差连接(Residual Connections)等深度学习的训练技巧。自注意力层、非线性层以及以上的这些训练技巧,构成了一个更大的Transformer层,也叫作Transformer块(Block),如图15所示。

MLP MLP MLP MLP + + 输入 Transformer Block 输出

图15 Transformer块

自注意力计算结果互斥

  由于自注意力结果需要经过归一化,导致即使一个输入和多个其他的输入相关,也无法同时为这些输入赋予较大的注意力值,即自注意力结果之间是互斥的,无法同时关注多个输入。因此,如果能使用多组自注意力模型产生多组不同的注意力结果,则不同组注意力模型可能关注到不同的输入上,从而增强模型的表达能力。那么如何产生多组自注意力模型呢?方法非常简单,只需要设置多组映射矩阵即可,然后将产生的多个输出向量拼接。为了将输出结果作为下一组的输入,还需要将拼接后的输出向量再经过一个线性映射,映射回 $d$ 维向量。该模型又叫作多头自注意力(Multi-head Self-attention)模型。从另一方面理解,多头自注意力机制相当于多个不同的自注意力模型的集成(Ensemble),也会增强模型的效果。类似卷积神经网络中的多个卷积核,也可以将不同的注意力头理解为抽取不同类型的特征。

基于Transformer的序列到序列模型

  以上介绍的Transformer模型可以很好地对一个序列编码。此外,与循环神经网络类似,Transformer也可以很容易地实现解码功能,将两者结合起来,就实现 了一个序列到序列的模型,于是可以完成机器翻译等多种自然语言处理任务。解码模块的实现与编码模块基本相同,不过要接收编码模块的最后一层输出作为输入,这也叫作记忆(Memory),另外还要将已经部分解码的输出结果作为输入,如图16所示。

En c o de r L a y er En c o de r L a y er En c o de r L a y er En c o de r L a y er De c o de r L a y er De c o de r L a y er De c o de r L a y er De c o de r L a y er I l o v e y o u 我 爱 输入 部分输出 输出

图16 基于Transformer的序列到序列模型示例

Transformer模型的优缺点

  与循环神经网络相比,Transformer能够直接建模输入序列单元之间更长距离的依赖关系,从而使得Transformer对于长序列建模的能力更强。另外,在Transformer的编码阶段,由于可以利用GPU等多核计算设备并行地计算Transformer块内部的自注意力模型,而循环神经网络需要逐个计算,因此Transformer具有更高的训练速度。
  不过,与循环神经网络相比,Transformer的一个明显的缺点是参数量过于庞大。每一层的Transformer块大部分参数集中在图15中的Self Attention块和MLP块中,即自注意力模型中输入向量的三个角色映射矩阵、多头机制导致相应参数的倍增和引入非线性的多层感知器等。更主要的是,还需要堆叠多层Transformer块,从而参数量又扩大多倍。最终导致一个实用的Transformer模型含有巨大的参数量。以BERT模型为例,BERT-base含有12层Transformer块,参数量超过1.1亿个,而24层的BERT-large,参数量达到了3.4亿个之多。巨大的参数量导致Transformer模型非常不容易训练,尤其是当训练数据较小时。因此,为了降低模型的训练难度,基于大规模数据的预训练模型应运而生,这也是将要介绍的重点内容。唯此,才能发挥Transformer模型强大的表示能力。

模型实现

  新版本的PyTorch(1.2版及以上)实现了Transformer模型。其中,nn.TransformerEncoder实现了编码模块,它是由多层Transformer块构成的,每个块使用TransformerEncoderLayer,下面演示具体的示例。

1
2
3
4
5
6
7
8
import torch
from torch import nn
encoder_layer = nn.TransformerEncoderLayer(d_model=4, nhead=2)
## 创建一个Transformer块,每个输入向量、输出的向量维度为4,头数为2
src = torch.rand(2, 3, 4)
## 随机生成输入,三个参数分别为序列的长度、批次的大小和每个输入向量的维度
out = encoder_layer(src)
print(out)
1
2
3
4
5
6
7
8
tensor([[[-0.7978,  1.7041, -0.2970, -0.6093],
         [ 1.5975, -1.0087,  0.0642, -0.6530],
         [ 0.3744,  1.1322,  0.0961, -1.6027]],

        [[-0.7975,  1.7126, -0.5364, -0.3786],
         [ 1.5220, -0.6576,  0.2301, -1.0945],
         [-0.2557,  0.7426,  1.0341, -1.5211]]],
       grad_fn=<NativeLayerNormBackward0>)

  然后,可以将多个Transformer块堆叠起来,构成一个完整的nn.TransformerEncoder

1
2
3
transformer_encoder = nn.TransformerEncoder(encoder_layer, num_layers=6)
out = transformer_encoder(src)
print(out)
1
2
3
4
5
6
7
8
tensor([[[-0.6637,  1.5151, -1.0971,  0.2457],
         [-1.1536,  1.6024, -0.2438, -0.2050],
         [-1.0782,  1.5838, -0.5700,  0.0645]],

        [[-0.3844,  0.6036, -1.4256,  1.2064],
         [-1.2024,  1.5715, -0.0940, -0.2752],
         [-1.2272,  1.4674, -0.5149,  0.2748]]],
       grad_fn=<NativeLayerNormBackward0>)

  解码模块也类似,TransformerDecoderLayer定义了一个解码模块的Transformer块,通过多层块堆叠构成nn.TransformerDecoder,下面演示具体的调用方式。

1
2
3
4
5
6
memory = transformer_encoder(src)
decoder_layer = nn.TransformerDecoderLayer(d_model=4, nhead=2)
transformer_decoder = nn.TransformerDecoder(decoder_layer, num_layers=6)
out_part = torch.rand(2, 3, 4)
out = transformer_decoder(out_part, memory) 
print(out)
1
2
3
4
5
6
7
8
tensor([[[ 1.3573,  0.5609, -1.0012, -0.9170],
         [ 0.1509, -1.2029,  1.5202, -0.4682],
         [ 0.2734,  0.2175, -1.6161,  1.1252]],

        [[ 0.8558,  1.1077, -1.2195, -0.7440],
         [ 0.4810, -0.3283,  1.2744, -1.4271],
         [ 0.8755,  0.6158,  0.1877, -1.6790]]],
       grad_fn=<NativeLayerNormBackward0>)

神经网络模型的训练

  以上章节介绍了自然语言处理中几种常用的神经网络(深度学习)模型,其中每种模型内部都包含大量的参数,如何恰当地设置这些参数是决定模型准确率的关键,而寻找一组优化参数的过程又叫作模型训练或学习。

损失函数

  为了评估一组参数的好坏,需要有一个准则,在机器学习中,又被称为损失函数(Loss Function)。简单来讲,损失函数用于衡量在训练数据集上模型的输出与真实输出之间的差异。因此,损失函数的值越小,模型输出与真实输出越相似,可以认为此时模型表现越好。不过如果损失函数的值过小,那么模型就会与训练数据集过拟合(Overfit),反倒不适用于新的数据。所以,在训练深度学习模型时,要避免产生过拟合的现象,有多种技术可以达到此目的,如正则化(Regularization)、丢弃正则化(Dropout)和早停法(Early Stopping)等。
  在此介绍深度学习中两种常用的损失函数:均方误差(Mean Squared Error, MSE)损失和交叉熵(Cross-Entropy, CE)损失。所谓均方误差损失指的是每个 样本的平均平方损失,即:

$$\begin{aligned} \text{MSE}=\frac{1}{m}\sum_{i=1}^{m}(\^{y}^{(i)}-y^{(i)})^2 \tag{32}\end{aligned}$$

  式中,$m$ 表示样本的数目; $y^{i}$ 表示第 $i$ 个样本的真实输出结果; $\^{y}^{(i)}$ 表示第 $i$ 个样本的模型预测结果。可见,模型表现越好,即预测结果与真实结果越相似,均方误差损失越小。
  以上形式的均方误差损失适合于回归问题,即一个样本有一个连续输出值作 为标准答案。那么如何使用均方误差损失处理分类问题呢?假设处理的是 $c$ 类分类问题,则均方误差被定义为:

$$\begin{aligned} \text{MSE}=\frac{1}{m}\sum_{i=1}^{m}\sum_{j=1}^{c}(\^{y}^{(i)}_j-y^{(i)}_j)^2 \tag{33}\end{aligned}$$

  式中,$y^{(i)}_j$ 表示第 $i$ 个样本的第 $j$ 类上的真实输出结果,只有正确的类别输出为1,其他类别输出为0; $\^{y}^{(i)}_j$ 表示模型对第 $i$ 个样本的第 $j$ 类上的预测结果,如果使用Softmax函数对结果进行归一化,则表示对该类别预测的概率。与回归问题的均方误差损失一样,模型表现越好,其对真实类别预测的概率越趋近于1,对于错误类别预测的概率则趋近于0,因此最终计算的损失也越小。
  在处理分类问题时,交叉嫡损失是一种更常用的损失函数。与均方误差损失 相比,交叉嫡损失的学习速度更快。其具体定义为:

$$\begin{aligned} \text{CE}=-\frac{1}{m}\sum_{i=1}^{m}\sum_{j=1}^{c}y^{(i)}_j\log \^{y}^{(i)}_j \tag{34}\end{aligned}$$

  式中,$y^{(i)}_j\$ 表示第 $i$ 个样本的第 $j$ 类上的真实输出结果,只有正确的类别输出为1,其他类别输出为0; $\^{y}^{(i)}_j$ 表示模型对第 $i$ 个样本属于第 $j$ 类的预测概率。于是,最终交叉嫡损失只取决于模型对正确类别预测概率的对数值。如果模型表现越好,则预测的概率越大,由于公式右侧前面还有一个负号,所以交叉嫡损失越小(这符合直觉)。更本质地讲,交叉嫡损失函数公式右侧是对多类输出结果的分布(伯努利分布)求极大似然中的对数似然函数(Log-Likelihood)。另外,由于交叉熵损失只取决于正确类别的预测结果,所以其还可以进一步化简,即:

$$\begin{aligned} \text{CE}=-\frac{1}{m}\sum_{i=1}^{m}\log \^{y}^{(i)}_t \tag{35}\end{aligned}$$

  式中,$\^{y}^{(i)}_t$ 表示模型对第 $i$ 个样本在正确类别 $t$ 上的预测概率。所以,交叉嫡损失也被称为负对数似然损失(Negative Log Likelihood, NLL)。之所以交叉嫡损失的学习速度更高,是因为当模型错误较大时,即对正确类别的预测结果偏小(趋近于0),负对数的值会非常大;而当模型错误较小时,即对正确类别的预测结果偏大(趋近于1),负对数的值会趋近于0。这种变化是呈指数形的,即当模型错误较大时,损失函数的梯度较大,因此模型学得更快;而当模型错误较小时,损失函数的梯度较小,此时模型学得更慢。

梯度下降

  梯度下降(Gradient Descent, GD)是一种非常基础和常用的参数优化方法。梯度(Gradient)即以向量的形式写出的对多元函数各个参数求得的偏导数。如函数 $f(x_1,x_2,\cdots,x_n)$ 对各个参数求偏导,则梯度向量为 $\displaystyle\left[\frac{\partial f}{\partial x_1},\frac{\partial f}{\partial x_2},\cdots,\frac{\partial f}{\partial x_n}\right]$ ,也可以记为 $\nabla f(x_1,x_2,\cdots,x_n)$ 。梯度的物理意义是函数值增加最快的方向,或者说,沿着梯度的方向更加容易找到函数的极大值;反过来说,沿着梯度相反的方向,更加容易找到函数的极小值。正是利用了梯度的这一性质,对深度学习模型进行训练时,就可以通过梯度下降法一步步地迭代优化一个事先定义的损失函数,即得到较小的损失函数,并获得对应的模型参数值。梯度下降算法如下所示。


  算法1(梯度下降算法)
输入:学习率 $\alpha$ ;含有 $m$ 个样本的数据
输出:优化参数 $\bm{\theta}$

1.
设置损失函数为 $L(f(\bm{x};\bm{\theta}),y)$ ;
2.
初始化参数 $\bm{\theta}$ 。
3.
while 未达到终止条件 do
4.
  计算梯度 $\displaystyle \bm{g}=\frac{1}{m}\nabla_{\bm{\theta}}\sum_{i=1}^{m}L(f(\bm{x};\bm{\theta}),y)$ ;
5.
   $\bm{\theta}=\bm{\theta}-\alpha\bm{g}$ 。
6.
end

  在算法中,循环的终止条件根据实际情况可以有多种,如给定的循环次数、算法两次循环之间梯度变化的差小于一定的阈值和在开发集上算法的准确率不再提升等,读者可以根据实际情况自行设定。
  然而,当训练数据的规模比较大时,如果每次都遍历全部的训练数据计算梯度,算法的运行时间会非常久。为了提高算法的运行速度,每次可以随机采样一定规模的训练数据来估计梯度,此时被称为小批次梯度下降(Mini-batch Gradient Descent),具体算法如下。


  算法2(小批次梯度下降算法)
输入:学习率 $\alpha$ ;批次大小 $b$ ;含有 $m$ 个样本的训练数据
输出:优化参数 $\bm{\theta}$

1.
设置损失函数为 $L(f(\bm{x};\bm{\theta}),y)$ ;
2.
初始化参数 $\bm{\theta}$ 。
3.
while 未达到终止条件 do
4.
  从训练数据中采样 $b$ 个样本
5.
  计算梯度 $\displaystyle \bm{g}=\frac{1}{b}\nabla_{\bm{\theta}}\sum_{i=1}^{b}L(f(\bm{x};\bm{\theta}),y)$ ;
6.
   $\bm{\theta}=\bm{\theta}-\alpha\bm{g}$ 。
7.
end

  虽然与原始的梯度下降法相比,小批次梯度下降法每次计算的梯度可能不那么准确,但是由于其梯度计算的速度较高,因此可以通过更多的迭代次数弥补梯度计算不准确的问题。当小批次的数目被设为 $b=1$ 时,则被称为随机梯度下降(Stochastic Gradient Descent, SGD )。
  接下来,以多层感知器为例,介绍如何使用梯度下降法获得优化的参数,解决异或问题。代码如下。

 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
44
45
46
47
import torch
from torch import nn, optim
from torch.nn import functional as F

class MLP(nn.Module):
    def __init__(self, input_dim, hidden_dim, num_class):
        super(MLP, self).__init__()
        self.linear1 = nn.Linear(input_dim, hidden_dim)
        self.activate = F.relu
        self.linear2 = nn.Linear(hidden_dim, num_class)

    def forward(self, inputs):
        hidden = self.linear1(inputs)
        activation = self.activate(hidden)
        outputs = self.linear2(activation)
        # 获得每个输入属于某一类别的概率(Softmax),然后再取对数
        # 取对数的目的是避免计算Softmax时可能产生的数值溢出问题
        log_probs = F.log_softmax(outputs, dim=1)
        return log_probs

## 异或问题的4个输入
x_train = torch.tensor([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]])
## 每个输入对应的输出类别
y_train = torch.tensor([0, 1, 1, 0])

## 创建多层感知器模型,输入层大小为2,隐含层大小为5,输出层大小为2(即有两个类别)
model = MLP(input_dim=2, hidden_dim=5, num_class=2)

criterion = nn.NLLLoss() 
## 当使用log_softmax输出时,需要调用负对数似然损失(Negative Log Likelihood,NLL)
optimizer = optim.SGD(model.parameters(), lr=0.05) 
## 使用梯度下降参数优化方法,学习率设置为0.05
for epoch in range(100):
    y_pred = model(x_train) # 调用模型,预测输出结果
    loss = criterion(y_pred, y_train) # 通过对比预测结果与正确的结果,计算损失
    optimizer.zero_grad() 
    # 在调用反向传播算法之前,将优化器的梯度值置为零,否则每次循环的梯度将进行累加
    loss.backward() # 通过反向传播计算参数的梯度
    optimizer.step() 
    # 在优化器中更新参数,不同优化器更新的方法不同,但是调用方式相同

print("Parameters:")
for name, param in model.named_parameters():
    print (name, param.data)

y_pred = model(x_train)
print("Predicted results:", y_pred.argmax(axis=1))

  输出结果如下:首先,输出网络的参数值,包括两个线性映射层的权重和偏置项的值;然后,输出网络对训练数据的预测结果,即[0,1,1,0],其与原训练数据相同,说明该组参数能够正确地处理异或问题(即线性不可分问题)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
linear1.weight tensor([[ 0.4401, -0.3742],
        [ 0.0823, -0.0395],
        [ 0.6235,  0.4426],
        [ 0.9924,  0.9916],
        [-0.3244, -0.3630]])
linear1.bias tensor([-0.0999, -0.6520,  0.0083, -0.9847,  0.6435])
linear2.weight tensor([[ 0.0699,  0.4218, -0.4199,  1.0875,  0.0842],
        [ 0.3730, -0.4458,  0.4100, -1.0057, -0.0434]])
linear2.bias tensor([-0.0453,  0.0135])
Predicted results: tensor([0, 1, 1, 0])

  需要注意的是,PyTorch提供了nn.CrossEntropyLoss损失函数(类),不过与一般意义上的交叉嫡损失不同,其在计算损失之前自动进行Softmax计算,因此在网络的输出层不需要再调用Softmax层。这样做的好处是在使用该模型预测时可以提高速度,因为没有进行Softmax运算,直接将输出分数最高的类别作为预测结果即可。除了 nn.NLLLossnn.CrossEntropyLoss,PyTorch还定义了很多其他常用的损失函数,本书不再进行介绍,感兴趣的读者请参考PyTorch的官方文档。
  同样地,除了梯度下降,PyTorch还提供了其他的优化器,如Adam、Adagrad和Adadelta等,这些优化器是对原始梯度下降法的改进,改进思路包括动态调整学习率、对梯度累积等。它们的调用方式也非常简单,只要在定义优化器时替换为相应的优化器类,并提供一些必要的参数即可。

情感分类实战

  本节以句子情感极性分类为例,演示如何使用PyTorch实现上面介绍的四种深度学习模型,即多层感知器、卷积神经网络、LSTM和Transformer,来解决文本分类问题。为了完成此项任务,还需要编写词表映射、词向量层、融入词向量层的多层感知器数据处理、文本表示和模型的训练与测试等辅助功能,下面分别加以介绍。

词表映射

  无论是使用深度学习,还是传统的统计机器学习方法处理自然语言,首先都需要将输入的语言符号,通常为标记(Token),映射为大于等于0、小于词表大小的整数,该整数也被称作一个标记的索引值或下标。本节编写了一个Vocab(词表,Vocabulary)类实现标记和索引之间的相互映射。完整的代码如下。

 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
44
45
46
47
48
49
50
51
52
53
54
from collections import defaultdict, Counter

class Vocab:
    def __init__(self, tokens=None):
        self.idx_to_token = list()
        # 使用列表存储所有的标记,从而根据索引值获取相应的标记
        self.token_to_idx = dict()
        # 使用字典实现标记到索引值的映射
        
        if tokens is not None:
            if "<unk>" not in tokens:
                tokens = tokens + ["<unk>"]
            for token in tokens:
                self.idx_to_token.append(token)
                self.token_to_idx[token] = len(self.idx_to_token) - 1
            self.unk = self.token_to_idx['<unk>']

    @classmethod
    def build(cls, text, min_freq=1, reserved_tokens=None):
        # 创建词表,输入的text包含若干句子,每个句子由若干标记构成
        token_freqs = defaultdict(int) #存储标记及其出现次数的映射字典
        for sentence in text:
            for token in sentence:
                token_freqs[token] += 1
        # 无重复的标记,其中预留了未登录词(Unknown word)标记(<unk>)以及若干
        # 用户自定义的预留标记
        uniq_tokens = ["<unk>"] + (reserved_tokens if reserved_tokens else [])
        uniq_tokens += [token for token, freq in token_freqs.items() \
                        if freq >= min_freq and token != "<unk>"]
        return cls(uniq_tokens)

    def __len__(self):
        # 返回词表的大小,即词表中有多少个互不
        return len(self.idx_to_token)

    def __getitem__(self, token):
        # 查找输入标记对应的索引值,如果该标记
        # 不存在,则返回标记<unk>的索引
        return self.token_to_idx.get(token, self.unk)

    def convert_tokens_to_ids(self, tokens):
        return [self[token] for token in tokens]

    def convert_ids_to_tokens(self, indices):
        return [self.idx_to_token[index] for index in indices]

def save_vocab(vocab, path):
    with open(path, 'w') as writer:
        writer.write("\n".join(vocab.idx_to_token))

def read_vocab(path):
    with open(path, 'r') as f:
        tokens = f.read().split('\n')
    return Vocab(tokens)

词向量层

  在使用深度学习进行自然语言处理时,将一个词(或者标记)转换为一个低维、稠密、连续的词向量(也称Embedding)是一种基本的词表示方法,通过torch.nn包提供的Embedding层即可实现该功能。创建Embedding对象时,需要提供两个参数,分别是num_embeddings,即词表的大小;以及embedding_dim,即Embedding向量的维度。调用该对象实现的功能是将输入的整数张量中每个整数(通过词表映射功能获得标记对应的整数)映射为相应维度(embedding_dim)的张量。如下面的例子所示。

1
2
3
4
5
6
7
8
9
import torch
from torch import nn

embedding = nn.Embedding(8, 3)
input = torch.tensor([[0, 1, 2, 1], [4, 6, 6, 7]], dtype=torch.long)
## 输入形状为(2, 4)的整数张量(相当于两个长度为4的整数序列),
## 其中每个整数范围在0~7
output = embedding(input)
print(output)
1
2
3
4
5
6
7
8
9
tensor([[[-0.9304, -0.3600, -0.6919],
         [-0.4263,  0.5648, -0.3593],
         [-2.0616,  0.2496,  2.8274],
         [-0.4263,  0.5648, -0.3593]],

        [[-1.5431, -1.4533, -0.4889],
         [-0.2462,  1.0806,  1.3818],
         [-0.2462,  1.0806,  1.3818],
         [ 0.7281, -0.0115, -0.3848]]], grad_fn=<EmbeddingBackward0>)
1
2
print(output.shape)
## 输出张量形状为(2, 4, 3),即在原始输入最后增加一个长度为3的维
1
torch.Size([2, 4, 3])

融入词向量层的多层感知器

  在多层感知器中介绍了基本的多层感知器实现方式,其输入为固定大小的实数向量。如果输入为文本,即整数序列(假设已经利用词表映射工具将文本中每个标记映射为了相应的整数),在经过多层感知器之前,需要利用词向量层将输入的整数映射为向量。
  但是,一个序列中通常含有多个词向量,那么如何将它们表示为一个多层感知器的输入向量呢? 一种方法是将 $n$ 个向量拼接成一个大小为 $n \times d$ 的向量,其 中 $d$ 表示每个词向量的大小。不过,这样做的一个问题是最终的预测结果与标记在序列中的位置过于相关。例如,如果在一个序列前面增加一个标记,则序列中的每个标记位置都变了,也就是它们对应的参数都发生了变化,那么模型预测的结果可能完全不同,这样显然不合理。在自然语言处理中,可以使用词袋(Bag-Of-Words, BOW)模型解决该问题。词袋模型指的是在表示序列时,不考虑其中元素的顺序,而是将其简单地看成是一个集合。于是就可以采用聚合操作处理一个序列中的多个词向量,如求平均、求和或保留最大值等。融入词向量层以及词袋模型的多层感知器代码如下:

 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
import torch
from torch import nn
from torch.nn import functional as F

class MLP(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_class):
        super(MLP, self).__init__()
        # 词嵌入层
        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        # 线性变换:词嵌入层->隐含层
        self.linear1 = nn.Linear(embedding_dim, hidden_dim)
        # 使用ReLU激活函数
        self.activate = F.relu
        # 线性变换:激活层->输出层
        self.linear2 = nn.Linear(hidden_dim, num_class)

    def forward(self, inputs):
        embeddings = self.embedding(inputs)
        # 将序列中多个embedding进行聚合(此处是求平均值)
        embedding = embeddings.mean(dim=1)
        hidden = self.activate(self.linear1(embedding))
        outputs = self.linear2(hidden)
        # 获得每个序列属于某一类别概率的对数值
        probs = F.log_softmax(outputs, dim=1)
        return probs

mlp = MLP(vocab_size=8, embedding_dim=3, hidden_dim=5, num_class=2)
## 输入为两个长度为4的整数序列
inputs = torch.tensor([[0, 1, 2, 1], [4, 6, 6, 7]], dtype=torch.long)
outputs = mlp(inputs)
print(outputs)

  最终输出结果为每个序列属于某一类别的概率的对数值。

1
2
tensor([[-0.6134, -0.7798],
        [-0.6364, -0.7533]], grad_fn=<LogSoftmaxBackward0>)

图17展示了上述代码定义的词向量层、聚合层以及多层感知器模型(没有展示激活函数)。

聚合层 隐含层 输出层 词向量层 输入层

图17 词向量层、聚合层及多层感知器模型

  然而,在实际的自然语言处理任务中,一个批次里输入的文本长度往往是不固定的,因此无法像上面的代码一样简单地用一个张量存储词向量并求平均值。PyTorch提供了一种更灵活的解决方案,即EmbeddingBag层。在调用EmbeddingBag层时,首先需要将不定长的序列拼接起来,然后使用一个偏移向量(Offsets)记录每个序列的起始位置。举个例子,假设一个批次中有4个序列,长度分别为4、5、3和6,将这些长度值构成一个列表,并在前面加入0(第一个序列的偏移量), 构成列表offsets=[0, 4, 5, 3, 6],然后使用语句torch.tensor(offsets[:-1])获得张量[0, 4, 5, 3],后面紧接着执行cumsum(dim=0)方法(累加),获得新的张量[0, 4, 9, 12],这就是最终每个序列起始位置的偏移向量。下面展示相应的代码示例。

1
2
3
4
5
6
7
8
import torch
input1 = torch.tensor([0, 1, 2, 1] , dtype=torch.long)
input2 = torch.tensor([2, 1, 3, 7, 5] , dtype=torch.long)
input3 = torch. tensor([6, 4, 2], dtype=torch. long)
input4 = torch.tensor([1, 3, 4, 3, 5, 7], dtype=torch.long)
inputs = [input1, input2, input3, input4]
offsets = [0] + [i.shape[0] for i in inputs]
print (offsets)
1
[0, 4, 5, 3, 6]
1
2
offsets = torch.tensor(offsets[:-1]).cumsum(dim=0)
print(offsets)
1
tensor([ 0,  4,  9, 12])
1
2
inputs = torch.cat(inputs)
print(inputs)
1
tensor([0, 1, 2, 1, 2, 1, 3, 7, 5, 6, 4, 2, 1, 3, 4, 3, 5, 7])
1
2
3
embeddingbag = nn.EmbeddingBag(num_embeddings=8, embedding_dim=3)
embeddings = embeddingbag(inputs, offsets)
print(embeddings)
1
2
3
4
tensor([[-0.6432, -0.0680,  0.3472],
        [ 0.2624, -1.3626,  0.4095],
        [-0.4975,  0.2763,  0.9373],
        [ 0.3392, -1.0356,  0.5568]], grad_fn=<EmbeddingBagBackward0>)

  使用词袋模型表示文本的一个天然缺陷是没有考虑词的顺序。为了更好地对文本序列进行表示,还可以将词的N-gram( $n$ 元组)当作一个标记,这样相当于考虑了词的局部顺序信息,不过同时也增加了数据的稀疏性,因此几不宜过大(一般为2或3)。

数据处理

  数据处理的第一步自然是将待处理的数据从硬盘或者其他地方加载到程序中,此时读入的是原始文本数据,还需要经过基础工具集与常用数据集介绍的分句、标记解析等预处理过程转换为标记序列,然后再使用词表映射工具将每个标记映射到相应的索引值。在此,使用NLTK提供的句子倾向性分析数据(sentence_polarity)作为示例,具体代码如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import torch

def load_sentence_polarity():
    from nltk.corpus import sentence_polarity

    vocab = Vocab.build(sentence_polarity.sents())

    train_data = [(vocab.convert_tokens_to_ids(sentence), 0)
        for sentence in sentence_polarity.sents(categories='pos')[:4000]] \
        + [(vocab.convert_tokens_to_ids(sentence), 1)
        for sentence in sentence_polarity.sents(categories='neg')[:4000]]

    test_data = [(vocab.convert_tokens_to_ids(sentence), 0)
        for sentence in sentence_polarity.sents(categories='pos')[4000:]] \
        + [(vocab.convert_tokens_to_ids(sentence), 1)
        for sentence in sentence_polarity.sents(categories='neg')[4000:]]

    return train_data, test_data, vocab

  通过以上函数加载的数据不太方便直接给PyTorch使用,因此PyTorch提供了DataLoader类(在torch.utils.data包中)。通过创建和调用该类的对象,可以在训练和测试模型时方便地实现数据的采样、转换和处理等功能。例如,使用下列语句创建一个DataLoader对象。

1
2
3
from torch.utils.data import DataLoader
dataloader = DataLoader(dataset, batch_size=64, \
                        collate_fn=collate_fn, shuffle=True)

  以上代码提供了四个参数,其中batch_sizeshuffle较易理解,分别为每一步使用的小批次(Mini-batch)的大小以及是否对数据进行随机采样;而参数datasetcollate_fn则不是很直观,下面分别进行详细的介绍。
  datasetDataset类(在torch.utils.data包中定义)的一个对象,用于存储数据,一般需要根据具体的数据存取需求创建Dataset类的子类。如创建一个BowDataset子类,其中Bow是词袋的意思。具体代码如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class BowDataset(Dataset):
    def __init__(self, data):
    # data为原始的数据,如使用load_sentence_polarity函数获得的训练数据
    # 和测试数据
        self.data = data
    def __len__(self):
        # 返回数据集中样例的数目
        return len(self.data)
    def __getitem__(self, i):
        # 返回下标为i的样例
        return self.data[i]

  collate_fn参数指向一个函数,用于对一个批次的样本进行整理,如将其转换为张量等。具体代码如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def collate_fn(examples):
    # 从独立样本集合中构建各批次的输入输出
    # 其中,BowDataset类定义了一个样本的数据结构,即输入标签和输出标签的元组
    # 因此,将输入inputs定义为一个张量的列表,其中每个张量为原始句子中标记序列
    # 对应的索引值序列(ex[0])
    inputs = [torch.tensor(ex[0]) for ex in examples]
    # 输出的目标targets为该批次中全部样例输出结果(0或1)构成的张量
    targets = torch.tensor([ex[1] for ex in examples], dtype=torch.long)
    # 获取一个批次中每个样例的序列长度
    offsets = [0] + [i.shape[0] for i in inputs]
    # 根据序列的长度,转换为每个序列起始位置的偏移量(Offsets)
    offsets = torch.tensor(offsets[:-1]).cumsum(dim=0)
    # 将inputs列表中的张量拼接成一个大的张量
    inputs = torch.cat(inputs)
    return inputs, offsets, targets

多层感知器模型的训练与测试

  对创建的多层感知器模型,使用实际的数据进行训练与测试。

 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
44
45
46
47
48
49
50
51
52
from torch.utils.data import Dataset, DataLoader
## tqdm是一个Python模块,能以进度条的方式显示迭代的进度
from tqdm.auto import tqdm

## 超参数设置
embedding_dim = 128
hidden_dim = 256
num_class = 2
batch_size = 32
num_epoch = 5

## 加载数据
train_data, test_data, vocab = load_sentence_polarity()
train_dataset = BowDataset(train_data)
test_dataset = BowDataset(test_data)
train_data_loader = DataLoader(train_dataset, batch_size=batch_size, \
    collate_fn=collate_fn, shuffle=True)
test_data_loader = DataLoader(test_dataset, batch_size=1, \
    collate_fn=collate_fn, shuffle=False)

## 加载模型
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = MLP(len(vocab), embedding_dim, hidden_dim, num_class)
model.to(device) # 将模型加载到CPU或GPU设备

##训练过程
nll_loss = nn.NLLLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001) # 使用Adam优化器

model.train()
for epoch in range(num_epoch):
    total_loss = 0
    for batch in tqdm(train_data_loader, desc=f"Training Epoch {epoch}"):
        inputs, offsets, targets = [x.to(device) for x in batch]
        log_probs = model(inputs, offsets)
        loss = nll_loss(log_probs, targets)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    print(f"Loss: {total_loss:.2f}")

## 测试过程
acc = 0
for batch in tqdm(test_data_loader, desc=f"Testing"):
    inputs, offsets, targets = [x.to(device) for x in batch]
    with torch.no_grad():
        output = model(inputs, offsets)
        acc += (output.argmax(dim=1) == targets).sum().item()

## 输出在测试集上的准确率
print(f"Acc: {acc / len(test_data_loader):.2f}")

  输出:

1
Acc: 0.73

基于卷积神经网络的情感分类

  当使用词袋模型表示文本时,只考虑了文本中词语的信息,而忽视了词组信息,如句子“我不喜欢这部电影”,词袋模型看到文本中有“喜欢” 一词,则很可能将其识别为褒义。而卷积神经网络可以提取词组信息,如将卷积核的大小设置为2,则可以提取特征“不喜欢”等,显然这对于最终情感极性的判断至关重要。卷积神经网络的大部分代码与多层感知器的实现一致,下面仅对其中的不同之处加以说明。 首先是模型不同,需要从nn.Module类派生一个CNN子类。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class CNN(nn.Module):
    def __init__(self, vocab_size, embedding_dim, filter_size, num_filter, num_class):
        super(CNN, self).__init__()
        self.embedding = nn.Embedding(vocab_size, embedding_dim)
        self.conv1d = nn.Conv1d(embedding_dim, num_filter, filter_size, padding=1)
        self.activate = F.relu
        self.linear = nn.Linear(num_filter, num_class)
    def forward(self, inputs):
        embedding = self.embedding(inputs)
        convolution = self.activate(self.conv1d(embedding.permute(0, 2, 1)))
        pooling = F.max_pool1d(convolution, kernel_size=convolution.shape[2])
        outputs = self.linear(pooling.squeeze(dim=2))
        log_probs = F.log_softmax(outputs, dim=1)
        return log_probs

  在调用卷积神经网络时,还需要设置两个额外的超参数,分别filter.size=3(卷积核的大小)和num_filter=100(卷积核的个数)。
  另外,数据整理函数也需要进行一些修改。

1
2
3
4
5
6
7
8
from torch.nn.utils.rnn import pad_sequence

def collate_fn(examples):
    inputs = [torch.tensor(ex[0]) for ex in examples]
    targets = torch.tensor([ex[1] for ex in examples], dtype=torch.long)
    # 对batch内的样本进行padding,使其具有相同长度
    inputs = pad_sequence(inputs, batch_first=True)
    return inputs, targets

  在代码中,pad_sequence函数实现补齐(Padding)功能,使得一个批次中全部序列长度相同(同最大长度序列),不足的默认使用0补齐。除了以上两处不同,其他代码与多层感知器的实现几乎一致。由此可见,如要实现一个基于新模型的情感分类任务,只需要定义一个nn.Module类的子类,并修改数据整理函数(collate_fn)即可,这也是使用PyTorch等深度学习框架的优势。

  数据集代码如下:

1
2
3
4
5
6
7
class CnnDataset(Dataset):
    def __init__(self, data):
        self.data = data
    def __len__(self):
        return len(self.data)
    def __getitem__(self, i):
        return self.data[i]

  训练与测试代码如下:

 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
##tqdm是一个Python模块,能以进度条的方式显式迭代的进度
from tqdm.auto import tqdm

##加载数据
train_data, test_data, vocab = load_sentence_polarity()
train_dataset = CnnDataset(train_data)
test_dataset = CnnDataset(test_data)
train_data_loader = DataLoader(train_dataset, batch_size=batch_size, collate_fn=collate_fn, shuffle=True)
test_data_loader = DataLoader(test_dataset, batch_size=1, collate_fn=collate_fn, shuffle=False)

##加载模型
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = CNN(len(vocab), embedding_dim, filter_size, num_filter, num_class)
model.to(device) #将模型加载到CPU或GPU设备

##训练过程
nll_loss = nn.NLLLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001) #使用Adam优化器

model.train()
for epoch in range(num_epoch):
    total_loss = 0
    for batch in tqdm(train_data_loader, desc=f"Training Epoch {epoch}"):
        inputs, targets = [x.to(device) for x in batch]
        log_probs = model(inputs)
        loss = nll_loss(log_probs, targets)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    print(f"Loss: {total_loss:.2f}")

##测试过程
acc = 0
for batch in tqdm(test_data_loader, desc=f"Testing"):
    inputs, targets = [x.to(device) for x in batch]
    with torch.no_grad():
        output = model(inputs)
        acc += (output.argmax(dim=1) == targets).sum().item()

##输出在测试集上的准确率
print(f"Acc: {acc / len(test_data_loader):.2f}")

  输出:

1
Acc: 0.71

基于循环神经网络的情感分类

  词袋模型还忽略了文本中词的顺序信息,因此对于两个句子“张三打李四”和“李四打张三”,它们的表示是完全相同的,但显然这并不合理。循环神经网络模型能更好地对序列数据进行表示。本节以长短时记忆(LSTM)网络为例,介绍如何使用循环神经网络模型解决情感分类问题。其中,大部分代码与前面的实现一致,下面仅对其中的不同之处加以说明。
  首先,需要从nn.Module类派生一个LSTM子类。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from torch.nn.utils.rnn import pack_padded_sequence

class LSTM(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_class):
        super(LSTM, self).__init__()
        self.embeddings = nn.Embedding(vocab_size, embedding_dim)
        self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True)
        self.output = nn.Linear(hidden_dim, num_class)

    def forward(self, inputs, lengths):
        embeddings = self.embeddings(inputs)
        x_pack = pack_padded_sequence(embeddings, lengths, batch_first=True, enforce_sorted=False)
        hidden, (hn, cn) = self.lstm(x_pack)
        outputs = self.output(hn[-1])
        log_probs = F.log_softmax(outputs, dim=-1)
        return log_probs

  代码中,大部分内容在前面的章节都已介绍过,只有pack_padded_sequence函数需要特别说明。其实现的功能是将之前经过补齐的一个小批次序列打包成一个序列,其中每个原始序列的长度存储在lengths中。该打包序列能够被self. lstm对象直接调用。
  另一个主要不同是数据整理函数,具体代码如下。

1
2
3
4
5
6
7
def collate_fn(examples):
    lengths = torch.tensor([len(ex[0]) for ex in examples])
    inputs = [torch.tensor(ex[0]) for ex in examples]
    targets = torch.tensor([ex[1] for ex in examples], dtype=torch.long)
    # 对batch内的样本进行padding,使其具有相同长度
    inputs = pad_sequence(inputs, batch_first=True)
    return inputs, lengths, targets

  在代码中,lengths用于存储每个序列的长度。除此之外,其他代码与多层感知器或卷积神经网络的实现几乎一致。
  数据集代码如下:

1
2
3
4
5
6
7
class LstmDataset(Dataset):
    def __init__(self, data):
        self.data = data
    def __len__(self):
        return len(self.data)
    def __getitem__(self, i):
        return self.data[i]

  训练和测试代码如下:

 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
44
45
46
47
48
##tqdm是一个Python模块,能以进度条的方式显式迭代的进度
from tqdm.auto import tqdm

embedding_dim = 128
hidden_dim = 256
num_class = 2
batch_size = 32
num_epoch = 5

##加载数据
train_data, test_data, vocab = load_sentence_polarity()
train_dataset = LstmDataset(train_data)
test_dataset = LstmDataset(test_data)
train_data_loader = DataLoader(train_dataset, batch_size=batch_size, collate_fn=collate_fn, shuffle=True)
test_data_loader = DataLoader(test_dataset, batch_size=1, collate_fn=collate_fn, shuffle=False)

##加载模型
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = LSTM(len(vocab), embedding_dim, hidden_dim, num_class)
model.to(device) #将模型加载到GPU中(如果已经正确安装)

##训练过程
nll_loss = nn.NLLLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001) #使用Adam优化器

model.train()
for epoch in range(num_epoch):
    total_loss = 0
    for batch in tqdm(train_data_loader, desc=f"Training Epoch {epoch}"):
        inputs, lengths, targets = [x.to(device) for x in batch]
        log_probs = model(inputs, lengths)
        loss = nll_loss(log_probs, targets)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    print(f"Loss: {total_loss:.2f}")

##测试过程
acc = 0
for batch in tqdm(test_data_loader, desc=f"Testing"):
    inputs, lengths, targets = [x.to(device) for x in batch]
    with torch.no_grad():
        output = model(inputs, lengths)
        acc += (output.argmax(dim=1) == targets).sum().item()

##输出在测试集上的准确率
print(f"Acc: {acc / len(test_data_loader):.2f}")

  输出结果为

1
Acc: 0.73

基于Transformer的情感分类

  基于Transformer实现情感分类与使用LSTM也非常相似,主要有一处不同, 即需要定义Transformer模型。具体代码如下。

 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
class Transformer(nn.Module):
    def __init__(self, vocab_size, embedding_dim, hidden_dim, num_class,
                 dim_feedforward=512, num_head=2, num_layers=2, dropout=0.1, max_len=128, activation: str = "relu"):
        super(Transformer, self).__init__()
        # 词嵌入层
        self.embedding_dim = embedding_dim
        self.embeddings = nn.Embedding(vocab_size, embedding_dim)
        self.position_embedding = PositionalEncoding(embedding_dim, dropout, max_len)
        # 编码层:使用Transformer
        encoder_layer = nn.TransformerEncoderLayer(hidden_dim, num_head, dim_feedforward, dropout, activation)
        self.transformer = nn.TransformerEncoder(encoder_layer, num_layers)
        # 输出层
        self.output = nn.Linear(hidden_dim, num_class)


    def forward(self, inputs, lengths):
        inputs = torch.transpose(inputs, 0, 1)
        hidden_states = self.embeddings(inputs)
        hidden_states = self.position_embedding(hidden_states)
        attention_mask = length_to_mask(lengths) == False
        hidden_states = self.transformer(hidden_states, src_key_padding_mask=attention_mask)
        hidden_states = hidden_states[0, :, :]
        output = self.output(hidden_states)
        log_probs = F.log_softmax(output, dim=1)
        return log_probs
本图书馆累计发布了37篇文章 共51.8万字
本图书馆访客数 访问量