背景

Attention is all you need 来自 Google,发布于2017,可能比我们认为的时间都早。这篇奠基大模型时代的经典论文当初并没有想到会带来如此大的影响。 如今的所有大语言模型基本都是基于 Transformer 架构,在 AI 逐步普及的今天,对于这篇论文的深入学习不论是作为深度学习入门,还是增强对 AI 的理解 或者 对于 AI 应用开发都是非常有价值的。 本文会从一个Beginner视角出发,串联整个 Transformer 架构,希望能够对 Transfomer 有一个全面且相对深入的理解。

前置知识

多层神经网络

神经网络,最初也是受到生物神经元的启发,使用单个神经元节点来模拟神经信号的传递与激活。 最初的神经网络只有一层,其输入到输出仅包含线性组合,输入(x1, x2, …)→ 加权求和(w1x1 + w2x2 + … + b)→ 输出(y)。然后再叠加上一个阶跃函数作为激活函数(输入和与阈值进行比较,如果大于阈值则输出 1,否则输出 0),早期就是用这样的方式处理类似“苹果”、“香蕉”的二分类问题。

单层神经网络

但是,它的缺陷太明显了,无法处理非线性问题,基本的“异或”问题都无法通过单层神经网络解决。后来,到了上世纪60-80年代,多层感知机被提出,通过引入“隐藏层”(中间层),形成多层结构,同时引入非线性激活函数(常用sigmoid、tanh、ReLU等)可突破线性的限制,可逼近任意连续函数(即 “万能近似定理”)。

mlp

激活函数

上面说到最初的神经网络无法解决非线性问题,那么如何引入非线性性呢?即使对于多层神经网络而言,MLP 的每一层如果仅包含线性变换(如 y = Wx + b),则无论堆叠多少层,整个网络仍等价于单层线性模型。如果要真正地打破模型线性的限制,需要给中间层引入“激活函数”。常用的激活函数有:S 函数,ReLU,Tanh 等。激活函数的作用就是在线性结果之后叠加一个非线性函数。

损失函数 与 反向传播

构建完神经网络之后,模型是如何通过学习不断调整节点参数的呢?这里整体的方案就是,先(随机)初始化模型的参数,通过输入的数据计算出模型输出的结果,然后构造一种计算方法(损失函数)用来计算模型输出结果和“正确答案”之间的距离。 然后通过梯度下降算法,将误差反向传播回来,对模型参数进行修正。具体来讲,想象你站在一座山上,想要最快走到山底。你该怎么行动?

  • 首先,你需要知道 “哪个方向是下坡”(梯度的反方向,导数,偏导数);
  • 然后,你需要决定 “每一步走多大”(学习率);
  • 重复这两个动作,直到走到山底(或足够接近山底)。

梯度下降的逻辑和 “下山” 完全一致:沿着函数梯度的反方向,逐步调整参数,最终逼近函数最小值(即误差最小)。 具体步骤上,先跑出网络的结果;定义损失函数同时算出结果和正确答案中间的“损失”;然后沿着梯度的方向将损失反向传播回来,更新节点参数。

Transformer

言归正传。论文链接 https://arxiv.org/abs/1706.03762,Transformer 的整体架构:

transformer_overall

不同大模型的分支:

llm_branches

编码器

前面说过 Transformer 最初的设计最初是为了翻译任务提出的。当今的 LLM 基本都是从这个架构而来,但不是照搬,做了一些变化。但核心架构都是一样的。 本文为了方便理解,我们假设有一个翻译任务,需要将“这是一个苹果”翻译成“This is an apple”:

“这是一个苹果” ---->>>> "This is an apple"

我们拿到原始语句,结合回架构图,“这是一个苹果”对应模型编码器的输入端,输入时,第一件事是需要将这句话进行”Input Embedding”

Input Embedding

Input Embedding 做的事情是把我们原始的 token 投射到一个高维空间。我们这里假设“这、是、一、个、苹、果”代表了 6 个 token(真实大模型中 token 并不一定和汉字、单词、标点等一一对应,这里为了表达方便)。 投射之后,我们任务中的 Input 会变成:

