04_Transformer架构详解

1 概述

1.1 发展历程

Seq2Seq 虽然通过注意力机制在一定程度上增强了模型能力,但是由于整体结构还是依赖于 RNN 等序列模型,依然存在计算效率低、难以建模长距离依赖等结构性限制。

为了解决这些问题,Google 在 2017 年发表了一篇论文《Attention Is All You Need》,提出了 Transformer 架构,该模型完全抛弃了 RNN 等序列模型,通过注意力机制直接建模序列之间的依赖关系,并引入了多头注意力机制,使得模型具有更高的并行度,从而大大提高计算效率,也增强了模型对长距离依赖的建模能力。

1.2 核心思想

在 Seq2Seq 模型中,通过引入注意力机制,使得解码器可以在预测时,动态利用编码器的输出信息,缓解了将整个输入序列压缩为定长向量的信息瓶颈,从而提升了模型的性能。

通过思考不难发现,注意力不仅仅是信息提取的工具,它的本质是在每一个目标位置上,显式建模该位置与源序列中各位置之间的依赖关系。而 RNN 是通过隐状态的传递,从而隐式捕捉上下文信息,本质上作用也是建立序列中不同位置之间的依赖关系。

既然注意力机制和 RNN 结构的本质功能都是建模依赖关系,那么理论上它就有代替 RNN 的可能性。并且由于取消了循环结构,注意力机制没有顺序计算的要求,方便并行处理,解决了 RNN 的无法并行计算的痛点;又因为注意力机制可以在任意位置之间建立联系,能够更好地捕捉长距离依赖,解决了 RNN 长期依赖关系建模困难的问题。

因此,注意力机制不仅能够代替 RNN,并且在效果和效率上都要优于 RNN。

Transformer 模型摒弃了传统的循环结构,仅依靠注意力机制完成输入序列和输出序列中所有位置之间的依赖建模任务,这就是 Attention is All You Need 所体现的核心理念。

2 总体架构

Transformer 延续了编码器-解码器的设计,编码器负责对输入序列进行理解和表示,解码器负责根据编码器的输出逐步生成目标序列

Transformer 的编码器和解码器模块分别由多个结构相同(但权重不同)的层堆叠而成,通过层层堆叠,模型能够逐步提取更深层次的语义特征,从而增强对复杂语言现象的建模能力。原论文中包含 6 个编码器层和 6 个解码器层,当然完全可以尝试其他排列方式。

3 编码器

3.1 编码器概述

编码器用于理解输入序列的信息,并生成每个 token 的上下文表示,为解码器生成目标序列提供基础。

编码器由多个相同的编码器层(Encoder Layer)堆叠构成,每个编码器层的任务都是对输入序列进行上下文建模,使每个位置的表示都能融合整个序列的全局信息。而每个编码器层又有两个子层(Sublayer)构成,分别是:

  1. 多头自注意力 (Multi-Head Self-Attention):用于捕捉输入序列中不同位置之间的依赖关系。
  2. 前馈神经网络 (Position-wise Feed-Forward Networks):用于每个位置的表示进行非线性变换,生成新的表示,提升模型的表达能力。

3.2 自注意力子层

自注意力机制(Self-Attention)的核心作用是:在序列内部建立各 token 之间的依赖关系,为每个位置生成一个融合全局语义信息的向量表示。

之所以被称为自注意力,是因为计算时所参考的信息全部来自同一个输入序列本身,而不是来自另一个序列。

每个 token 都通过注意力机制“观察”句子中的所有 token,收集上下文信息,并更新之前对自己的词表示。并且由于没有顺序关系,这个过程是可以并行进行的。(注意是观察所有 token,包括自己,下图中的错误是没有表示出对词元自身的注意力)

RNN 必须读取整个句子才能理解词元在句子中的含义,而对于较长的句子需要相当长的时间。相比之下,Transformer 编码器中的词元可以同时相互观察,交换信息,并尝试在整个句子的上下文中更好地理解彼此。这个过程会在多个层中重复,以加深对自身语义的理解。

3.2.1 自注意力计算

自注意力的计算和之前注意力计算思想上类似的,额外区分了 Q, K, V,并且由于每个词计算注意力加权时把自己也包含在内,所以不需要再进行拼接。我们先看整体流程:

  1. 生成 Query, Key, Value 向量
  2. 使用缩放点积计算注意力得分
  3. 使用 softmax 归一化,得到注意力权重
  4. 根据注意力权重对所有 Value 加权求和,得到融合全局信息新表示

