一、Transformer 模型出现之前的NLP 语言模型
1、N 元文法语言模型
1.1、马尔科夫假设(Markov Assumption)与 N 元文法语言模型(N-gram Language Model)
下一个词出现的概率只依赖于它前面 n-1 个词,这种假设被称为马尔科夫假设(Markov Assumption)。N 元文法,也称为 N-1 阶马尔科夫链。
一元文法(1-gram),unigram,零阶马尔科夫链,不依赖前面任何词;
二元文法(2-gram),bigram,一阶马尔科夫链,只依赖于前 1 个词;
三元文法(3-gram),trigram,二阶马尔科夫链,只依赖于前 2 个词;
通过前 t-1 个词预测时刻 t 出现某词的概率,用最大似然估计:
进一步地,一组词(也就是一个句子)出现的概率就是:
模型表示如下:
模型存在的问题:
因为基于马尔科夫假设,所以 N 固定窗口取值,对长距离词依赖的情况会表现很差。
如果把 N 值取很大来解决长距离词依赖,则会导致严重的数据稀疏(零频太多了),参数规模也会急速爆炸(高维张量计算)。
这两个问题在神经网络模型出现后才更好解决的。
如果有词出现次数为了 0,这一串乘出来就是 0
这个问题引入平滑 / 回退 / 差值等方法来解决
1.2、平滑(Smoothing)/ 折扣(Discounting)
虽然限定了窗口 n 大小降低了词概率为 0 的可能性,但当 n-gram 的 n 比较大的时候会有的未登录词问题(Out Of Vocabulary,OOV)。另一方面,训练数据很可能也不是 100% 完备覆盖实际中可能遇到的词的。所以为了避免 0 概率出现,就有了让零平滑过渡为非零的补丁式技术出现。
最简单的平滑技术,就是折扣法(Discounting)。就是把整体 100% 的概率腾出一小部分来,给这些零频词(也常把低频词一起考虑)。常见的平滑方法有:加 1 平滑、加 K 平滑、Good-Turing 平滑、Katz 平滑等。
1.2.1、加 1 平滑 / 拉普拉斯平滑(Add-One Discounting / Laplace Smoothing)
加 1 平滑,就是直接将所有词汇的出现次数都 +1,不止针对零频词、低频词。模型就会变成:
其中 N 表示所有词的词频之和,|V| 表示词汇表的大小
1.2.2、加 K 平滑 / δ 平滑(Add-K Discounting / Delta Smoothing)
把 +1 换成 δ,模型变成:
δ 是一个超参数,确定它的值需要用到困惑度(Perplexity,一般用缩写 PPL)。
1.2.3、困惑度(Perplexity)
对于指定的测试集,困惑度定义为测试集中每一个词概率的几何平均数的倒数
把马尔科夫带入上述公式,就得到了 PPL 的计算公式:
1.3、回退(Back-off)
在多元文法模型中,比如以 3-gram 为例,如果出现某些三元语法概率为零,则不使用零来表示概率,而回退到 2-gram
1.4、差值(Interpolation)
N 元文法模型如果用回退法,则只考虑了 n-gram 概率为 0 时回退为 n-1 gram,n-gram 不为零时,也可以按一定权重来考虑 n-1 gram。以 3-gram 为例,把 2-gram、1-gram 都考虑进来:
2、 感知器(Perceptron)
N 元文法模型的问题,基本在神经网络模型中被解决,而要了解神经网络模型,就要从感知器(Perceptron)开始。1957 年感知机模型被提出,1959 年多层感知机(MLP)模型被提出。MLP 有时候也被称为 ANN,即 Artificial Neural Network
2.1、感知器(Perceptron)
解决二元分类任务的前馈神经网络
感知器其实就是一个前馈神经网络,由输入层、输出层组成,没有隐藏层。而且输出是一个二元函数,用于解决二元分类问题
x 是一个输入向量,ω 是一个权重向量(对输入向量里的而每个值分配一个权重值所组成的向量)。比如如果这两个向量的内积超过某个值,则判断为 1,否则为 0,这其实就是一个分类任务。那么这个最终输出值可以如下表示:
这就是一个典型的感知器(Perceptron),一般用来解决分类问题。还可以再增加一个偏差项(bias)
2.2、线性回归(Linear Regression)
从离散值的感知器(解决类问题),到连续值的线性回归(解决回归问题)
一般认为感知器的输出结果,是离散值。一般认为离散值作为输出解决的问题,是分类问题;相应地,连续值解决的问题是回归(Regression)。比如对于上面的感知器,如果我们直接将 ω⋅x+b 作为输出值,则就变成了一个线性回归问题的模型了。
用 PyTorch 来实现一个线性回归的代码示例,在 PyTorch 里有一个常用的函数:
nn.Linear(in_features, out_features)
这个函数在创建时会自动初始化权值和偏置,并且可以通过调用它的 forward 函数来计算输入数据的线性变换。当输入为 x 时,forward 函数会计算 y=ω⋅x+b,其中 W 和 b 分别是 nn.Linear 图层的权值和偏置。
import torch
import torch.nn as nn# 定义模型,一开始先创建一个 LinearRegression 线性回归模型的类,其中有一个 forward 前向传播函数,调用时其实就是计算一下输出值 y。
class LinearRegression(nn.Module):def __init__(self, input_size, output_size):super(LinearRegression, self).__init__()self.linear = nn.Linear(input_size, output_size)def forward(self, x):return self.linear(x)# 初始化模型,创建一个线性回归模型实例,
model = LinearRegression(input_size=1, output_size=1)# 定义损失函数和优化器,定义一个用于评价模型效果的损失函数评价器,和用随机梯度下降(Stochastic Gradient Descent)作为优化器。
criterion = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)# 创建输入特征 X 和标签 y,然后创建一个输入特征张量,和标签张量。用这组特征和标签进行训练,训练的过程就是根据 X 计算与测试 predictions 向量,再把它和 y 一起给评价器算出损失 loss,然后进行反向传播。
X = torch.Tensor([[1], [2], [3], [4]])
y = torch.Tensor([[2], [4], [6], [8]])# 训练模型,如此训练 100 次(每一次都会黑盒化地更新模型的参数,一个 epoch 就是一次训练过程,有时也称为 iteration 或者 step,不断根据 loss 训练优化模型参数。
for epoch in range(100):# 前向传播predictions = model(X)loss = criterion(predictions, y)# 反向传播optimizer.zero_grad()loss.backward()optimizer.step()#创建了一组测试特征值张量 X_test,和测试标签张量 y_test,然后用它们测试模型性能,把测试特征得到的 predictions 与 y_test 共同传给评价器,得到 loss。
X_test = torch.Tensor([[5], [6], [7], [8]])
y_test = torch.Tensor([[10], [12], [14], [16]])# 测试模型,得到如下结果:Test loss: 0.0034
with torch.no_grad():predictions = model(X_test)loss = criterion(predictions, y_test)print(f'Test loss: {loss:.4f}')
2.3、逻辑回归(Logistic Regression)
没有值域约束的线性回归,到限定在一个范围内的逻辑回归(常用于分类问题)
线性回归问题,输出值是没有范围限定的。如果限定(limit)在特定的 (0,L) 范围内,则就叫做逻辑回归了。那么如何将一个线性回归变成逻辑回归呢?一般通过如下公式变换:这样原来的 z∈(−∞,+∞) 就被变换成了 y∈(0,L) 了。
激活函数:这种把输出值限定在一个目标范围内的函数,被叫做 激活函数(Activation Function)。
函数的陡峭程度 由 k 控制,越大越陡。
scikit-learn 库的示例代码:
from sklearn.linear_model import LogisticRegression
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split# 这是 scikit-learn 库里的一个简单的数据集
iris = load_iris()# 把 iris 数据集拆分成训练集和测试集两部分
X_train, X_test, y_train, y_test = train_test_split(iris.data, iris.target, test_size=0.25, random_state=42)# 用 scikit-learn 库创建一个逻辑回归模型的实例
lr = LogisticRegression()# 用上边 split 出来的训练集数据,训练 lr 模型实例
lr.fit(X_train, y_train)# 用训练过的模型,拿测试集的输入数据做测试
predictions = lr.predict(X_test)# 用测试集的数据验证精确性
accuracy = lr.score(X_test, predictions)
print(accuracy)
2.4、Sigmoid 回归(Sigmoid Regression)
归一化的逻辑回归,一般用于二元分类任务
当 L=1,k=1,z0=0 ,此时的激活函数就是 Sigmoid 函数,也常表示为 σ 函数,如下
Sigmoid 回归的值域,恰好在 (0, 1) 之间,所以常备作为用来归一化的激活函数。而一个线性回归模型,再用 sigmoid 函数归一化,这种也常被称为Sigmoid 回归。Sigmoid 这个单词的意思也就是 S 形,我们可以看下它的函数图像如下:
因为归一化,所以也可以把输出值理解为一个概率。比如一个二元分类问题,那么输出结果就对应属于这个类别的概率。
这样一个 sigmoid 模型可以表示为:
Sigmoid 回归一般用于二元分类任务。对于超过二元的情况需要下面的 Softmax 回归。
2.5、Softmax 回归(Softmax Regression)
Softmax 也称为多项逻辑回归。Sigmoid 一般用于解决二分类问题,那么多元问题就要用 Softmax 回归。比如问题是对于任意输入的一个电商商品的图片,来判断这个图片所代表的的商品属于哪个商品类目。假设我们一共有 100 个类目。那么一个图片比如说其所有像素值作为输入特征值,输出就是一个 100 维的向量 ** z **,输出向量中的每个值 zi 表示属于相对应类目的概率 yi :
那么最后得到的 y 向量中的每一项就对应这个输入 z 属于这 100 个类目的各自概率了。 Softmax 回归的模型如下:
假设每个图片的尺寸是 512x512,这个模型展开式如下:
这个对输入向量 x 执行 w⋅x+b 运算,一般也常称为「线性映射/线性变化」。
2.6、多层感知器(Multi-Layer Perceptron)
上面遇到的任务,都是用线性模型(Linear Models)解决的。有时候问题复杂起来,就要引入非线性模型。
激活函数 ReLU(Rectified Linear Unit), 是一个非线性激活函数,定义如下:
比如 MNIST 数据集的手写数字分类问题,就是一个典型的非线性的分类任务,示例代码:
import torch
import torch.nn as nn
import torchvision
import torchvision.transforms as transforms# 定义多层感知器模型
class MLP(nn.Module):def __init__(self, input_size, hidden_size, num_classes):super(MLP, self).__init__()self.fc1 = nn.Linear(input_size, hidden_size)self.relu = nn.ReLU()self.fc2 = nn.Linear(hidden_size, num_classes)def forward(self, x):out = self.fc1(x)out = self.relu(out)out = self.fc2(out)return out# 超参数
input_size = 784
hidden_size = 500
num_classes = 10
num_epochs = 5
batch_size = 100
learning_rate = 0.001# 加载 MNIST 数据集
train_dataset = datasets.MNIST(root='../../data',train=True,transform=transforms.ToTensor(),download=True)test_dataset = datasets.MNIST(root='../../data',train=False,transform=transforms.ToTensor())# 数据加载器
train_loader = torch.utils.data.DataLoader(dataset=train_dataset,batch_size=batch_size,shuffle=True)test_loader = torch.utils.data.DataLoader(dataset=test_dataset,batch_size=batch_size,shuffle=False)model = MLP(input_size, hidden_size, num_classes)# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)# 训练模型
for epoch in range(num_epochs):for images, labels in train_loader:# 前向传播outputs = model(images)loss = criterion(outputs, labels)# 反向传播optimizer.zero_grad()loss.backward()optimizer.step()# 输出训练损失print(f'Epoch {epoch + 1}, Training Loss: {loss.item():.4f}')
用公式表示模型定义:
MLP 通常是一个输入和输出长度相同的模型,但少数情况下也可以构建输入和输出长度不同的 MLP 模型,比如输入一组序列后,输出是一个离散的分类结果。
2.7、正向传播与反向传播
训练神经网络,主要包括前向传播、反向传播这两步。
正向传播,就是将数据输入给模型,基于已确定的一组参数(比如 MLP 中的权重 W、偏置 b 等),得到输出结果。根据输出结果计算损失函数,衡量当前参数下的模型性能。
反向传播最常用到的是梯度下降法,依托损失函数,将其中的参数当做变量来求偏导(计算梯度),沿着梯度下降的方向求解损失函数的极小值,此时的参数可替代此前的参数。这就是对模型优化训练的一个典型过程。
梯度消失、梯度爆炸问题:因为对损失函数的求偏导,是从输出层向输入层反向基于链式法则计算的,数学上这是个连乘计算,层数越多越容易出现这个问题。这个求导过程可能会出现梯度为零的情况,即梯度消失。也有可能出现梯度值特别大的情况。
2.8、MLP 的问题
在 MLP 中,不论有多少层,某一层的输出向量hn 中的每个值,都会在下一层计算输出向量hn+1 的每个值时用到。如果对于某一层的输出值如下:
「用到」,其实就是要针对 hn 生成相应的特征 Wn+1 权重矩阵中的每个行列里的数值和 bn+1 偏差向量 里的每个值。
可以看到,输入的所有元素都被连接,即被分配权重 w 和偏差项 b,所以这被称为一个全连接层(Fully Connected Layer)或者稠密层(Dense Layer)。但是对于一些任务这样做会付出大量无效的计算。
因此我们需要 focus 在更少量计算成本的模型,于是有了卷积神经网络(CNN)。
3、卷积神经网络(CNN)
MLP 里每一层的每个元素,都要乘以一个独立参数的权重 W,再加上一个偏置 b,这样有个问题:如果输入内容的局部重要信息只是发生轻微移动并没有丢失,在全连接层处理后,整个输出结果都会发生很大变化,这不合理。
于是我们会想到,如果我们用一个小一些的全连接层,只对重要的局部输入进行处理呢?其实这个思路和 n-gram 是类似的,都是用一个窗口来扫描局部。卷积神经网络(Convolutional Neural Network,CNN)就是基于此诞生的。
卷积核:卷积核是一个小的稠密层,用于提取局部特征,又称其为卷积核(kernel)/ 滤波器(filter)/ 感受野(receptive field / field of view)。
池化层(Pooling,或称汇聚层):经过卷积核处理的结果,进一步聚合的过程。对于输入大小不一样的样本,池化后将有相同个数的特征输出。
提取多个局部特征:一个卷积核只能提取单一类型的局部特征,需要提取多种局部特征则需要多个卷积核。有些文章里提到「多个模式」、「多个通道」,其实指的就是多个 kernel 识别多个特征。
全连接分类层:多个卷积核得到的多个特征,需经过一个全连接的分类层用于最终决策。
这样做有几个特性:
本地性(Locality):输出结果只由一个特定窗口大小区域内的数据决定。
平移不变性(Translation Invariant):对同一个特征,扫描不同区域时只用一个 kernel 来计算。
卷积层的参数规模,与输入输出数据大小无关。
CNN 主要的适用领域是计算机视觉。而在 NLP 中,文本数据的维度很高,并且语言的结构比图像更复杂。因此,CNN 一般不适用于处理 NLP 问题。
4、循环神经网络(RNN)
4.1、经典结构的 RNN
Unfold 箭头右侧是展开示意。输入序列(用 x 表示)传递给隐藏层(hidden layer,用 h 表示),处理完生成输出序列(用 o 表示)。序列的下一个词输入时的、上一步隐藏层会一起影响这一步的输出。U、V、W 都表示权重。输入序列长度与输出序列长度是相同的。
这种经典结构的应用场景,比如对一段普通话输入它的四川话版本,比如对视频的每一帧进行处理并输出,等等。
RNN 是一个一个序列处理的,每个序列中的数据项都是有序的,所以对于计算一个序列内的所有数据项是无法并行的。但是计算不同序列时,不同序列各自的计算则是可以并行的。如果我们把上一个时刻 t 隐藏层输出的结果( ht−1 )传给一个激活函数(比如说用正切函数 tanh 函数),然后和当下时刻 t 的这个输入(xt )一起,处理后产生一个时刻 t 的输出(ht )。然后把隐藏层的输出通过多项逻辑回归(Softmax)生成最终的输出值( y ),可以如下表示这个模型:
对应的示意图如下:
这种输入和输出数据项数一致的 RNN,叫做 N vs. N 的 RNN。用 PyTorch 来实现一个非常简单的经典 RNN
import torch
import torch.nn as nn
# 实例化一个单向单层RNN
# 创建一个 RNN 实例,实例化了一个带有 1 个隐藏层的 RNN 网络。
# 第一个参数,初始隐藏状态是一个形状为 (1, 5, 20) 的张量。
rnn = nn.RNN(10, 20, 1, batch_first=True) # 输入是一个形状为 (5, 3, 10) 的张量,表示有 5 个样本,每个样本有 3 个时间步,每个时间步的特征维度是 10。
# 5 个输入数据项(也可以说是样本)
# 3 个数据项是一个序列,有 3 个 steps
# 每个 step 有 10 个特征
input = torch.randn(5, 3, 10)# 隐藏层是一个 (1, 5, 20) 的张量
h0 = torch.randn(1, 5, 20)# 调用 rnn 函数后,返回输出、最终的隐藏状态
output, hn = rnn(input, h0)
# 输出的形状是 (5, 3, 20),表示有 5 个样本,每个样本有 3 个时间步,每个时间步的输出维度是 20。
# 最终的隐藏状态的形状是 (1, 5, 20),表示最后的隐藏状态是 5
print(output)
print(hn)
手写RNN
class MikeCaptainRNN(nn.Module):def __init__(self, input_size, hidden_size):super().__init__()# 对于 RNN,输入维度就是序列数self.input_size = input_size# 隐藏层有多少个节点/神经元,经常将 hidden_size 设置为与序列长度相同self.hidden_size = hidden_size# 输入层到隐藏层的 W^{xh} 权重、bias^{xh} 偏置项self.weight_xh = torch.randn(self.hidden_size, self.input_size) * 0.01self.bias_xh = torch.randn(self.hidden_size)# 隐藏层到隐藏层的 W^{hh} 权重、bias^{hh} 偏置项self.weight_hh = torch.randn(self.hidden_size, self.hidden_size) * 0.01self.bias_hh = torch.randn(self.hidden_size)# 前向传播def forward(self, input, h0):# 取出这个张量的形状N, L, input_size = input.shape# 初始化一个全零张量output = torch.zeros(N, L, self.hidden_size)# 处理每个时刻的输入特征for t in range(L):# 获得当前时刻的输入特征,[N, input_size, 1]。unsqueeze(n),在第 n 维上增加一维x = input[:, t, :].unsqueeze(2) w_xh_batch = self.weight_xh.unsqueeze(0).tile(N, 1, 1) # [N, hidden_size, input_size]w_hh_batch = self.weight_hh.unsqueeze(0).tile(N, 1, 1) # [N, hidden_size, hidden_size]# bmm 是矩阵乘法函数w_times_x = torch.bmm(w_xh_batch, x).squeeze(-1) # [N, hidden_size]。squeeze(n),在第n维上减小一维w_times_h = torch.bmm(w_hh_batch, h0.unsqueeze(2)).squeeze(-1) # [N, hidden_size]h0 = torch.tanh(w_times_x + self.bias_ih + w_times_h + self.bias_hh)output[:, t, :] = h0return output, h0.unsqueeze(0)
4.2、N vs.1 的 RNN
上面那个图里,如果只保留最后一个输出,那就是一个 N vs. 1 的 RNN 了。应用场景,比如说判断一个文本序列是英语还是德语,比如根据一个输入序列来判断是一个正向情绪内容还是负向或者中性,或者比如根据一段语音输入序列来判断是哪一首曲子(听歌识曲)。
这个模型里,每个序列只有隐藏层对最后一个数据项进行处理时才产生输出 hn 。是如下结构:
4.3、1 vs. N 的 RNN
反过来,上面那个图里,如果只保留一个 x,那么就是一个 1 vs. N 的 RNN 了。这种场景的应用,比如 AI 创作音乐,还有通过一个 image 提炼或识别某些文本内容输出。
示意图如下:
在 RNN 的隐藏层是能够存储一些有关于输入数据的一些相关内容的,所以也常把 RNN 的隐藏层叫做记忆单元。
4.4、LSTM(Long Short-Term Memory)长短时记忆网络
4.4.1、如何理解这个 Short-Term
1997 年论文《Long Short-Term Memory》中提出 LSTM 模型。模型的定义:
上式中与经典结构的 RNN(输入与输出是 N vs. N)相比,唯一的区别是第一个式子中多了一个ht−1 。如果把第一个式子的 tanh 部分记作ut :
所以:
展开出如下一组式子:
如果从 hk+1 到 hn 的所有式子左侧相加、右侧相加,就得到如下式子:
进而推导出:
从这里就可以看到,第 t 时刻的隐藏层输出,直接关联到第 k 时刻的输出,t 到 k 时刻的相关性则用uk+1 到 ut 相加表示。也就是有 t-k 的短期(Short Term)记忆。
4.4.2、遗忘门 f、输入门 i、输出门 o、记忆细胞 c
如果我们为式子 ht=ht−1+ut 右侧两项分配一个权重,就是隐藏层对上一个数据项本身被上一个数据项经过隐藏层计算的结果,这两者做一对权重考虑配比:
其中:
⊙ 是 Hardamard 乘积,即张量的对应元素相乘。
ft 是遗忘门(Forget Gate),该值很小时 t-1 时刻的权重就很小,也就是「此刻遗忘上一刻」。该值应根据 t 时刻的输入数据、t-1 时刻数据在隐藏层的输出计算,而且其每个元素必须是 (0, 1) 之间的值,所以可以用 sigmoid 函数来得到该值:
但这种方式,对于过去ht−1 和当下 ut 形成了互斥,只能此消彼长。但其实过去和当下可能都很重要,有可能都恨不重要,所以我们对过去继续采用 ft 遗忘门,对当下采用it 输入门(Input Gate):
其中:
与 ft 类似地,定义输入门 it ,但是注意 ft 与ht−1 而非xt−1 有关。
再引入一个输出门:
再引入记忆细胞 ct ,它是原来 ht 的变体,与 t-1 时刻的记忆细胞有遗忘关系(通过遗忘门),与当下时刻有输入门的关系:
那么此时 可以把 ht 变成:
记忆细胞它存储了过去的一些信息。
到此整体的 LSTM 模型就变成了这个样子:
4.5、双向循环神经网络、双向 LSTM
双向循环神经网络很好理解,就是两个方向都有,例如下图:
在 PyTorch 中使用 nn.RNN 有参数表示双向:bidirectional:默认设置为 False。若为 True,即为双向 RNN。
4.6、堆叠循环神经网络(Stacked RNN)、堆叠长短时记忆网络(Stacked LSTM)
在 PyTorch 中使用 nn.RNN 有参数表示双向:num_layers:隐藏层层数,默认设置为 1 层。当 num_layers >= 2 时,就是一个 stacked RNN 了。
4.7、N vs. M 的 RNN
对于输入序列长度(长度 N)和输出序列长度(长度 M)不一样的 RNN 模型结构,也可以叫做 Encoder-Decoder 模型,也可以叫 Seq2Seq 模型。首先接收输入序列的 Encoder 先将输入序列转成一个隐藏态的上下文表示 C。C 可以只与最后一个隐藏层有关,甚至可以是最后一个隐藏层生成的隐藏态直接设置为 C,C 还可以与所有隐藏层有关。
有了这个 C 之后,再用 Decoder 进行解码,也就是从把 C 作为输入状态开始,生成输出序列。
可以如下表示:
进一步展开:
这种的应用就非常广了,因为大多数时候输入序列与输出序列的长度都是不同的,比如最常见的应用,从一个语言翻译成另一个语言;再比如语音识别,将语音序列输入后生成所识别的文本内容;还有比如 ChatGPT 这种问答应用等等。
Seq2Seq 模型非常出色,一直到 2018 年之前 NLP 领域里该模型已成为主流。但是它有很显著的问题:
当输入序列很长时,Encoder 生成的 Context 可能就会出现所捕捉的信息不充分的情况,导致 Decoder 最终的输出是不尽如人意的。具体地,毕竟还是 RNN 模型,其词间距过长时还是会有梯度消失问题,根本原因在于用到了「递归」。当递归作用在同一个 weight matrix 上时,使得如果这个矩阵满足条件的话,其最大的特征值要是小于 1 的话,就一定出现梯度消失问题。后来的 LSTM 和 GRU 也仅仅能缓解问题,并不能根本解决。
并行效果差:每个时刻的结果依赖前一时刻。
5、为什么 RNN 模型没有体现「注意力」
Encoder-Decoder 的一个非常严重的问题,是依赖中间那个 context 向量,则无法处理特别长的输入序列 —— 记忆力不足,根本原因,是没有「注意力」。
当输入序列经过 Encoder 生成的中间结果(上下文 C),被喂给 Decoder 时,这些中间结果对所生成序列里的哪个词,都没有区别(没有特别关照谁)。这相当于在说:输入序列里的每个词,对于生成任何一个输出的词的影响,是一样的,而不是输出某个词时是聚焦特定的一些输入词。这就是模型没有注意力机制。
人脑的注意力模型,其实是资源分配模型。NLP 领域的注意力模型,是在 2014 年被提出的,后来逐渐成为 NLP 领域的一个广泛应用的机制。可以应用的场景,比如对于一个电商平台中很常见的白底图,其边缘的白色区域都是无用的,那么就不应该被关注(关注权重为 0)。比如机器翻译中,翻译词都是对局部输入重点关注的。
所以 Attention 机制,就是在 Decoder 时,不是所有输出都依赖相同的上下文 Ct ,而是时刻 t 的输出,这个 Ct 来自对每个输入数据项根据注意力进行的加权。
6、基于 Attention 机制的 Encoder-Decoder 模型
2015 年论文《Neural Machine Translation by Jointly Learning to Align and Translate》 中提出了「Attention」机制
ei 表示编码器的隐藏层输出,di 表示解码器的隐藏层输出
更进一步细化关于 Ct 部分
这个图里的 h~i 与上一个图里的 di 对应, hi 与上一个图里的 ei 对应。
针对时刻 t 要产出的输出,隐藏层每一个隐藏细胞都与 Ct 有一个权重关系 αt,i 其中 1≤i≤n ,这个权重值与「输入项经过编码器后隐藏层后的输出 ei(1≤i≤n) 、解码器的前一时刻隐藏层输出dt−1 」两者有关:
常用的 score 函数有:
然后上下文向量就表示成:
Encoder-Decoder 模型的公式表示
加入 Attention 机制的 Encoder-Decoder 模型如下。
这种同时考虑 Encoder、Decoder 的 Attention,就叫做「Encoder-Decoder Attention」,也常被叫做「Vanilla Attention」。可以看到上面最核心的区别是第二个公式Ct 。加入 Attention 后,对所有数据给予不同的注意力分布。用如下的函数来定义这个模型:
注意力机制的问题:忽略了位置信息。比如 Tigers love rabbits 和 Rabbits love tigers 会产生一样的注意力分数。
二、Transformer 模型
Transformer 模型中用到了自注意力(Self-Attention)、多头注意力(Multiple-Head Attention)、残差网络(ResNet)与捷径(Short-Cut)
1、自注意力机制(Self-Attention)
在加入了 Attention 的 Encoder-Decoder 模型中,对输出序列 Y 中的一个词的注意力来自于输入序列 X,那么如果 X 和 Y 相等呢?什么场景会有这个需求?因为我们认为一段文字里某些词就是由于另外某些词而决定的,那么这样一段文字,其实就存在其中每个词的自注意力,举个例子:
老王是我的主管,我很喜欢他的平易近人。
对这句话里的「他」,如果基于这句话计算自注意力的话,显然应该给予「老王」最多的注意力。受此启发,我们认为:
一段自然语言中,其实暗含了:为了得到关于某方面信息 Q,可以通过关注某些信息 K,进而得到某些信息(V)作为结果。
Q 就是 query 检索/查询,K、V 分别是 key、value。所以类似于我们在图书检索系统里搜索「NLP书籍」(这是 Q),得到了一本叫《自然语言处理实战》的电子书,书名就是 key,这本电子书就是 value。只是对于自然语言的理解,我们认为任何一段内容里,都自身暗含了很多潜在 Q-K-V 的关联。这是整体受到信息检索领域里 query-key-value 的启发的。
基于这个启发,我们将自注意力的公式表示为:
X 经过自注意力计算后,得到的「暗含」了大量原数据内部信息的 Z。然后我们拿着这个带有自注意力信息的 Z 进行后续的操作。Z 向量中的每个元素 z_i 都与 X 的所有元素有某种关联,而不是只与 x_i 有关联。
如何计算 Q、K、V
Q、K、V 全部来自输入 X 的线性变换:
WQ、WK、WV 以随机初始化开始,经过训练就会得到非常好的表现。对于 X 中的每一个词向量 xi ,经过这个变换后得到:
注意力函数:如何通过 Q、V 得到 Z
基于上面的启发,我们认为 X 经过自注意力的挖掘后,得到了:
暗含信息 1:一组 query 与一组 key 之间的关联,记作 qk(想一下信息检索系统要用 query 找到 key)
暗含信息 2:一组 value
暗含信息 3:qk 与 value 的某种关联
这三组信息,分别如何表示?
Transformer 的作者认为 Q 和 K 两个向量之间的关联,是我们在用 Q 找其在 K 上的投影,如果 Q 、 K 是单位长度的向量,那么这个投影其实可以理解为找Q 和 K 向量之间的相似度:
如果 Q 和 K 垂直,那么两个向量正交,其点积(Dot Product)为 0;
如果 Q 和 K 平行,那么两个向量点积为两者模积 ∥Q∥∥K∥ ;
如果 Q 和 K 呈某个夹角,则点积就是 Q 在 K 上的投影的模。
因此「暗含信息 1」就可以用「 Q⋅K 」再经过 Softmax 归一化来表示。这个表示,是一个所有元素都是 0~1 的矩阵,可以理解成对应注意力机制里的「注意力分数」,也就是一个「注意力分数矩阵(Attention Score Matrix)」。
而「暗含信息 2」则是输入 X 经过的线性变换后的特征,看做 X 的另一种表示。然后我们用这个「注意力分数矩阵」来加持一下 V ,这个点积过程就表示了「暗含信息 3」了。所以我们有了如下公式:
有时候,为了避免因为向量维度过大,导致Q⋅KT 点积结果过大,再加一步处理
这里 dk 是 K 矩阵中向量 ki 的维度。这一步修正还有进一步的解释,即如果经过 Softmax 归一化后模型稳定性存在问题。怎么理解?如果假设 Q 和 K 中的每个向量的每一维数据都具有零均值、单位方差,这样输入数据是具有稳定性的,那么如何让「暗含信息 1」计算后仍然具有稳定性呢?即运算结果依然保持零均值、单位方差,就是除以「 dk 」。
其他注意力函数
为了提醒大家这种暗含信息的表示,都只是计算方法上的一种选择,好坏全靠结果评定,所以包括上面的在内,常见的注意力函数有(也可以自己定义):
到这里,我们就从原始的输入 X 得到了一个包含自注意力信息的 Z 了,后续就可以用 Z 了。
2、多头注意力
我们已经理解了自注意力,而 Transformer 这篇论文通过添加「多头」注意力的机制进一步提升了注意力层。《The Illustrated Transformer》
Transformer 中用了 8 个头,也就是 8 组不同的 Q-K-V:
这样就能得到 8 个 Z:
然后把 Z0 到 Z7 沿着行数不变的方向全部连接起来,如下图所示:
我们再训练一个权重矩阵 WO ,然后用上面拼接的 Z0−7 乘以这个权重矩阵:
会得到一个 Z 矩阵:
这就是多头注意力机制,与单头注意力相比,都是为了得到一个 Z 矩阵,但是多头用了多组 Q-K-V,然后经过拼接、乘以权重矩阵得到最后的 Z。总览一下整个过程:
通过多头注意力,每个头都会关注到不同的信息,可以如下类似表示:
这通过两种方式提高了注意力层的性能:
多头注意力机制,扩展了模型关注不同位置的能力。 Z 矩阵中的每个向量 zi 包含了与 X 中所有向量 xi 有关的一点编码信息。反过来说,不要认为 zi 只与 xi 有关。
多头注意力机制,为注意力层提供了多个「表示子空间 Q-K-V」,以及 Z。这样一个输入矩阵 X ,就会被表示成 8 种不同的矩阵 Z,都包含了原始数据信息的某种解读暗含其中。
3、退化现象、残差网络与 Short-Cut
3.1、退化现象
对于一个 56 层的神经网路,我们很自然地会觉得应该比 20 层的神经网络的效果要好,比如说从误差率(error)的量化角度看。但何凯明等人的论文《Deep Residual Learning for Image Recognition》中呈现了相反的结果,而这个问题的原因并不是因为层数多带来的梯度爆炸/梯度消失(毕竟已经用了归一化解决了这个问题),而是因为一种反常的现象,这种现象称之为「退化现象」。何凯明等人认为这是因为存在「难以优化好的网络层」。
3.2、恒等映射
多出来的 36 个网络层,如果对于提升性能(例如误差率)毫无影响,甚至更进一步,这 36 层前的输入数据,和经过这 36 层后的输出数据,完全相同,那么如果将这 36 层抽象成一个函数 f36 ,这就是一个恒等映射的函数:
到实际应用中。如果我们对于一个神经网络中的连续 N 层是提升性能,还是降低性能,是未知的,那么则可以建立一个跳过这些层的连接实现:如果这 N 层可以提升性能,则采用这 N 层;否则就跳过。这就像给了这 N 层神经网络一个试错的空间,待我们确认它们的性能后再决定是否采用它们。同时也可以理解成,这些层可以去单独优化,如果性能提升,则不被跳过。
3.3、残差网络(Residual Network)与捷径(Short-Cut)
如果前面 20 层已经可以实现 99% 的准确率,那么引入了这 36 层能否再提升「残差剩余那 1%」的准确率从而达到 100% 呢?所以这 36 层的网络,就被称为残差网络(Residual Network,常简称为 ResNet)。
而那个可以跳过 N 层残差网络的捷径,则常被称为 Short-Cut,也会被叫做跳跃链接(Skip Conntection),这就解决了深度学习中的「退化现象」。
4、Transformer 的位置编码(Positional Embedding)
注意力机制忽略了位置信息。比如 Tigers love rabbits 和 Rabbits love tigers 会产生一样的注意力分数。
4.1、Transformer 论文中的三角式位置编码(Sinusoidal Positional Encoding)
现在来解决这个问题,为每一个输入向量 xi 生成一个位置编码向量 ti ,这个位置编码向量的维度,与输入向量(词的嵌入式向量表示)的维度是相同的:
Transformer 论文中给出了如下的公式,来计算位置编码向量的每一位的值:
这样对于一个 embedding,如果它在输入内容中的位置是 pos,那么其编码向量就表示为:
延展开的话,位置编码还分为绝对位置编码(Absolute Positional Encoding)、相对位置编码(Relative Positional Encoding)。前者是专门生成位置编码,并想办法融入到输入中,上面看到的就是一种。后者是微调 Attention 结构,使得它可以分辨不同位置的数据。另外其实还有一些无法分类到这两种的位置编码方法。
4.2、绝对位置编码
绝对位置编码,如上面提到的,就是定义一个位置编码向量 ti ,通过 xi+ti 就得到了一个含有位置信息的向量。
习得式位置编码(Learned Positional Encoding):将位置编码当做训练参数,生成一个「最大长度 x 编码维度」的位置编码矩阵,随着训练进行更新。目前 Google BERT、OpenAI GPT 模型都是用的这种位置编码。缺点是「外推性」差,如果文本长度超过之前训练时用的「最大长度」则无法处理。目前有一些给出优化方案的论文,比如「层次分解位置编码」。
三角式位置编码(Sinusoidal Positional Encodign):上面提过了。
循环式位置编码(Recurrent Positional Encoding):通过一个 RNN 再接一个 Transformer,那么 RNN 暗含的「顺序」就导致不再需要额外编码了。但这样牺牲了并行性,毕竟 RNN 的两大缺点之一就有这个。
相乘式位置编码(Product Positional Encoding):用「 xi⊙ti 」代替「xi+ti 」。
4.3、相对位置编码和其他位置编码
最早来自于 Google 的论文《Self-Attention with Relative Position Representations》相对位置编码,考虑的是当前 position 与被 attention 的 position 之前的相对位置。
常见相对位置编码:经典式、XLNET 式、T5 式、DeBERTa 式等。
其他位置编码:CNN 式、复数式、融合式等。
5、 Transformer 的编码器 Encoder 和解码器 Decoder
5.1、Encoder 和 Decoder 的图示结构
一个 Encoder 可以用如下的示意图表示:
第一层是多头注意力层(Multi-Head Attention Layer)。
第二层是经过一个前馈神经网络(Feed Forward Neural Network,简称 FFNN)。
这两层,每一层都有「Add & Normalization」和 ResNet。
解码器有两个多头注意力层。第一个多头注意力层是 Masked Multi-Head Attention 层,即在自注意力计算的过程中只有前面位置上的内容。第二个多头注意力层买有被 Masked,是个正常多头注意力层。
可以看出来,第一个注意力层是一个自注意力层(Self Attention Layer),第二个是 Encoder-Decoder Attention 层(它的 K、V 来自 Encoder,Q 来自自注意力层),有些文章里会用这个角度来指代。
FNN、Add & Norm、ResNet 都与 Encoder 类似。
5.2、Decoder 的第一个输出结果
产出第一个最终输出结果的过程:
不需要经过 Masked Multi-Head Attention Layer(自注意力层)。
只经过 Encoder-Decoder Attention Layer。
这样就像 Encoder-Decoder Attention 模型一样,得到第一个输出。但是最终的输出结果,还会经过一层「Linear + Softmax」。
5.3、Decoder 后续的所有输出
从产出第二个输出结果开始:
Decoder 的自注意力层,会用到前面的输出结果。
可以看到,这是一个串行过程。
5.4、Decoder 之后的 Linear 和 Softmax
经过所有 Decoder 之后,我们得到了一大堆浮点数的结果。最后的 Linear & Softmax 就是来解决「怎么把它变成文本」的问题的。
Linear 是一个全连接神经网络,把 Decoders 输出的结果投影到一个超大的向量上,我们称之为 logits 向量。
如果我们的输出词汇表有 1 万个词,那么 logits 向量的每一个维度就有 1 万个单元,每个单元都对应输出词汇表的一个词的概率。
Softmax 将 logits 向量中的每一个维度都做归一化,这样每个维度都能从 1 万个单元对应的词概率中选出最大的,对应的词汇表里的词,就是输出词。最终得到一个输出字符串。
6、Transformer 模型整体
整体看一下 Transformer:
首先输入数据生成词的嵌入式向量表示(Embedding),生成位置编码(Positional Encoding,简称 PE)。
进入 Encoders 部分。先进入多头注意力层(Multi-Head Attention),是自注意力处理,然后进入全连接层(又叫前馈神经网络层),每层都有 ResNet、Add & Norm。
每一个 Encoder 的输入,都来自前一个 Encoder 的输出,但是第一个 Encoder 的输入就是 Embedding + PE。
进入 Decoders 部分。先进入第一个多头注意力层(是 Masked 自注意力层),再进入第二个多头注意力层(是 Encoder-Decoder 注意力层),每层都有 ResNet、Add & Norm。
每一个 Decoder 都有两部分输入。
Decoder 的第一层(Maksed 多头自注意力层)的输入,都来自前一个 Decoder 的输出,但是第一个 Decoder 是不经过第一层的(因为经过算出来也是 0)。
Decoder 的第二层(Encoder-Decoder 注意力层)的输入,Q 都来自该 Decoder 的第一层,且每个 Decoder 的这一层的 K、V 都是一样的,均来自最后一个 Encoder。
最后经过 Linear、Softmax 归一化。