input_embedding

为什么要这样做?

  1. 首先计算机是不认识汉字(或者英文单词)的,要输入模型进行训练,我们需要将所有的 token 进行编码,这些码必须是数字,最直接的方式是将所有的字符进行编码,例如,这 - 876, 是 - 1987,一 - 776 等;但是他们携带的信息过于有限,也完全无法表示不同 token 之间语意联系;
  2. 很多在低维空间难以解决的问题,往往可以通过升维,在高维空间轻易找到问题的解;

例如: 在一个低维平面难以分类的数据,在高维空间的投射下可能很容易找到一个超平面将数据分隔开:

low_dimension

high_dimension

对于训练好的词嵌入矩阵,我们有一些经典的案例:

vector("king") - vector("man") + vector("woman") ≈ vector("queen")
vector("Germany") - vector("Berlin") + vector("France") ≈ vector("Paris")  
vector("Germany") - vector("Hitler") + vector("Italy") ≈ vector("Mussolini")

embedding_vector

注意力机制

encoder

经过第一步的 Input Embedding,我们已经将输入投射到了高维空间。但是对于训练好的词嵌入矩阵而言,相同的token 所得到的向量始终是静态的。也就是说,它不包含上下文信息。但是上下文对于 token 的实际影响是巨大的,例如:

“美女,方便加一个微信吗?”
“这位美女,麻烦让一让。”

同样一个“美女”,两句话中的含义却大不相同。第一句话中的“美女”大概率是表示“颜值”维度的意思,而第二句话则更多是“性别”维度的表述了。这些含义的“修正”就来自上下文。 另外还需要补充一点,注意力机制并不是 transformer 的独创,自注意力,多头注意力才是这篇论文中提出的。(注意力机制最初论文”Neural Machine Translation by Jointly Learning to Align and Translate”中提出的,用于改进序列到序列(seq2seq)模型在机器翻译任务中的表现。)

自注意力机制

那么什么是“自注意力机制”呢? 我们看上图,输入的语句“这是一个苹果”,经过 Input Embedding 以及 位置编码后,这时这句话已经变成了 高维空间 中,包含了位置信息的一个矩阵。我们假设 Input Embedding 的维度是 512 维,那么这句话就被编码成了一个 6*512 的矩阵。 接下来,我们看到,这个矩阵被 copy 成了 3 份,送入了「Multi-Head Attention」中,如下图所示:

scaled_dot_product_attention

这部分需要重点解释一下,它是 Transfomer 中注意力的核心。论文中把这里的运算称为 Scaled Dot-Product Attention,给出的公式如下:

\[\text{Attention}(Q, K, V) = \text{softmax}\left( \frac{QK^T}{\sqrt{d_k}} \right) V\]

要理解这个公式,我们先简单解释下 Q,K,V 是什么。先看一个比较经典的可视化的例子:

q_k_v_pic

Q Query 仿佛在向其他位置发出询问:你们谁是来修饰(变换)我的?
K Key 回答哪些位置会有信息供 Q 来查询;
V Value 具体修饰(变换)某个 token 的值;

以上面的图形为例,creature 发出 Query,你们哪些词对我有影响?这时候,fluffy 和 blue 会表示“自己”对 creature 有影响,最终的结果就是 creature 变成了 毛茸茸 + 绿色的 creature. 这是一个类比的例子,真实的注意力机制是人类无法直观理解的。 下面我们看下具体实现的细节: 首先,Q K V 如何获得?前面说过,输入「这是一个苹果」已经通过embedding 和 位置编码变成了 6*512 的矩阵,接下来将 copy 3 份送入了「Multi-Head Attention」中,这里的 Q,K,V 就是从这个 6*512 的矩阵变而来。