1. 生成 Query, Key, Value 向量

将输入序列中的每个 token 映射为 Query, Key, Value 三个不同的向量。

  • 查询(Query):用于发起注意力匹配的向量。通过观察其他 token,寻求信息以便更好地理解自身。
  • 键(Key):内容标识,用来响应 Query 请求,计算注意力权重。
  • 值(Value):内容信息,把更多的信息提供给需要它的 token(即那些给予该词较大权重的 token),用于加权求和计算注意力输出。

为什么区分 Q, K, V?

直接使用原始词向量表示 Q, K, V,又要学习如何查找相似词,又要学习如何被别的词向量查找,又要学习如何表示自身语义。单一词向量承载了太多任务,不利于学习;并且如果只有一种原始词向量,那么就无法使用多头去捕捉不同的语义特征。

Q, K, V 就是原始词向量通过不同权重的矩阵相乘映射得到的结果,将打包成矩阵的词嵌入与我们训练的权重矩阵相乘即可生成。

关于 Q, K, V 的理解:

  • Q: 为了理解自身而主动发出的搜寻请求。
    • 你在图书馆拿着一个写着主题的小纸条(Query),上面是当前的词元“苹果”,想要知道这里的苹果是水果还是公司,你需要带着这个疑问去观察周围其他的词,看看能不能找到线索。
  • K: 用于响应查询并进行匹配的索引。
    • 这是书架上每一本书脊上的标签(Key),拿着手里的纸条(Query)去和书架上的标签(Key)一一比对。
      • 如果标签是“科技公司”,匹配度(点积)就很高 -> 高注意力权重。
      • 如果标签是“母猪护理”,匹配度就很低 -> 低注意力权重。
  • V: 被提取的实质性内容或信息精华。
    • 这就是书里写的实际内容(Value),
      • 一旦通过标签(Key)确认了这本书很重要(权重高),你就会把书打开,取出里面的内容(Value)。
      • 如果权重高,取出的内容就多(比如提取出“iPhone”、“库克”等信息);如果权重低,你几乎忽略这本书的内容。
      • 最终,你把所有借到的书的内容(Weighted Values)融合在一起,就完全理解了“苹果”在这个语境下的含义。

2. 计算注意力得分

评分函数采用向量点积形式。随着维度的升高,点积后的数值方差也会随之变大,过大差异的得分经过 softmax 后会更加放大其差异,将高得分的权重逼近于 1,其他小得分的权重逼近于 0,不管压缩至过大还是过小,都会使梯度趋近于 0,造成梯度消失。因此在实际计算中对结果进行了缩放,除掉一个维度的二次方根(dk为 key 的维度,由于 qkv 的维度相同,所以都可以)。

$$score(i,j)=\frac{Q_i{K_j}^T}{\sqrt{d_k}}$$

对于整个序列,模型可以通过 Query 和 Key 矩阵运算一次性计算所有位置之间的得分。


3. 计算注意力权重

使用 softmax 函数对注意力得分进行归一化,确保每个位置对所有位置的关注程度之和为 1,从而形成一个有效的加权分布。

对于整个序列,模型对之前得到的注意力评分矩阵的每一行(也就是每个词元对所有词的注意力得分)进行 softmax 归一化。


4. 加权汇总得到输出

最后根据注意力权重对所有的 Value 进行加权求和,得到每个位置融合全局信息后的新表示。

对于整个序列,模型同样可以通过矩阵运算一次性计算所有位置的输出,利用矩阵相乘的性质,同时实现了 Value 和权重相乘加权再相加求和的过程。

由于我们处理的是矩阵,可以将 第 2~4 步合并为一个公式来计算自注意力层的输出,对应原论文中的公式:

$$ Attention(Q,K,V) = softmax(\frac{QK^T}{\sqrt{d_k}})V $$

整体计算流程如下所示:

3.2.2 多头自注意力

自然语言本身具有高度的语义复杂性,一个句子往往同时包含多种类型的语义关系。翻译“这只动物没有过马路,因为它太累了”这样的句子,模型需要理解“它”指的是谁,需要理解“因为”体现的因果关系等等…

这些信息很难通过单一视角或一套注意力机制完整捕捉,Transformer 为此引入了多头注意力机制(Multi-Head Attention)。其核心思想是通过多组独立的 Query、Key、Value 投影,让不同注意力头分别专注于不同的语义关系,最后将各头的输出拼接融合。

$$\begin{array}{c} \operatorname{MultiHead}(Q, K, V)=\operatorname{Concat}\left(\operatorname{head}_{1}, \ldots, \operatorname{head}_{n}\right) W_{o}\\ \operatorname{head}_{i}=\operatorname{Attention}\left(Q W_{Q}^{i}, K W_{K}^{i}, V W_{V}^{i}\right) \end{array}$$

在 Transformer 的原始实现中使用了 8 个头,多头自注意力计算流程:

1. 分别计算各头注意力

2. 合并多头注意力

8 个头得到 8 个矩阵输出,前馈层期望得到的是 1 个矩阵,而不是 8 个,每个词对应一个向量,所以需要将 8 个矩阵按照每个词表示的维度进行拼接,再乘一个额外的权重矩阵WO,得到最终和输入向量维度相同的输出向量。

为什么将多头注意力拼接之后还需再经过一个线性层?

在整个编码器解码器所有子层的输入和输出中,可以发现词表示的维度始终都相同,这是为了方便进行残差连接,所以一般不称作embedding_dim,而是统一称为d_model。原论文中d_model为 512,每个头的 QKV 维度为 64,刚好分为 8 头。

形式上,这是通过多个注意力机制来实现的,并将它们的结果拼接起来。

实际在实现过程中,并不需要真正地分别乘以多个矩阵生成多个 Q, K, V,而只需要将单头注意力机制算出的 Q, K, V 拆分成多个部分即可,可以看做把多个矩阵拼接起来做一次大的矩阵运算即可。

这样做的好处是无论有几个注意力头,模型大小都相同,也就是说,多头注意力机制并不会增加模型的大小。

3.3 前馈神经网络子层

在每一层中,前馈神经网络(Feed-Forward Network,FFN)紧接在多头注意力子层之后,通过对每个位置的表示进行逐位置的非线性特征变换,进一步提升模型对复杂语义的建模能力。

标准的 FFN 子层包含两个线性变换和一个非线性激活函数,中间通常使用 ReLU 激活。

FFN(x) = Linear2(ReLU(Linear1(x)))

在通过注意力机制观察其他词元后,模型使用 FFN 来处理这些新信息。

  • 自注意力:观察其他词元并收集信息
  • FFN:花点时间思考并处理这些信息

第一个 Linear1 通常将 d_model 映射到更高维度,以增强模型表达能力,经过 ReLU 激活后再经过 Linear2 映射回 d_model 维度。

4 解码器

4.1 解码器概述

解码器的作用是根据编码器的输出,逐步生成目标目标序列

解码器同样由多个解码器层堆叠构成,每个解码器层又包含三个子层:

  • 掩码多头自注意力(Masked Multi-Head Attention): 用于建模当前词和上文的依赖关系。在训练过程中将后面的词进行掩码,模拟逐词生成的过程,限制模型只能观察已生成的词。
  • 编码器-解码器注意力(Encoder-Decoder Attention): 用于建模当前词和原序列的关系。类似 Seq2Seq 模型中的注意力,编码器在生成词时,参考编码器输出并提取语义信息。
  • 前馈神经网络(Feed Forward Network): 用于处理当前词的语义信息。对每个词进行非线性映射,增强模型表达能力。

另外在堆叠多层之后,解码器输出的隐藏向量还要会经过一个线性层,将其映射为词表大小的向 量表示预测得分,并通过 softmax 生成概率分布,用于预测当前要输出的词。

4.2 掩码多头自注意力子层

掩码自注意力(Masked Multi-Head Attention)的核心作用:建模目标序列中当前位置与前文的依赖信息。

由于 Transformer 没有 RNN 那样的隐状态来传递上下文信息,因此在生成每个词时,必须使用此前生成的所有词作为输入,这带来的好处是模型可以随时查看任意久远的历史信息。随后通过自注意力机制重新建模上下文,更新对整句话的语义理解,以预测下一个词。

解码器采用自回归(auto-regressive)生成方式,每一步的输入由此前生成的所有词构成,每一步的输出和输入序列长度相同,只取最后一个位置的输出作为当前步的预测结果。不断重复直到生成特殊的结束标记 ,表示序列生成完成。