Wq, Wk, Wv 分别是三个 512*512 的矩阵,矩阵的参数正式我们训练将要学习的参数。分别和经过位置编码后的 Input 做矩阵乘法运算,得到三个新的 [6, 512] 的矩阵, Q, K, V。

接下来,论文中将 Q 和 K 的转置相乘,会得到一个 [6,6] 的方阵。

为了进一步理解 Q 和 K 的转置相乘的含义,我们举个查询的例子来看。假设我们有一个存储着键和值的表:

Key-Value Map:
{
    "key_1": "1",
    "key_2": "2",
    "key_3": "3"
    "key_4": "4",
    "key_5": "5"
}

如果我们要获取某一维特征的具体值,我们可以发出一个 Query 查询,例如 帮我查询下第 3 维特征的具体数值是多少。然后我们可以从上面的 Map 中查询到 “key_3” 对应的 value 为 3。 但是,在潜空间中,我们的查询往往没有那么直观,如果我们想要查询 “key_pi” 的值,应该返回多少呢?这里我们使用到了向量相似度的概念,具体的做法就是,我们逐个计算 Q Vector 和 不同维度 K Vection 的相似度,然后和对应的 Value 相乘,之后加权求和得到最终的查询结果。 假设我们计算得到 key_pi 和 key_1~key_5 的相似度分别为:

特征维度 相似度 Softmax
key_1 5 0.789
key_2 -4 0.0001
key_3 3 0.1068
key_4 2 0.0393
key_5 2.5 0.0648

那么 feature_pi 的最终查询结果就为:

Q(key_pi) = 0.789 * 1 + 0.0001*2 + 0.1068*3 + 0.0393*4 + 0.0648*5

这里的相似度,使用的就是余弦相似度的概念(向量点积),也就是 Q 和 K 的转置相乘。 Q 和 K转置相乘表示的含义,按行看,就是

[token1查询 和 token1Key的相似度, token1查询 和 token2Key的相似度, ..., token1查询 和 token6Key的相似度]
[token2查询 和 token2Key的相似度, token2查询 和 token2Key的相似度, ..., token2查询 和 token6Key的相似度]
[token3查询 和 token3Key的相似度, token3查询 和 token2Key的相似度, ..., token3查询 和 token6Key的相似度]
[token4查询 和 token4Key的相似度, token4查询 和 token2Key的相似度, ..., token4查询 和 token6Key的相似度]
[token5查询 和 token5Key的相似度, token5查询 和 token2Key的相似度, ..., token5查询 和 token6Key的相似度]
[token6查询 和 token6Key的相似度, token6查询 和 token2Key的相似度, ..., token6查询 和 token6Key的相似度]

然后对方阵的每一项除以 √dₖ 进行放缩,最后按行进行 softmax 计算。此时的结果还是一个 [6,6] 的方阵,然后和 V ([6*512])进行矩阵相乘,得到最终「融合token」(如蓝色毛茸茸的creature)。得到上面的相似度矩阵之后,再逐个和 V 矩阵相乘便得到了最终融合上下文信息的 token 矩阵编码。

Softmax 和 √dₖ

说完自注意力机制的框架,我们看下公式中的细节,除了矩阵运算之外,公式中还有 softmax 以及 √dₖ 没有解释,这里提一下。 softmax 的计算公式为:

\[\text{softmax}(z)_i = \frac{e^{z_i}}{\sum_{j=1}^{K} e^{z_j}}\]

其中:

  • $z = (z_1, z_2, \dots, z_K)$ 是输入向量(长度为 $K$)
  • $\text{softmax}(z)_i$ 表示输出向量中第 $i$ 个元素的概率值
  • $e^{z_i}$ 是自然指数函数
  • 分母为所有输入元素的指数之和,确保输出值之和为 1(满足概率分布性质)