但如果在训练阶段也采用这种逐词生成的方式,那么完全无法利用 Transformer 的并行计算优势。因此可以将完整的目标序列输入到解码器中,不过这样会导致模型偷窥到未来数据,学到从后面信息预测当前词的模式,导致在真正预测时无法准确推理。

为了避免这个问题,Transformer 引入了掩码(Mask)机制,阻止模型访问未来的信息,只允许模型参考当前位置之前的信息。

Mask 机制的实现非常简单,只需要一个下三角矩阵,将注意力得分矩阵中当前位置对后续位置的评分设置为 −∞,在经过 softmax 后,这些位置的权重会趋近于 0,从而实现对后面词的屏蔽。

4.3 编码器-解码器注意力子层

编码器-解码器注意力(Encoder-Decoder Attention)的核心作用是建模当前位置和编码器原序列各位置之间的依赖关系。相当于 Seq2Seq 模型中的注意力机制,给解码器生成目标词提供参考。

  1. 编码器首先处理原序列,顶层编码器的输出分别经过两个线性变换矩阵WKWV,得到每个位置的键向量 K 和值张量 V,由于原句是不变的,所以解码器每一步都会重复使用这一组 K, V。
  2. 解码器中,由掩码自注意力子层输出的当前位置信息,经过一个线性变换矩阵 WQ,得到当前位置的查询张量 Q。
  3. 接下来就是熟悉的注意力计算,解码器使用 Q 去和编码器的 K 使用缩放点积计算相似度,随后经过 softmax 归一化为权重,用这些权重和 V 进行加权求和,得到当前生成词所需的上下文信息。
  4. 生成的上下文信息后续会通过残差连接和原本的当前位置信息(也就是掩码注意力层的输出,用于生成 Q 的原始向量)进行相加,以融合解码器当前状态和编码器参考信息。

4.4 前馈神经网络子层

对每个位置的表示进行独立的非线性变换,增强模型的表达能力,与编码器中的结构一致,两个线性层之间使用 ReLU 非线性激活。

FFN(x) = Linear2(ReLU(Linear1(x)))

4.5 最终线性层和输出

最后一层的解码器会输出一个浮点数张量,如何转换为对应的词元?

通过一个简单的全连接神经网络,将解码器的输出向量投影到一个更大的向量,称为 logits 向量,维度为词表大小,每个维度代表对应词元的预测得分,再通过 softmax 将这些分数转换为概率。后续选词策略常用的有贪心策略、束搜索和随机采样。

5 优化手段

实际上,上述每个编码器层和解码器层中的每一个子层,其输出都要经过残差连接(Residual Connection)和层归一化(Layer Normalization)处理。

实际上这二者并不是 Transformer 发明的新概念,而是在深度神经网络中常用的结构,可以用来缓解梯度消失、收敛困难的问题,也是 Transformer 能够堆叠那么多层的原因。

5.1 残差连接

残差连接(Residual Connection,也称“跳跃连接”或“捷径连接”),此前在计算机视觉领域何凯明提出残差网络(resnet),用于缓解深度神经网络中梯度消失的问题

核心思想是:将一个子层的输入与它的输出直接相加,形成一条跨越子层的“快速通道”。这样,输入上的梯度不仅会间接地流经该子层,还会直接到达最终的输出结果。

y = x + f(x)

这里将每个子层中的所有运算抽象为函数 f(x),输入 x 则输出 f(x),残差连接就是将输入和输出相加。

残差连接可以确保在反向传播时,梯度至少存在一条稳定路径进行回传,极度缓解了梯度消失,是深层神经网络可训练的关键结构。

$$\frac{\partial y}{\partial x}=\frac{\partial(x+f(x))}{\partial x}=1+\frac{\partial f(x)}{\partial x}$$

这里的 1 十分关键,无论 f(x) 的梯度变得多么小,甚至梯度消失,梯度总能通过 1 这条路,无损地传回前面的层。

残差连接可以看做是一个拟合残差的过程,f 这个函数的运算逻辑就是我们要训练的模型,x 是输入,y 是输出。

常规模式 y = f(x) 训练模型时,是在让模型学会如何在给定输入 x 时,生成出 y。 残差连接 y = x + f(x) 训练模型时,是在让模型学会如何在现有 x 的基础上,通过一些改动,加上 f(x) 之后去逼近最终输出 y