它的作用是把一组向量投射到 (0,1) 区间,同时使投射后所有结果值的和为 1,通常用于概率分布映射,上文在计算查询时,将所有和 key_pi 的相似度(key_1 到 key_5)进行 softmax 之后,相似度结果都在 0-1 区间且和为 1, 恰好用于权重系数的计算。同时在逻辑上,将原始的相似度分数(可能为任意实数)转化为总和为 1 的概率分布,让权重具有明确的 “关注比例” 含义,同时放大高相似度分数的权重、抑制低分数权重(形成 “聚焦” 效果)。举个例子来说明问题,随手写一个序列 [3,5,-10,20] 求 softmax 之后结果为(可以看出20基本占了绝对主导地位):

softmax_eg

这里顺便说一句模型的“温度(temperature)”参数,是和 softmax 息息相关的:

\[\text{softmax}_T(z)_i = \frac{e^{z_i / T}}{\sum_{j=1}^{K} e^{z_j / T}}\]

其中:

  • $z = (z_1, z_2, \dots, z_K)$ 是输入向量
  • $T$ 为温度参数($T > 0$)
  • 当 $T = 1$ 时,退化为标准 softmax 函数
  • 当 $T \to 0^+$ 时,输出趋近于“one-hot”分布(概率集中在最大 $z_i$ 对应的位置)
  • 当 $T \to +\infty$ 时,输出趋近于均匀分布(各元素概率接近相等)

当 T 较大时,会显著摊平输出之间的差异(方差减小),还是以 [3,5,-10,20] 为例,当 T = 10 时,输出结果为 [0.1255, 0.1533, 0.0342, 0.6870],这样结果的随机性就大大提高了。

接下来说回√dₖ,为什么在计算完 Q K 相似度,在 softmax 之前,要进行一个 √dₖ 的缩放? 在深度学习参数初始化时,我们通常会假定 Q K 矩阵每个 token 维度下的向量 [1, 512] 都是均值为 0, Var(方差) 为 1 的序列。在进行了 Q 和 K转置 相乘的操作之后,结果的每一项计算如下:

\[(QK^T)_{i,j} = \sum_{t=1}^{d_k} Q_{i,t} \cdot K_{j,t}\]

其中:

  • $d_k$ 为向量维度(如 512)
  • $Q_{i,t}$ 表示 $Q$ 矩阵中第 $i$ 个 token 第 $t$ 维的元素
  • $K_{j,t}$ 表示 $K$ 矩阵中第 $j$ 个 token 第 $t$ 维的元素

其中 $Q_{i,t}$ $K_{j,t}$ 是相互独立的 均值为0,Var 为 1 的随机变量,则 $Q_{i,t}$ * $K_{j,t}$ 也是均值为0,方差为 1 的随机变量。而 $Q$ 和 $K$ 转置相乘 结果的每一项都是 $d_{k}$ 个均值为0,方差为 1 的随机变量之和。则其方差为 $d_{k}$,标准差为√dₖ。这里除以 √dₖ 是为了将序列再次缩放回一个 均值为0,方差为1 的序列上。 这样做的好处是避免点积结果的数值过大,导致 softmax 函数进入梯度饱和区域,从而影响模型训练,对于模型有效训练十分关键。这里还是具体看一下:

设输出的概率分布为:

\[(QK^T)_{i,j} = \sum_{t=1}^{d_k} Q_{i,t} \cdot K_{j,t}\]

其中

\[\hat{y}_i = \text{Softmax}(z)_i\]

使用交叉熵损失,得到 Loss 为:

\[L = -\sum_{i=1}^{C} y_i \log(\hat{y}_i)\]

正确答案 $y$ 是一个 one-hot 编码,即正确的分类结果为 1,其他均为0,于是,可以将上式化简为:

\[L = -\log(\hat{y}_k)\]

损失函数使用链式法则,逐级对输入 z 求偏导(具体计算省略)得到每个方向的梯度:

\[\frac{\partial L}{\partial z_i} = \hat{y}_i - y_i\]

则,关于参数 $b$

\[\frac{\partial L}{\partial b_i} = \hat{y}_i - y_i\]

关于参数 $W$

\[\frac{\partial L}{\partial W_{ik}} = \left( \hat{y}_i - y_i \right) \cdot x_k\]

这里,如果 $z_{i}$ 分布过于离散(在没有除以 √dₖ 之前),由于指数函数的放大效应,很容易导致“一家独大”,也就是某一项的 $y_{i}$ 约为1,其余项都为0。参考上面 Softmax 计算的例子。

这样的话,我们代入上式,不论对于预测正确,还是错误,都会得到大量的趋于 0 的梯度,导致训练困难。

多头注意力机制

说完了自注意力,接下来看多头注意力。我们先来看多头注意力是如何计算的,然后再来看为什么要做这样的设计。

先看原文中多头注意力的表示:

\[\text{MultiHead}(Q, K, V) = \text{Concat}(\text{head}_1, \dots, \text{head}_h) W^O\] \[\text{where } \text{head}_i = \text{Attention}(QW_i^Q, KW_i^K, VW_i^V)\]

看起来比较复杂,其实很简单,假设我们把 512 维向量拆分为 4 个头,那么只是把输入切成 4 个 [1, 128] 的向量,各自进行上面的自注意力机制计算。然后把得到的 4 个矩阵,按照拆分的方式,再拼接回来即可。

我们可以看到,多头拆分的方式是按照特征维度拆分的,这样做的好处就是能够让各个小的模块能够更加聚焦当前维度的特征学习。就好比一个人管理 512 人的大班级很容易导致失焦,但如果将班级划分为 4 个小组,每个小组内部管理,这样效果就会好很多。我们可以分为几个角度来看:

  1. 如果只用一个“头”(Single-Head Attention),它在训练初期可能只会关注到一种最明显、但不一定最优的关系(比如,只关注相邻的词)。如果这个方向错了,模型可能会陷入一个局部最优解,很难再学习到其他更复杂的模式。而多个“头”的好处可以让他们分别探索,即使某个头陷入了困境,其他头依然能提供有用的梯度信号,拉着整个模型向正确的方向前进,这使得训练更加稳健。

  2. 在高维空间中(比如一个 512 维的词嵌入,GPT3 12288维),单个注意力机制的计算(尤其是点积)可能会产生非常大或非常小的值,导致梯度在 Softmax 后变得不稳定(梯度消失或爆炸,上面已经提到),让训练难以收敛。多头机制下,每个头只在自己的 64(假设切8个,和文中保持一致) 维低维空间里进行计算。在低维空间中,点积的结果更不容易出现极端值,Softmax 的计算也更稳定。

  3. 多个头各自探索也能进一步提升泛化能力。例如论文 Analyzing Multi-Head Self-Attention 中的实验表明多头注意力的头间平均相似度显著低于单头 “拆分后” 的相似度(若将单头强行拆分为 h 个子空间,其相似度会相对高很多),说明多头确实捕捉了更多样的子空间模式。

  4. 此外,对于训练速度而言,拆分并行的子任务,能够充分利用多核并行硬件的能力,提升训练速度。

Add & Norm

仔细观察论文给出的结构,我们会发现,最初经过 embedding 和 位置编码的矩阵,不仅被复制了 3 份,还有一份从侧面进入了 「Add & Norm」中,它在每一个 sub-layer 之后,进行了 残差网路 和 层归一化操作。

残差连接

残差网络在当今深度学习中极为常见,由何恺明等人在 2015 年的论文 “Deep Residual Learning for Image Recognition”中提出,首次在 ImageNet 图像分类任务中证明了其有效性,并因此获得了 CVPR 2016 最佳论文奖。也是 Attention is All you need 的参考文献[11]。其核心价值在于解决了深度神经网络的训练难题,使得模型能够既深又强。

它的数学表示非常简单,只是把原始输入叠加回网络输出:

\[\text{output} = \text{LayerNorm}(x + \text{SubLayer}(x))\]

层归一化