如果比作写作文,一个是从头开始,一个是润色修改。所以残差残差,就是让模型去拟合残余的误差。

5.2 层归一化

残差连接让梯度更容易流动,虽然解决了梯度消失问题,但是由于梯度加上了 1,如果 f(x)梯度始终大于 0,那么梯度会像雪球一样越滚越大,层层相乘后,梯度会指数级爆炸。

所以残差连接之后经常搭配层归一化(Layer Normalization,LayerNorm),主要作用是对每个 token 的特征分布进行归一化(某个 token 的表示可能在不同维度上有较大数值差异),从而提升模型训练的稳定性。

计算方式:

  1. 计算该每个 token 的特征向量在所有维度上的平均值 μ 和标准差 σ
  2. 归一化为均值为 0,标准差为 1 的标准正态分布。$$\widehat{x}^i=\frac{x^{i}-\mu}{\sigma+\varepsilon}$$
  3. 在归一化的基础上加入可学习的缩放因子和偏移因子,调整均值和标准差,保证归一化不会限制模型的表示 能力。 LayerNorm(xi) = γii + βi

为什么残差连接会带来梯度爆炸呢?层归一化又是怎样解决梯度爆炸的?

在前向传播时,每一层的残差计算为 y = x + f(x),由于二者是相加的关系,则 y 的方差会只增不减、线性累积,到深层网络中,方差会变得巨大,这样的数值最终经过 softmax 运算,结果会变得极不稳定。

在反向传播过程中,总梯度=$1+\frac{\partial f(x)}{\partial x}$连乘,如果每一层的$\frac{\partial f(x)}{\partial x}$总是正数,那么在深层网络当中就会出现梯度爆炸。

而加入层归一化之后,无论上一层加出来的值多么大,在经过层归一化之后,方差和均值都会把拉到合理的范围内。确保了 f(x) 只是配角,强制$\frac{\partial f(x)}{\partial x}$的数值非常小,并且有正有负,使整体的梯度$1+\frac{\partial f(x)}{\partial x}$维持在 1 附近,而不易梯度爆炸。

加入残差连接和层归一化之后如下图,子层的输入与输出相加之后,经过层归一化,再作为输入进入下一层:

6 位置编码

由于 Transformer 不包含 RNN 那样的循环结构,虽然可以并行处理所有位置的信息,但是带来了另一个问题,无法区分 token 的位置关系,也就是说无法区分 token 相同但语序不同的句子,比如“我爱你”和“你爱我”。

因此 Transformer 引入了位置编码(Positional Encoding),为每个词设计一个表示其位置信息的向量,并和对应的原始词向量相加,使结果既包含了语义信息,又包含了位置信息。

最简单的思路是直接使用 0、1、2、3、4 这样的绝对位置编码,但是缺点也很明显,越靠后的 token 位置编码就越大,和词向量相加后会淹没语义信息。

可以很自然地想到将绝对位置编码除以句子长度,使用归一化解决数值过大的问题,但是会带来另一个问题,在不同长度的句子当中,相同的位置具有不同的位置编码,不利于模型形成对位置的感知能力。

实际 Transformer 中的位置编码使用基于正弦余弦的位置编码,每个位置 pos 的位置编码由一个长度为 dmodel 的向量表示:

$$PE(pos,2i)=sin(\frac{pos}{10000^{\frac{2i}{d_{model}}}})$$ $$PE(pos,2i+1)=cos(\frac{pos}{10000^{\frac{2i}{d_{model}}}}) $$

  • pos 为当前词在序列中的位置
  • i 是当前词位置编码的维度索引,偶数维使用 sin,奇数维使用 cos
  • i 取值范围为 [0, $\frac{d_{model}}{2}-1$]

这种位置编码方式对于任意长度的序列,其编码结果都是固定的,因此可以提前计算,无需训练。并且由于编码之间存在数学规律,模型在计算注意力时可以感知到 token 间的相对位置关系。

由于每个位置的计算中三角函数的周期是不同的,因此在不同位置计算出的位置编码,几乎不可能在所有维度上都相同,因此可以认为每个位置的编码是唯一的。

7 总结


04_Transformer架构详解
https://zhubaoduo.com/2024/08/21/大模型开发/06_自然语言处理/04_Transformer架构详解/
作者
baoduozhu
发布于
2024年8月21日
许可协议