层归一化是针对某个 token 的所有特征维度进行归一化。为了避免发散,这里就不去对比 batchNorm 等概念了,我们只需要知道这里归一化之后,所有的 token 在特征维度上会表示为一个 均值为0,方差为 1 的向量。计算过程可以表示为:(D 是特征维度,这里针对某个 token,内部对不同维度特征进行归一化处理)

均值:

\[\mu_{b,l} = \frac{1}{D} \sum_{i=1}^{D} x_{b,l,i}\]

方差:

\[\sigma_{b,l}^2 = \frac{1}{D} \sum_{i=1}^{D} \left( x_{b,l,i} - \mu_{b,l} \right)^2\]

归一化值:

\[\hat{x}_{b,l,i} = \frac{x_{b,l,i} - \mu_{b,l}}{\sqrt{\sigma_{b,l}^2 + \epsilon}}\]

到这里完成了逐层的基础归一化,之后,还会有个缩放与平移操作:

\[\text{LayerNorm}(x)_{b,l,i} = \gamma_i \cdot \hat{x}_{b,l,i} + \beta_i\]

上式中的 gamma i 和 beta i 分别是针对某个特征维度的,这里的作用,会把第 i 维特征的方差 和 均值进行修正。gamma i 会把第 i 维特征的方差放缩为原有的 gamma i 的平方倍,而 beta i 是一个平移操作,会直接在原有均值上叠加 beta i。为什么要这样做呢?

LayerNorm 通过减去均值、除以标准差来标准化输入,这会将数据分布强制拉到均值为 0、方差为 1 的标准正态分布。但这种标准化可能会 “洗掉” 一些对模型有用的特征分布信息(例如,某些层可能天然具有不同的方差,而这些方差差异本身可能携带了重要信息)。这时就允许使用 $\gamma$ 和 $\beta$ 对不同层进行一个修正,使其恢复模型的表达。

话说回来,这里虽然过程看起来有些复杂,但如前面所说,这里的层归一化是极为常见的一个操作,在 python 中实现也早已标准化,十分简单:

# PyTorch实现import torch
import torch.nn as nn

# 创建输入张量 [batch_size, sequence_length, hidden_size]
x = torch.randn(32, 10, 512)# 使用LayerNorm
layer_norm = nn.LayerNorm(normalized_shape=512)  # 对最后一维进行归一化
output = layer_norm(x)

Feed Forward MLP

feedforward_mlp

在 Transformer 的整体结构中,我们可以看到每个 identical layer 中有 2 个 sub-layers,第二个就是 Feed Forward,这部分没有 Self-Attention 的鼎鼎大名,但它非常重要,按照原文,这里具体的运算如下:

\[\text{FFN}(x) = \max(0, xW_1 + b_1)W_2 + b_2\]

relu

论文中提到,ffn 的中间层是一个拥有 2048 个节点的网络层,然后经过 ReLU 激活函数之后,再投射回 512 维矩阵。整体链路表示如下:

relu_trans

这里具体做了什么,我们可以参考一个经典的数字识别的例子:

digit_recognize

这里输入是一个 784 维的向量,表示[28*28]的像素点,第一层的隐藏层将向量升维,我们可以近似理解为 从更加理论一些的角度来分析,这样做的原因差不多可以分为下面几个层面:

  1. 升维到 2048:将输入从 512 维扩展到更高维度,使得模型能够学习更复杂的特征组合。例如,在低维空间中难以区分的特征,在高维空间中可能变得可分离(这个我们前面提到过,推荐视频);

  2. ReLU 激活函数:在 2048 维空间中应用 ReLU 激活,能够筛选出对任务更重要的特征方向。这种非线性变换是模型捕捉复杂语义关系的关键,注意力层可以认为都是线性变换(除了Softmax),没有激活函数,无法表征非线性关系,这里引入 ReLU 激活函数,也是引入模型的非线性性,这是十分关键的一点。

  3. 从 2048 再降维回 512 可以去除冗余信息,保留最相关的特征,同时确保输出维度与输入一致,便于后续的处理。

  4. 最后,还有一个非常关键的因素,在 FFN 中存储了模型最大一部分的参数(约为总数量的2/3,GPT 1700亿参数为例,FFN中占有约1200亿),这部分为模型提供了足够的“容量”来存储和记忆 facts。

举个例子来说,Self-Attention 部分可以认为在一个会议上,我们整合了所有人的发言;FFN 部分则是针对这些发言进行独立、深入的思考消化,同时形成独立见解。是推理能力十分重要的一环。

我们再具体看一下变化过程:

mlp_trans

解码器

Masked Multi-Head Attention

我们在训练阶段,会将 input 和 output 同时输入模型,经过运算得到损失函数的值后,通过梯度下降逐步修正参数进行学习。 但是,这里有个问题,我们真实的推理阶段,token 是逐个生成的,那么也就是说,当前 token 之后的信息对于当下及以前的 token 是不可见的。那么在训练阶段,我们就需要一种机制,将一段文本序列当前 token 后面的所有信息“遮挡”起来。在编码自注意力的时候也要特别注意这一点,于是,这里对于解码器而言(训练阶段),自注意力矩阵需要有相应的变化。

对于具体操作而言,这里是在对 QK转置 的结果进行逐行 Softmax 之前,我们手动将对角线右侧的数据置为 -∞,这样在进行逐行 Softmax 之后,对角线右侧的数据将会变成一个极小的趋近于零的值(不知道为啥的可以看 softmax 的函数表达式)。这样,在输入第一个 token 的时候,我们只能得到 token1 和自己的关系值,后面的都为0;在输入第二个 token 的时候,我们能得到 token2 和 token1 的关系以及 token2 和 token2 的关系,后面的都为0。以此类推。

但这里我们可能还有一个疑问,上面的处理(对 QK转置 的结果,手动将对角线右侧的数据置为 -∞)会不会泄题?也就是说在获取 Q K 矩阵的时候,不同 token 之间是否已经“勾兑”了?这是一个非常好的问题,我们回看具体运算的过程,其实可以比较容易得出答案。对于输入的 6 个token,我们在计算 Q 和 K转置相乘之后,得到了 [6 * 6] 的方阵。我们回顾一下这个方阵是如何计算出来的。

我们可以看到在计算前面 token 相互关系的时候(灰色),后面的 token 是不参与的,各个 token 之间的点积是各自独立完成的。然后在计算最终“勾兑” 结果的时候,后续 token 对前面 token 的影响统一都设置为 0 了,完美遮住了答案。

交叉注意力

在编码器 和 解码器结合的位置,我们看到了一个相对特殊的连接:

cross_attention

如果理解了前面的自注意力机制,会发现这里并没有什么特别,从输入看,Q 来自 output,K V 来自 input。从逻辑上是 output 的每一个 token 向 input 序列发出查询(“你们每个token对我有影响吗?具体影响是什么?”)。 我们在处理自注意力的时候,是针对同一个输入序列进行操作,Q K V 也都是 d_model 维度的方阵(讲解用,实际会分多头),但这里 input 和 output 的长度显然是不相等的,注意力的计算会出问题吗?

假设输入是一个序列长度是 m,当前输出是序列长度是 n,还是按照上面的计算方法,我们可以得到:

\[Q \Rightarrow [n \times d_{\text{model}}]\] \[K \Rightarrow [m \times d_{\text{model}}]\] \[QK^T \Rightarrow [n \times d_{\text{model}}][d_{\text{model}} \times m] \Rightarrow [n \times m]\] \[V \Rightarrow [m \times d_{\text{model}}]\]

从上面可以看出,矩阵运算是可以完美匹配的。QK^T 得到的[n * m]那表示输出序列中每一个 token 和输入序列中每一个 token 之间的关联程度。在和 V 相乘后,结果依然是 [n * d_model],表示被输入编码(影响)后的输出。另外提一句,在类似 GPT 这样的自回归语言模型中,不会使用交叉注意力模式。

位置编码

最后我们聊一下位置编码,这是我们前面一直忽略的问题。看整体的架构图,在 Input 和 Output 的最开始都会进行位置编码,这里编码的设计还是挺有启发性的。

位置(顺序)对于语言的重要性无需多言,很多时候顺序会带来语义理解上的致命问题,例如「我是你老板」和「你是我老板」5个完全相同的字,简单调换了 2 个字的顺序,在语义上就天壤之别了。

针对原始的自注意力机制而言,我们通过 Q K转置 向量余弦相似度来计算权重,这里面是没有携带关于位置的任何信息,还是上面的例子,「你是我老板」中,不论是否交换“我”和“你”的位置,对于“老板”这个词而言,乘出来的结果都一样。

理解了位置编码的重要性,我们来思考下,对于位置编码而言,天然有哪些需要关注的问题?

  1. 编码需要能体现相对位置关系;例如「这是一台苹果手机」,“手机”这个词对于“苹果”词义的影响很大,这个和「这是一台苹果手机」在一大段文本中的绝对位置无关,和短语内部 token 的相对位置强相关。这句话放在开头或者结尾,还是中间,苹果都大概率指 Apple Inc.

  2. 编码需要兼顾近距离,以及远距离的关联。这点非常重要,特别对于文本理解而言,非常常见。

  3. 编码对于不定长的文本要有“外推性”(extrapolation),推理时的文本长度很可能是和训练时不同的,因此需要位置编码的函数能天然做不同长度的适配。例如,我们训练的文本长度是 1000,实际推理时候长度就不一定了,因此编码的方案要天然能适配不同长度的文本。

带着以上的视角,我们再来看文中提供的位置编码公式:

\[PE_{(pos, 2i)} = \sin\left( \frac{pos}{10000^{2i/d_{\text{model}}}} \right)\] \[PE_{(pos, 2i+1)} = \cos\left( \frac{pos}{10000^{2i/d_{\text{model}}}} \right)\]

其中 $i$ 是特征维度,$pos$ 是 $seq$ 中 token 位置。

第一条,根据三角恒等式有:

\[\sin\left( \frac{pos + k}{10000^{2i/d_{\text{model}}}} \right) = \sin\left( \frac{pos}{10000^{2i/d_{\text{model}}}} \right) \cdot \cos\left( \frac{k}{10000^{2i/d_{\text{model}}}} \right) + \cos\left( \frac{pos}{10000^{2i/d_{\text{model}}}} \right) \cdot \sin\left( \frac{k}{10000^{2i/d_{\text{model}}}} \right)\]

因此,对于任意的位置编码 $pos$ 而言,相对距离为 $k$ 的位置编码 $pos+k$ 的值,仅和 $pos$ 本身的编码 以及 相对位置 $k$ 有关。余弦函数和正弦函数类似,这里不再赘述。

第二条,对于固定的 $pos$ 值,我们可以看到随着 $i$(特征)维度的增大,位置编码相对于 $pos$ 的函数,是一个周期不断变大的函数。这是一个很巧妙的特性,这样的设计能够让低维度特征天然感知相对较近距离文本的关系(周期小),而高维度特征天然可以感知超长距离文本的关联(周期大)。

pos_encode

第三条,第三条相对显而易见一些,位置编码不论是正弦函数还是余弦函数都是一个连续函数,这样对于任何 pos 都可以从上面的编码公式直接导出,且能够贯穿整个学习过程中的规律。

这里可以看到位置编码的设计的确非常巧妙,可以给我们很多启发。不过同时需要说明的是,位置编码也并非只有这一种,深度学习本质上是对某一个具体的问题场景进行合理的数学建模,然后在这个模式下,让海量数据驱动去学习其中的特征和规律。我们也可以尝试其他的位置编码方式,可能会让模型具有更好的上下文理解的推理能力。

总结

整个框架的流程已经梳理完了,希望这篇文章能帮大家推开 Transformer 的大门。接下来可以尝试用一些公开数据集跑一些简单的训练模型。同时结合实践操作进一步加深对深度学习的认识。