自然语言处理(四)—— 注意力机制与 Transformer
Chen Kai BOSS

在深度学习席卷自然语言处理的历程中, 2017 年可能是最具转折意义的一年。 Google 的论文《 Attention Is All You Need 》提出了 Transformer 架构,彻底改变了 NLP 的游戏规则。在此之前, RNN 和 LSTM 统治了序列建模领域,但它们的顺序处理特性限制了并行化能力,长距离依赖问题也始终困扰着研究者。 Transformer 通过完全抛弃循环结构,仅依靠注意力机制就实现了更好的性能和更高的训练效率。

今天, BERT 、 GPT 、 T5 等模型的成功都建立在 Transformer 的基础上。理解 Transformer 不仅是理解现代 NLP 的关键,更是深入大语言模型的必经之路。本文将从 Seq2Seq 的局限性讲起,逐步推导出注意力机制的必要性,然后深入剖析 Transformer 的每个组件,最后通过 PyTorch 代码实现一个完整的 Transformer 模型。

Seq2Seq 的局限性:信息瓶颈问题

在注意力机制出现之前, Seq2Seq 模型是处理序列到序列任务(如机器翻译)的主流方法。典型的 Seq2Seq 架构包含两个部分:

  • 编码器( Encoder):将输入序列 编码为固定长度的上下文向量
  • 解码器( Decoder):基于上下文向量 生成输出序列

这个架构的核心问题在于信息瓶颈:无论输入序列有多长,编码器都必须将所有信息压缩到一个固定维度的向量 中。对于短句子,这个瓶颈可能不明显;但当处理长文本时,这个固定长度的向量就成了性能的天花板。

信息瓶颈的直观理解

想象你在翻译一篇 100 个词的文章。传统 Seq2Seq 要求你先读完整篇文章,然后在脑海中只记住一个"总结性的想法"(上下文向量),接着仅凭这个总结来翻译每一个词。这个总结无论多么精炼,也不可能完美保留原文的所有细节。

更具体地说,假设我们翻译句子:"The animal didn't cross the street because it was too tired."。当翻译到 "it" 时,需要知道 "it" 指的是 "animal" 而非 "street"。如果编码器的上下文向量丢失了这个关联信息,解码器就无法正确翻译。

数学上的表达

在标准 Seq2Seq 中,编码器的最后隐状态作为上下文向量:

解码器在时刻 的隐状态计算为:

注意这里的 在整个解码过程中是固定不变的。这意味着无论当前要生成哪个词,解码器看到的都是同一个编码器总结,无法根据当前需求"回头"重点关注输入的某些特定部分。

注意力机制的诞生背景

2014 年, Bahdanau 等人在论文《 Neural Machine Translation by Jointly Learning to Align and Translate 》中首次提出了注意力机制。他们的核心洞察是:与其让编码器生成一个固定的上下文向量,不如让解码器在每个时刻动态地"查看"输入序列的不同部分

这个想法非常符合人类翻译的过程:当我们翻译一个长句子时,会不断地回头看原文的不同片段,将注意力聚焦在当前翻译所需的关键信息上。

注意力的直观定义

注意力机制的本质是一个加权求和过程:

  1. 编码器为输入序列的每个位置生成一个隐状态
  2. 解码器在时刻 计算每个编码器隐状态的重要性权重
  3. 用这些权重对编码器隐状态进行加权求和,得到当前时刻的上下文向量

数学表达为:

其中权重 表示解码器在时刻 对输入位置 的关注程度。这些权重通常满足:

核心是:不同时刻的 是不同的,因为权重分布 会根据解码器当前状态动态调整。

Bahdanau 注意力(加性注意力)

Bahdanau 注意力是最早提出的注意力机制,也称为加性注意力( Additive Attention)或拼接注意力( Concat Attention)。

计算流程

给定解码器在时刻 的隐状态 和编码器的隐状态序列, Bahdanau 注意力按以下步骤计算:

步骤 1:计算对齐分数( Alignment Scores)

对于每个编码器位置,计算一个标量分数

这里: - 是可学习的权重矩阵 - 是可学习的权重向量 - 是激活函数

这个公式的含义是:将解码器状态 和编码器状态通过线性变换和激活函数组合,然后投影到一个标量上,衡量它们的"匹配度"。

步骤 2:归一化为注意力权重

使用 Softmax 将分数归一化为概率分布:

步骤 3:计算上下文向量

用注意力权重对编码器隐状态加权求和:

步骤 4:生成输出

将上下文向量与解码器状态结合,生成当前时刻的输出:

为什么叫"加性"注意力?

名字来源于分数计算公式中的加法操作:。这与后面要介绍的"点积注意力"形成对比。

可视化理解

假设我们翻译 "I love deep learning" 到法语。当解码器生成 "apprentissage" (learning) 时,注意力权重可能是:

输入词 I love deep learning
权重 0.05 0.10 0.20 0.65

权重 0.65 表示解码器当前高度关注输入的 "learning",这符合翻译的对应关系。

Luong 注意力(点积注意力)

2015 年, Luong 等人提出了几种更简洁的注意力变体。其中核心:点积注意力( Dot-Product Attention),它极大简化了分数计算。

三种变体

Luong 提出了三种计算对齐分数的方法:

1. 点积( Dot)

直接计算解码器状态与编码器状态的点积。要求两者维度相同。

2. 通用( General)

引入一个权重矩阵 进行线性变换。可以处理维度不同的情况。

3. 拼接( Concat)

与 Bahdanau 类似,但使用拼接而非求和。

点积注意力的优势

点积注意力相比 Bahdanau 注意力有明显优势:

  1. 计算更简单:只需矩阵乘法,无需复杂的神经网络层
  2. 速度更快:可以高度并行化,利用现代 GPU 的矩阵运算优化
  3. 参数更少:不需要额外的 等参数

这些优势使得点积注意力成为 Transformer 的基础。

Bahdanau vs Luong:关键区别

特性 Bahdanau 注意力 Luong 注意力
提出时间 2014 2015
分数计算 加性(拼接 + 前馈网络) 点积 / 通用 / 拼接
使用的解码器状态 (前一时刻) (当前时刻)
计算复杂度 较高 较低(特别是点积)
参数量 较多 较少(特别是点积)

Self-Attention:自注意力详解

前面介绍的注意力机制都是编码器-解码器注意力( Encoder-Decoder Attention),即解码器关注编码器的输出。 Transformer 引入了一个更强大的概念:自注意力( Self-Attention)。

自注意力的核心思想

自注意力允许序列中的每个位置关注同一序列中的其他所有位置。这让模型能够捕捉序列内部的依赖关系,而无需依赖循环结构。

举个例子,考虑句子:"The animal didn't cross the street because it was too tired."

  • 当处理 "it" 时,自注意力可以计算 "it" 与 "animal"、"street" 的关联程度
  • 模型会学习到 "it" 与 "animal" 的关联更强,从而正确理解指代关系

Query 、 Key 、 Value:注意力的三要素

自注意力引入了三个核心概念:Query(查询)Key(键)Value(值)。这个命名来源于信息检索系统的类比。

直觉: Q/K/V 的信息检索类比

想象你在图书馆查资料:

  • Query(查询):你的搜索关键词,比如"机器学习基础"
  • Key(索引):每本书的关键词标签(目录卡片上的主题词)
  • Value(内容):书架上书的实际内容

查找过程:

  1. 匹配阶段:用你的 Query 和所有 Key 对比,计算相关性分数(哪些书最匹配你的需求?)
  2. 加权阶段:用 Softmax 将分数转换为权重,决定每本书的重要性(最相关的书权重最高)
  3. 提取阶段:按权重提取 Value,相关性高的书贡献更多内容

在 Transformer 中:

  • Query:当前位置想要获取什么信息("我需要什么?")
  • Key:每个位置能提供什么信息("我有什么?")
  • Value:每个位置的实际内容("我的内容是什么?")

为什么要分开 Q 、 K 、 V?

如果直接用同一个向量表示,就无法区分"需求"和"供给"。分开后:

  • Query 表达"需求":当前位置需要什么类型的信息
  • Key 表达"供给":其他位置能提供什么类型的信息
  • Value 表达"内容":实际要传递的信息是什么

这种设计让模型可以学习到:哪些位置( Key)与当前需求( Query)匹配,然后提取对应的内容( Value)。

数学形式化

给定输入序列的嵌入,通过三个权重矩阵生成 Q 、 K 、 V:

符号说明:

-$X ^{n d_{model}} nd_{model}W^Q, W^K ^{d_{model} d_k}d_kd_{model} / hh$ 是注意力头数) -: Value 的投影矩阵 -: Value 的维度(通常等于

直觉理解:

  • 每一行 分别是位置 的查询、键、值向量 - 是可学习的,训练过程中自动学会如何分解输入为 Q 、 K 、 V
  • 不同的投影矩阵让模型可以从同一个输入中提取出不同角度的信息

注意力计算的直观理解

自注意力的计算可以分解为三步:

步骤 1:计算相似度

对于位置 的查询,计算它与所有位置键 的相似度:

点积越大,表示 的关联越强。

步骤 2:归一化

使用 Softmax 归一化为权重:

步骤 3:加权求和

用权重对值向量加权求和,得到位置 的输出:

矩阵形式的简洁表达

上述过程可以用矩阵形式一次性处理所有位置:

这里: - 是所有位置对之间的相似度矩阵 - 是缩放因子(下一节详细解释) - Softmax 按行归一化 - 最终输出

为什么自注意力如此强大?

  1. 并行化:所有位置可以同时计算,无需像 RNN 那样顺序处理
  2. 长距离依赖:任意两个位置都可以直接交互,无论距离多远
  3. 灵活性:通过学习,模型可以自适应地捕捉不同类型的关系

Scaled Dot-Product Attention:缩放点积注意力

Transformer 使用的注意力机制称为 Scaled Dot-Product Attention(缩放点积注意力)。相比普通点积注意力,它引入了一个缩放因子

为什么需要缩放?(公式动机)

问题 1:点积数值爆炸

( Q 和 K 的维度)很大时,点积 的数值范围会变得很大。

数学推导:

假设 的元素是独立同分布的随机变量,均值为 0,方差为 1:

点积计算为:

由于 独立,点积的方差为:

直觉:点积是 个独立项的求和,每增加一个维度,方差就增加 1 。所以 越大,点积的数值范围越大。

问题 2: Softmax 饱和与梯度消失

当点积值过大时(如 时点积可能达到± 20), Softmax 会进入饱和区:

此时:

  1. 梯度消失: Softmax 在饱和区的梯度接近 0,反向传播信号微弱
  2. 分布极端化:所有权重几乎都集中在最大值上,模型只关注一个位置,丧失了分布式注意力的优势

缩放因子的作用

通过除以,可以将点积的方差稳定在 1:

方差推导:

完整公式:

符号说明:

-: Query 矩阵, 是序列长度 -: Key 矩阵, 是 Key 序列长度(自注意力中) -: Value 矩阵
-:相似度矩阵, 元素表示 Query 和 Key 的相似度 -$ ^{n d_v}$ 直觉理解:

  1. :计算所有 Query-Key 对的相似度,形成 的矩阵
  2. 除以:归一化,使相似度的数值范围适合 Softmax(通常在 之间)
  3. Softmax 按行:每一行是一个 Query 对所有 Key 的注意力分布,和为 1
  4. 乘以:加权求和,每个 Query 获得所有 Value 的加权平均

实际效果对比(数值示例)

场景:,某个 Query 与 3 个 Key 的原始点积为 不缩放: - 梯度几乎为 0(饱和) - 只关注第 3 个 Key,丧失分布式注意力

缩放后: - 梯度正常 - 注意力更均匀分布,保留了多个 Key 的信息

关键结论: 缩放因子 不是随意选择的,而是基于方差分析得出的数学最优解,确保不同维度的模型都能稳定训练。

Mask 机制

在某些场景下,需要阻止某些位置之间的注意力。例如:

  • Padding Mask:忽略填充位置
  • Look-Ahead Mask:在解码器中,阻止当前位置关注未来位置

实现方式是在 Softmax 之前,将需要屏蔽的位置的分数设为

其中 是掩码矩阵,不需要屏蔽的位置为 0,需要屏蔽的位置为

Multi-Head Attention:多头注意力

单个注意力头只能学习一种类型的关系。为了让模型同时关注不同的表示子空间, Transformer 使用了多头注意力( Multi-Head Attention)。

核心思想

多头注意力将 Q 、 K 、 V 分别线性投影到 个不同的子空间,在每个子空间中独立计算注意力,最后将结果拼接并投影回原始维度。

数学定义

其中每个注意力头的计算为:

参数维度: - - -Double exponent: use braces to clarifyW^O ^{h d_v d_{model}} 通常设置,使得总计算量与单头注意力相当。

为什么需要多头?

直观类比:多头注意力就像用多双眼睛同时观察一个物体。每个头可以学习不同类型的关系:

  • Head 1:可能学习句法关系(如主谓宾)
  • Head 2:可能学习语义关系(如同义词)
  • Head 3:可能学习位置关系(如相邻词)

实际配置

在原始 Transformer 论文中: - - - 这样每个头只处理 64 维的向量,但 8 个头并行处理,总参数量与单个 512 维注意力相当。

多头的优势

  1. 表示能力:不同子空间可以捕捉不同方面的信息
  2. 鲁棒性:即使某些头学习失败,其他头仍能提供有用信息
  3. 可解释性:可以可视化不同头学到的注意力模式

Transformer 完整架构

现在可以将所有组件组合起来,构建完整的 Transformer 架构。 Transformer 采用经典的编码器-解码器结构,但完全基于注意力机制,没有任何循环或卷积层。

整体结构

Transformer 由以下部分组成:

  1. 编码器( Encoder) 个相同的层堆叠(原论文
  2. 解码器( Decoder) 个相同的层堆叠
  3. 输入嵌入( Input Embedding):将词转换为向量
  4. 位置编码( Positional Encoding):添加位置信息
  5. 输出层:线性层 + Softmax,生成词表上的概率分布

编码器层的结构

每个编码器层包含两个子层:

子层 1:多头自注意力

输入序列自己关注自己。

子层 2:前馈网络

这是一个两层全连接网络,中间使用 ReLU 激活。通常中间层维度是 的 4 倍(如)。

残差连接与层归一化

每个子层都使用残差连接和层归一化:

完整的编码器层可以表示为:

解码器层的结构

每个解码器层包含三个子层:

子层 1:掩码多头自注意力

使用 Look-Ahead Mask 防止关注未来位置。

子层 2:编码器-解码器注意力Query 来自解码器, Key 和 Value 来自编码器输出。这让解码器能够关注输入序列。

子层 3:前馈网络

与编码器相同。

完整的解码器层:

架构图(文本表示)

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
输入序列 (源语言)              目标序列 (目标语言)
| |
Embedding Embedding
| |
+ Positional + Positional
Encoding Encoding
| |
v v
┌─────────────┐ ┌─────────────┐
│ Encoder 1 │ │ Decoder 1 │
├─────────────┤ ├─────────────┤
│ Self-Attn │ │ Masked Attn │
│ + FFN │ │ Cross Attn │
└─────────────┘ │ + FFN │
| └─────────────┘
v |
... (N 层) ... (N 层)
| |
v v
┌─────────────┐ ┌─────────────┐
│ Encoder N │─────────────>│ Decoder N │
└─────────────┘ └─────────────┘
|
Linear
|
Softmax
|
输出概率分布

编码器-解码器结构详解

Transformer 的编码器-解码器结构有明确的分工:

编码器的职责

编码器的目标是将输入序列转换为一系列连续表示,这些表示捕捉了输入的语义信息。

关键特性:

  1. 双向注意力:每个位置可以关注整个输入序列(包括前后)
  2. 并行处理:所有位置同时计算,无需等待前一个位置
  3. 多层抽象:通过堆叠多层,逐步抽象出高层语义

信息流动:

  • 输入:词嵌入 + 位置编码
  • 第 1 层:学习局部依赖(如短语结构)
  • 第 2-3 层:学习句法关系(如主谓宾)
  • 第 4-6 层:学习语义关系(如指代、推理)

解码器的职责

解码器基于编码器的表示,自回归地生成输出序列。

关键特性:

  1. 单向注意力:只能关注已生成的位置(通过 Look-Ahead Mask)
  2. 自回归生成:每个位置依赖前面位置的输出
  3. 跨序列注意力:通过编码器-解码器注意力访问输入信息

信息流动:

  • 已生成的输出 + 位置编码 → Masked Self-Attention
  • 结合编码器表示 → Cross-Attention
  • 前馈网络进一步处理
  • 输出层生成下一个词的概率

训练 vs 推理

训练阶段( Teacher Forcing):

解码器的输入是真实的目标序列(向右平移一位)。通过 Look-Ahead Mask,每个位置只能看到前面的真实标签,模拟自回归生成。

推理阶段(自回归解码):

解码器的输入是自己生成的序列。每次生成一个词,然后将其添加到输入序列,继续生成下一个词,直到生成结束符。

位置编码详解

由于 Transformer 完全抛弃了循环结构,它本身无法感知序列的顺序信息。为了解决这个问题,需要显式地添加位置编码( Positional Encoding)。

为什么需要位置编码?

什么是 Embedding?

在深入位置编码之前,先理解什么是 Embedding(嵌入)。

通俗理解: Embedding 就是"用向量表示对象"——把离散的符号(如词、 ID)转换成连续的数字向量。

为什么需要 Embedding?

计算机只认识数字,不认识"苹果"、"香蕉"这些词。我们需要把它们转换成数字向量,同时保留语义信息(相似的词有相似的向量)。

例子:

传统的独热编码( One-Hot):

  • 苹果 =
  • 香蕉 =
  • 橙子 =
  • 汽车 = 问题:
  1. 维度太高(词表有 10 万个词就需要 10 万维)
  2. 无法表达相似性(苹果和香蕉的相似度 = 苹果和汽车的相似度 = 0)
  3. 稀疏表示,浪费空间

Embedding 改进:

  • 苹果 =(稠密向量,如 256 维)
  • 香蕉 =(与苹果相似)
  • 橙子 =
  • 汽车 =(与水果差异大)

优点:

  1. 维度低(通常 128-512 维)
  2. 相似的对象向量接近:3. 稠密表示,信息丰富

核心思想: 用低维稠密向量表示高维稀疏对象,同时保留语义相似性。


位置编码的必要性

由于 Transformer 完全抛弃了循环结构,它本身无法感知序列的顺序信息。为了解决这个问题,需要显式地添加位置编码( Positional Encoding)。

考虑两个句子:

  • "The cat chased the mouse."(猫追老鼠)
  • "The mouse chased the cat."(老鼠追猫)

词汇完全相同,但顺序不同导致语义相反。如果没有位置信息,自注意力机制会给出相同的输出,因为它只是一个加权求和,与顺序无关。

数学上的问题:

自注意力的计算 对输入顺序是置换不变的:

如果对输入序列重新排列,注意力输出也会按相同方式重新排列,但每个位置的输出本身不变。这意味着模型无法区分"猫追老鼠"和"老鼠追猫"。

正弦/余弦位置编码

原始 Transformer 使用固定的正弦和余弦函数生成位置编码:

其中: - 是位置索引() - 是维度索引() - 偶数维使用正弦,奇数维使用余弦

正弦位置编码的优点

1. 唯一性

每个位置的编码都是唯一的,不会重复。

2. 外推性

模型可以处理比训练时更长的序列。由于编码是通过数学公式计算的,任意位置都能生成编码,即使该位置在训练数据中从未出现。

3. 相对位置信息

对于固定的偏移量 可以表示为 的线性函数。这让模型更容易学习相对位置关系。

证明:利用三角恒等式

可以将 表示为 的线性组合。

可学习位置编码

另一种方法是将位置编码作为可学习参数:

其中 是一个 的可学习矩阵。

优点: - 模型可以根据数据自适应学习最优的位置表示 - 在某些任务上性能略优于正弦编码

缺点: - 无法外推到更长序列(超过) - 需要额外的参数(对于长序列,参数量可能很大)

实践选择

  • BERT 、 GPT 系列:使用可学习位置编码
  • 原始 Transformer:使用正弦位置编码
  • T5 、 DeBERTa:使用相对位置编码(更复杂的变体)

现代实践中,可学习位置编码更常见,因为序列长度通常有上限(如 512 或 1024),而可学习编码的性能通常更好。

位置编码的添加方式

位置编码直接到输入嵌入上:

这里没有使用拼接,因为加法保持了维度不变,且在实验中效果很好。直观理解是:嵌入提供内容信息,位置编码提供顺序信息,两者相加形成完整表示。

Layer Normalization vs Batch Normalization

Transformer 使用 Layer Normalization(层归一化) 而非深度学习中更常见的 Batch Normalization(批归一化)。理解它们的区别对于理解 Transformer 的训练稳定性至关重要。

Batch Normalization 回顾

批归一化在批次维度上归一化:

其中 是当前批次的均值和方差。

问题:

  1. 依赖批次大小:小批次时统计量不稳定
  2. 序列长度不一致: NLP 任务中,不同样本的序列长度不同,批归一化会受到填充影响
  3. 训练/推理不一致:推理时需要使用训练时累积的统计量

Layer Normalization

层归一化在特征维度上归一化:

其中 是单个样本在特征维度上的均值和方差。

对于形状为 的张量(批次大小,序列长度,特征维度): - Batch Norm 在 维度上计算统计量(对每个特征独立) - Layer Norm 在 维度上计算统计量(对每个样本的每个位置独立)

Layer Norm 的优势

1. 独立于批次大小

每个样本独立归一化,不受批次大小影响。这对于 NLP 任务很重要,因为内存限制常导致小批次。

2. 适合序列数据

不同位置的归一化参数相同,不会受到序列长度变化的影响。

3. 训练/推理一致

无需维护移动平均统计量,训练和推理使用相同的计算。

Layer Norm 的位置

Transformer 中有两种 Layer Norm 的放置方式:

Post-LN(原始论文):

归一化在残差连接之后。

Pre-LN(更稳定):

归一化在子层之前。现代实现(如 GPT-2 、 GPT-3)通常使用 Pre-LN,因为它训练更稳定,梯度流动更好。

可学习的仿射变换

Layer Norm 之后通常添加可学习的缩放和偏移:

其中 是可学习参数, 表示逐元素乘法。这让模型可以调整归一化的强度。

残差连接的作用

Transformer 的每个子层都使用残差连接( Residual Connection):

残差连接最初由 ResNet 提出,在 Transformer 中同样至关重要。

为什么需要残差连接?

梯度消失的直观理解

在深入残差连接之前,先理解什么是梯度消失。

信号衰减类比:

想象你在山谷中大喊一声,声音经过多次反弹传播:

  • 第 1 次反弹:声音清晰,能量 100%
  • 第 5 次反弹:声音变弱,能量约 30%
  • 第 10 次反弹:几乎听不见,能量约 1%
  • 第 20 次反弹:完全听不见,能量接近 0

梯度在深层网络中的传播类似:

  • 每经过一层,梯度乘以一个数(如层的权重导数,通常
  • 经过多层后:梯度 =
  • 如果每层的权重导数都是 0.5,经过 20 层:(几乎为 0)
  • 结果:早期层的参数几乎不更新,只有后面几层在学习

数学证明( RNN 的例子):

假设 RNN 的权重矩阵,激活函数

时(大多数情况):

为什么 LSTM 能缓解梯度消失?

LSTM 通过门控机制,让梯度有"高速公路"( highway):

  • 遗忘门 时:信息几乎无损传播
  • 加法操作(加法不衰减梯度)
  • 类似给信号加了"中继站",防止衰减

但 LSTM 仍然是顺序结构,有两个问题:

  1. 无法并行化(必须等前一步计算完)
  2. 长距离依赖仍然困难(虽然比 RNN 好)

Transformer 的残差连接提供了类似但更强大的解决方案。


1. 缓解梯度消失

在深层网络中,梯度在反向传播时会逐层衰减。残差连接提供了一条从输出直接到输入的"快捷路径"( shortcut),梯度可以不经过中间层直接传播。

数学上,反向传播时:

关键点: 即使 很小(梯度消失),由于常数项 的存在,梯度仍能有效传播。

直觉: 残差连接相当于在网络中铺设了一条"梯度高速公路",绕过可能衰减梯度的复杂层。

2. 简化训练

残差连接让网络更容易学习恒等映射。如果某一层对当前任务没有帮助,它只需将输出设为 0,整体就退化为恒等映射

对比:

  • 无残差连接:层需要学习完整的变换,即使目标是恒等映射也很难学
  • 有残差连接:层只需学习残差(差值),学习恒等映射时只需,非常简单

实际意义: 训练初期,大部分层可能还不知道该学什么,残差连接让它们可以先"什么都不做"(输出接近 0),不会破坏已有信息,随着训练推进再逐渐学习有用的变换。

3. 允许更深的网络

原始 Transformer 使用 6 层,但残差连接使得堆叠 12 层( BERT-base)、 24 层( BERT-large)、甚至 96 层( GPT-3)成为可能。

历史对比:

  • ResNet 之前:网络超过 20 层就很难训练,性能反而下降(退化问题)
  • ResNet 之后: 152 层网络轻松训练,性能随深度提升

Transformer 借鉴了这一设计,让大模型成为可能。

残差连接与 Layer Norm 的协同

残差连接与 Layer Norm 的组合特别强大: - 残差连接保证梯度流动 - Layer Norm稳定激活值的分布

这种组合是 Transformer 训练稳定性的关键。

实际效果

在 Transformer 论文中,作者发现: - 没有残差连接, 6 层模型难以收敛 - 有残差连接,可以轻松训练更深的模型

现代大模型(如 GPT-3 的 96 层)都严重依赖残差连接。

实战:从零实现 Transformer( PyTorch)

现在我们用 PyTorch 从零实现一个完整的 Transformer 模型。这个实现将包含所有核心组件,并附有详细注释。

缩放点积注意力

实现目的

实现注意力机制的核心计算:给定 Query 、 Key 、 Value,计算 Query 对 Value 的加权平均。

实现思路

  1. 计算 Query 和 Key 的点积相似度(
  2. 缩放:除以,防止数值过大
  3. 应用 Mask(可选):屏蔽不需要关注的位置
  4. Softmax 归一化:转换为概率分布
  5. 加权求和:用权重对 Value 进行加权平均

代码实现

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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
import torch
import torch.nn as nn
import torch.nn.functional as F
import math

class ScaledDotProductAttention(nn.Module):
"""缩放点积注意力

核心公式:
Attention(Q, K, V) = softmax(QK^T / √ d_k) V

关键设计:
1. 点积计算相似度: Query 和 Key 越相似,点积越大
2. 缩放防止饱和:除以√ d_k 保持数值稳定
3. Softmax 归一化:转换为概率分布(每行和为 1)
4. 加权求和:用注意力权重对 Value 加权平均
"""

def __init__(self, dropout=0.1):
"""初始化

参数:
dropout: Dropout 概率,用于注意力权重(防止过拟合)
"""
super().__init__()
self.dropout = nn.Dropout(dropout)

def forward(self, q, k, v, mask=None):
"""前向传播

参数:
q: Query 张量,形状 (batch, n_heads, seq_len, d_k)
- batch: 批次大小
- n_heads: 注意力头数(多头注意力)
- seq_len: 序列长度( Query 的数量)
- d_k: 每个 Query/Key 的维度
k: Key 张量,形状 (batch, n_heads, seq_len, d_k)
v: Value 张量,形状 (batch, n_heads, seq_len, d_v)
- d_v: 每个 Value 的维度(通常等于 d_k)
mask: 掩码张量,形状 (batch, 1, 1, seq_len) 或 (batch, 1, seq_len, seq_len)
- 0 表示需要屏蔽的位置(不参与注意力计算)
- 1 表示正常位置

返回:
output: 注意力输出,形状 (batch, n_heads, seq_len, d_v)
attn_weights: 注意力权重,形状 (batch, n_heads, seq_len, seq_len)
"""
# 步骤 1: 获取 d_k( Key 的维度),用于缩放
d_k = q.size(-1)

# 步骤 2: 计算注意力分数 scores = Q * K^T / √ d_k
# matmul(q, k.transpose(-2, -1)): Query 和 Key 的点积
# - q: (batch, n_heads, seq_len, d_k)
# - k.transpose(-2, -1): (batch, n_heads, d_k, seq_len) # 转置最后两维
# - 结果: (batch, n_heads, seq_len, seq_len)
#
# 除以 math.sqrt(d_k): 缩放,防止点积过大导致 softmax 饱和
# - 例如: d_k=64 时,除以 8; d_k=512 时,除以 22.6
scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(d_k)
# scores[b, h, i, j] = Query_i 与 Key_j 的相似度分数

# 步骤 3: 应用掩码(如果提供)
# 为什么需要 mask?
# 1. Padding mask: 忽略填充位置(如序列长度不一致时的 padding)
# 2. Look-ahead mask: 在解码器中防止关注未来位置(自回归生成)
if mask is not None:
# masked_fill: 将 mask 为 0 的位置填充为-1e9(接近负无穷)
# 这样 softmax 后这些位置的权重接近 0
scores = scores.masked_fill(mask == 0, -1e9)

# 步骤 4: Softmax 归一化(按最后一维,即每个 Query 对所有 Key 的分数)
# 输入: scores (batch, n_heads, seq_len, seq_len)
# 输出: attn_weights (batch, n_heads, seq_len, seq_len)
# 每一行(最后一维)是一个概率分布,和为 1
attn_weights = F.softmax(scores, dim=-1)
# attn_weights[b, h, i, j] = Query_i 对 Key_j 的注意力权重

# Dropout: 随机丢弃部分注意力权重(正则化,防止过拟合)
# 训练时:随机将部分权重置 0
# 推理时:不做任何操作
attn_weights = self.dropout(attn_weights)

# 步骤 5: 加权求和 output = attention_weights * V
# matmul(attn_weights, v):
# - attn_weights: (batch, n_heads, seq_len, seq_len)
# - v: (batch, n_heads, seq_len, d_v)
# - 结果: (batch, n_heads, seq_len, d_v)
#
# 对于每个 Query i:
# output[i] = Σ_j attn_weights[i,j] * V[j]
# 即: Query i 的输出是所有 Value 的加权平均,权重由注意力分数决定
output = torch.matmul(attn_weights, v)

return output, attn_weights

代码解读

为什么 transpose(-2, -1)?

因为 matmul 要求最后两维可以相乘:

-: -: - 结果: 转置最后两维(-2 和-1)实现了

为什么 dim=-1?

对每个 Query,计算它对所有 Key 的注意力分布,所以在最后一维( seq_len 维度)做 softmax 。

数值稳定性

PyTorch 的 softmax 已经内置了数值稳定技巧(先减去最大值再计算),所以不需要手动处理。

注意事项

  1. 内存开销:注意力权重的形状是 - 序列长度 512 时,单个样本需要约 1MB 内存

    • 序列长度 2048 时,单个样本需要约 16MB 内存
    • 长序列时内存开销巨大!
  2. 梯度检查点:对于长序列,可以使用 PyTorch 的 gradient checkpointing 节省内存:

    1
    2
    from torch.utils.checkpoint import checkpoint
    output = checkpoint(self.attention_layer, q, k, v)

  3. Mask 处理:实际使用时需要区分不同类型的 mask:

    • Padding mask: 屏蔽填充位置(值为 0)
    • Causal mask: 屏蔽未来位置(自回归模型)
    • 两种 mask 可以组合使用(取交集)

多头注意力

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
55
56
57
58
59
60
61
62
63
64
class MultiHeadAttention(nn.Module):
"""多头注意力"""

def __init__(self, d_model, n_heads, dropout=0.1):
super().__init__()
assert d_model % n_heads == 0, "d_model 必须能被 n_heads 整除"

self.d_model = d_model
self.n_heads = n_heads
self.d_k = d_model // n_heads

# Q 、 K 、 V 的线性投影层
self.W_q = nn.Linear(d_model, d_model)
self.W_k = nn.Linear(d_model, d_model)
self.W_v = nn.Linear(d_model, d_model)

# 输出投影层
self.W_o = nn.Linear(d_model, d_model)

self.attention = ScaledDotProductAttention(dropout)
self.dropout = nn.Dropout(dropout)

def split_heads(self, x):
"""将最后一维拆分为 (n_heads, d_k)"""
batch_size, seq_len, d_model = x.size()
x = x.view(batch_size, seq_len, self.n_heads, self.d_k)
return x.transpose(1, 2) # (batch, n_heads, seq_len, d_k)

def combine_heads(self, x):
"""将多头合并回原始维度"""
batch_size, n_heads, seq_len, d_k = x.size()
x = x.transpose(1, 2).contiguous() # (batch, seq_len, n_heads, d_k)
return x.view(batch_size, seq_len, self.d_model)

def forward(self, q, k, v, mask=None):
"""
Args:
q: Query,形状 (batch, seq_len_q, d_model)
k: Key,形状 (batch, seq_len_k, d_model)
v: Value,形状 (batch, seq_len_v, d_model)
mask: 掩码
"""
# 线性投影
Q = self.W_q(q) # (batch, seq_len_q, d_model)
K = self.W_k(k) # (batch, seq_len_k, d_model)
V = self.W_v(v) # (batch, seq_len_v, d_model)

# 拆分为多头
Q = self.split_heads(Q) # (batch, n_heads, seq_len_q, d_k)
K = self.split_heads(K) # (batch, n_heads, seq_len_k, d_k)
V = self.split_heads(V) # (batch, n_heads, seq_len_v, d_k)

# 计算注意力
attn_output, attn_weights = self.attention(Q, K, V, mask)
# attn_output: (batch, n_heads, seq_len_q, d_k)

# 合并多头
output = self.combine_heads(attn_output) # (batch, seq_len_q, d_model)

# 输出投影
output = self.W_o(output)
output = self.dropout(output)

return output, attn_weights

前馈网络

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class PositionwiseFeedForward(nn.Module):
"""位置前馈网络(逐位置的两层全连接)"""

def __init__(self, d_model, d_ff, dropout=0.1):
super().__init__()
self.linear1 = nn.Linear(d_model, d_ff)
self.linear2 = nn.Linear(d_ff, d_model)
self.dropout = nn.Dropout(dropout)

def forward(self, x):
"""
Args:
x: 形状 (batch, seq_len, d_model)
"""
x = self.linear1(x)
x = F.relu(x)
x = self.dropout(x)
x = self.linear2(x)
x = self.dropout(x)
return x

位置编码

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
class PositionalEncoding(nn.Module):
"""正弦余弦位置编码"""

def __init__(self, d_model, max_len=5000, dropout=0.1):
super().__init__()
self.dropout = nn.Dropout(dropout)

# 创建位置编码矩阵
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len).unsqueeze(1).float()
div_term = torch.exp(torch.arange(0, d_model, 2).float() *
(-math.log(10000.0) / d_model))

pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)

pe = pe.unsqueeze(0) # (1, max_len, d_model)
self.register_buffer('pe', pe)

def forward(self, x):
"""
Args:
x: 形状 (batch, seq_len, d_model)
"""
seq_len = x.size(1)
x = x + self.pe[:, :seq_len, :]
return self.dropout(x)

编码器层

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
class EncoderLayer(nn.Module):
"""Transformer 编码器层"""

def __init__(self, d_model, n_heads, d_ff, dropout=0.1):
super().__init__()
self.self_attn = MultiHeadAttention(d_model, n_heads, dropout)
self.feed_forward = PositionwiseFeedForward(d_model, d_ff, dropout)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout1 = nn.Dropout(dropout)
self.dropout2 = nn.Dropout(dropout)

def forward(self, x, mask=None):
"""
Args:
x: 形状 (batch, seq_len, d_model)
mask: 注意力掩码
"""
# 子层 1: 多头自注意力
attn_output, _ = self.self_attn(x, x, x, mask)
x = self.norm1(x + self.dropout1(attn_output))

# 子层 2: 前馈网络
ff_output = self.feed_forward(x)
x = self.norm2(x + self.dropout2(ff_output))

return x

解码器层

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
class DecoderLayer(nn.Module):
"""Transformer 解码器层"""

def __init__(self, d_model, n_heads, d_ff, dropout=0.1):
super().__init__()
self.self_attn = MultiHeadAttention(d_model, n_heads, dropout)
self.cross_attn = MultiHeadAttention(d_model, n_heads, dropout)
self.feed_forward = PositionwiseFeedForward(d_model, d_ff, dropout)

self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.norm3 = nn.LayerNorm(d_model)

self.dropout1 = nn.Dropout(dropout)
self.dropout2 = nn.Dropout(dropout)
self.dropout3 = nn.Dropout(dropout)

def forward(self, x, encoder_output, src_mask=None, tgt_mask=None):
"""
Args:
x: 解码器输入,形状 (batch, tgt_seq_len, d_model)
encoder_output: 编码器输出,形状 (batch, src_seq_len, d_model)
src_mask: 源序列掩码
tgt_mask: 目标序列掩码( Look-Ahead Mask)
"""
# 子层 1: 掩码多头自注意力
self_attn_output, _ = self.self_attn(x, x, x, tgt_mask)
x = self.norm1(x + self.dropout1(self_attn_output))

# 子层 2: 编码器-解码器注意力
cross_attn_output, _ = self.cross_attn(x, encoder_output, encoder_output, src_mask)
x = self.norm2(x + self.dropout2(cross_attn_output))

# 子层 3: 前馈网络
ff_output = self.feed_forward(x)
x = self.norm3(x + self.dropout3(ff_output))

return x

完整 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
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
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
class Transformer(nn.Module):
"""完整的 Transformer 模型"""

def __init__(self, src_vocab_size, tgt_vocab_size, d_model=512, n_heads=8,
n_encoder_layers=6, n_decoder_layers=6, d_ff=2048,
max_len=5000, dropout=0.1):
super().__init__()

# 嵌入层
self.src_embedding = nn.Embedding(src_vocab_size, d_model)
self.tgt_embedding = nn.Embedding(tgt_vocab_size, d_model)

# 位置编码
self.pos_encoding = PositionalEncoding(d_model, max_len, dropout)

# 编码器
self.encoder_layers = nn.ModuleList([
EncoderLayer(d_model, n_heads, d_ff, dropout)
for _ in range(n_encoder_layers)
])

# 解码器
self.decoder_layers = nn.ModuleList([
DecoderLayer(d_model, n_heads, d_ff, dropout)
for _ in range(n_decoder_layers)
])

# 输出层
self.output_linear = nn.Linear(d_model, tgt_vocab_size)

self.d_model = d_model
self._init_parameters()

def _init_parameters(self):
"""初始化参数"""
for p in self.parameters():
if p.dim() > 1:
nn.init.xavier_uniform_(p)

def generate_square_subsequent_mask(self, sz):
"""生成 Look-Ahead 掩码"""
mask = torch.triu(torch.ones(sz, sz), diagonal=1).bool()
return mask

def encode(self, src, src_mask=None):
"""编码器前向传播"""
# 嵌入 + 位置编码
x = self.src_embedding(src) * math.sqrt(self.d_model)
x = self.pos_encoding(x)

# 通过所有编码器层
for layer in self.encoder_layers:
x = layer(x, src_mask)

return x

def decode(self, tgt, encoder_output, src_mask=None, tgt_mask=None):
"""解码器前向传播"""
# 嵌入 + 位置编码
x = self.tgt_embedding(tgt) * math.sqrt(self.d_model)
x = self.pos_encoding(x)

# 通过所有解码器层
for layer in self.decoder_layers:
x = layer(x, encoder_output, src_mask, tgt_mask)

return x

def forward(self, src, tgt, src_mask=None, tgt_mask=None):
"""
Args:
src: 源序列,形状 (batch, src_seq_len)
tgt: 目标序列,形状 (batch, tgt_seq_len)
src_mask: 源序列掩码
tgt_mask: 目标序列掩码

Returns:
输出 logits,形状 (batch, tgt_seq_len, tgt_vocab_size)
"""
# 编码
encoder_output = self.encode(src, src_mask)

# 解码
decoder_output = self.decode(tgt, encoder_output, src_mask, tgt_mask)

# 输出投影
output = self.output_linear(decoder_output)

return output

使用示例

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
# 创建模型
src_vocab_size = 10000
tgt_vocab_size = 10000
model = Transformer(src_vocab_size, tgt_vocab_size)

# 生成示例数据
batch_size = 32
src_seq_len = 20
tgt_seq_len = 15

src = torch.randint(0, src_vocab_size, (batch_size, src_seq_len))
tgt = torch.randint(0, tgt_vocab_size, (batch_size, tgt_seq_len))

# 生成掩码
tgt_mask = model.generate_square_subsequent_mask(tgt_seq_len).unsqueeze(0).unsqueeze(0)
# 形状: (1, 1, tgt_seq_len, tgt_seq_len)

# 前向传播
output = model(src, tgt, tgt_mask=tgt_mask)
print(f"输出形状: {output.shape}") # (32, 15, 10000)

# 计算损失
criterion = nn.CrossEntropyLoss(ignore_index=0) # 0 是 padding 索引
tgt_output = torch.randint(0, tgt_vocab_size, (batch_size, tgt_seq_len))
loss = criterion(output.view(-1, tgt_vocab_size), tgt_output.view(-1))
print(f"损失: {loss.item()}")

训练循环示例

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
import torch.optim as optim

def train_step(model, src, tgt, optimizer, criterion, device):
"""单步训练"""
model.train()
optimizer.zero_grad()

src = src.to(device)
tgt_input = tgt[:, :-1].to(device) # 去掉最后一个 token
tgt_output = tgt[:, 1:].to(device) # 去掉第一个 token(通常是<bos>)

# 生成目标掩码
tgt_seq_len = tgt_input.size(1)
tgt_mask = model.generate_square_subsequent_mask(tgt_seq_len).to(device)
tgt_mask = tgt_mask.unsqueeze(0).unsqueeze(0)

# 前向传播
output = model(src, tgt_input, tgt_mask=tgt_mask)

# 计算损失
loss = criterion(output.reshape(-1, output.size(-1)), tgt_output.reshape(-1))

# 反向传播
loss.backward()
optimizer.step()

return loss.item()

# 训练配置
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)
optimizer = optim.Adam(model.parameters(), lr=0.0001, betas=(0.9, 0.98), eps=1e-9)
criterion = nn.CrossEntropyLoss(ignore_index=0)

# 训练循环(伪代码)
for epoch in range(num_epochs):
for batch in train_dataloader:
src, tgt = batch
loss = train_step(model, src, tgt, optimizer, criterion, device)
print(f"Epoch {epoch}, Loss: {loss}")

实战:使用 HuggingFace Transformers

虽然从零实现有助于理解原理,但在实际项目中,我们通常使用成熟的库。 HuggingFace Transformers 提供了预训练模型和便捷的 API 。

安装

1
pip install transformers torch

机器翻译示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from transformers import MarianMTModel, MarianTokenizer

# 加载预训练的翻译模型(英语 -> 法语)
model_name = 'Helsinki-NLP/opus-mt-en-fr'
tokenizer = MarianTokenizer.from_pretrained(model_name)
model = MarianMTModel.from_pretrained(model_name)

# 要翻译的文本
src_text = "Hello, how are you?"

# Tokenize
inputs = tokenizer(src_text, return_tensors="pt", padding=True)

# 生成翻译
translated = model.generate(**inputs)

# 解码
tgt_text = tokenizer.decode(translated[0], skip_special_tokens=True)
print(f"原文: {src_text}")
print(f"译文: {tgt_text}")

使用 T5 进行多种任务

T5( Text-to-Text Transfer Transformer)将所有 NLP 任务都统一为文本到文本的格式。

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
from transformers import T5Tokenizer, T5ForConditionalGeneration

# 加载模型
model_name = 't5-small'
tokenizer = T5Tokenizer.from_pretrained(model_name)
model = T5ForConditionalGeneration.from_pretrained(model_name)

# 任务 1: 翻译
task = "translate English to German: "
text = "How are you?"
input_text = task + text
inputs = tokenizer(input_text, return_tensors="pt")
outputs = model.generate(**inputs, max_length=50)
print(tokenizer.decode(outputs[0], skip_special_tokens=True))

# 任务 2: 摘要
task = "summarize: "
text = """
The Transformer architecture revolutionized natural language processing.
It uses self-attention mechanisms to process sequences in parallel,
making it much faster than RNNs. Models like BERT and GPT are based on Transformers.
"""
input_text = task + text
inputs = tokenizer(input_text, return_tensors="pt")
outputs = model.generate(**inputs, max_length=50)
print(tokenizer.decode(outputs[0], skip_special_tokens=True))

# 任务 3: 问答
task = "question: What is the capital of France? context: Paris is the capital of France."
inputs = tokenizer(task, return_tensors="pt")
outputs = model.generate(**inputs, max_length=20)
print(tokenizer.decode(outputs[0], skip_special_tokens=True))

微调预训练模型

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
from transformers import Trainer, TrainingArguments
from datasets import load_dataset

# 加载数据集(以 IMDB 情感分析为例)
dataset = load_dataset("imdb")

# 加载模型和 tokenizer
from transformers import BertTokenizer, BertForSequenceClassification

model_name = 'bert-base-uncased'
tokenizer = BertTokenizer.from_pretrained(model_name)
model = BertForSequenceClassification.from_pretrained(model_name, num_labels=2)

# 数据预处理
def tokenize_function(examples):
return tokenizer(examples["text"], padding="max_length", truncation=True, max_length=512)

tokenized_datasets = dataset.map(tokenize_function, batched=True)

# 训练配置
training_args = TrainingArguments(
output_dir="./results",
evaluation_strategy="epoch",
learning_rate=2e-5,
per_device_train_batch_size=8,
per_device_eval_batch_size=8,
num_train_epochs=3,
weight_decay=0.01,
)

# 创建 Trainer
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_datasets["train"].select(range(1000)), # 使用小部分数据演示
eval_dataset=tokenized_datasets["test"].select(range(1000)),
)

# 开始训练
trainer.train()

# 评估
results = trainer.evaluate()
print(results)

自定义 Transformer 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from transformers import BertConfig, BertModel

# 自定义配置
config = BertConfig(
vocab_size=30000,
hidden_size=768,
num_hidden_layers=12,
num_attention_heads=12,
intermediate_size=3072,
max_position_embeddings=512,
)

# 创建随机初始化的模型
model = BertModel(config)
print(f"模型参数量: {sum(p.numel() for p in model.parameters()) / 1e6:.2f}M")

可视化注意力权重

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
from transformers import BertTokenizer, BertModel
import torch

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertModel.from_pretrained('bert-base-uncased', output_attentions=True)

text = "The animal didn't cross the street because it was too tired."
inputs = tokenizer(text, return_tensors="pt")

# 获取注意力权重
with torch.no_grad():
outputs = model(**inputs)
attentions = outputs.attentions # 12 层的注意力权重元组

# 查看第一层第一个头的注意力
layer_0_head_0 = attentions[0][0, 0] # (seq_len, seq_len)
print(f"注意力矩阵形状: {layer_0_head_0.shape}")

# 使用 matplotlib 可视化
import matplotlib.pyplot as plt
import seaborn as sns

tokens = tokenizer.convert_ids_to_tokens(inputs['input_ids'][0])
plt.figure(figsize=(10, 8))
sns.heatmap(layer_0_head_0.numpy(), xticklabels=tokens, yticklabels=tokens, cmap='Blues')
plt.title("Layer 0, Head 0 Attention")
plt.xlabel("Keys")
plt.ylabel("Queries")
plt.tight_layout()
plt.savefig('attention_heatmap.png')

❓ Q&A: Transformer 常见问题

Q1: 为什么 Transformer 比 RNN 快?

A: 主要原因是并行化。 RNN 必须顺序处理序列( 依赖于),无法并行计算。而 Transformer 的自注意力机制允许所有位置同时计算:

  • 训练阶段:所有时刻的输出可以一次性并行计算
  • 推理阶段:编码器仍然并行;解码器仍需自回归,但单步计算更快

此外,自注意力的计算是高度优化的矩阵乘法,可以充分利用 GPU 的并行计算能力。

Q2: Transformer 的复杂度是多少?

A: 对于序列长度 和模型维度

  • 自注意力层 - 计算需要 - 计算需要
  • 前馈层 很大时(如长文档),自注意力的 复杂度成为瓶颈。这催生了许多高效 Transformer 变体:
  • Longformer:使用局部注意力和稀疏注意力,复杂度降为 是窗口大小)
  • Linformer:使用低秩近似,复杂度降为 是投影维度)
  • Performer:使用核方法近似注意力,复杂度降为

Q3: Transformer 如何处理变长序列?

A: 通过填充( Padding)掩码( Masking)

  1. 填充:将所有序列填充到批次中的最大长度,使用特殊的 PAD token
  2. Padding Mask:在注意力计算时,将对应 PAD 位置的分数设为,使其 Softmax 输出为 0

示例:

1
2
3
def create_padding_mask(seq, pad_idx=0):
"""seq: (batch, seq_len)"""
return (seq == pad_idx).unsqueeze(1).unsqueeze(2) # (batch, 1, 1, seq_len)

Q4: 为什么需要 Warmup 和特殊的学习率调度?

A: Transformer 对学习率非常敏感。原论文使用的调度策略是:

这个策略的特点: 1. Warmup 阶段(前 4000 步):线性增加学习率 2. 衰减阶段:学习率按步数的平方根倒数衰减

原因: - Warmup 避免了训练初期梯度过大导致的不稳定 - 逐步衰减 帮助模型在后期精细调整

现代实践中,常用的还有余弦退火( Cosine Annealing)等策略。

Q5: Encoder-only 、 Decoder-only 、 Encoder-Decoder 有什么区别?

A: 这是三种不同的 Transformer 架构变体:

架构 结构 典型模型 适用任务
Encoder-only 只有编码器 BERT, RoBERTa 分类、 NER 、问答(理解)
Decoder-only 只有解码器 GPT, GPT-2/3 生成、续写、对话
Encoder-Decoder 完整编码器+解码器 T5, BART, mT5 翻译、摘要、问答生成

核心区别: - Encoder-only:使用双向注意力,擅长理解和表示 - Decoder-only:使用单向注意力( Look-Ahead Mask),擅长生成 - Encoder-Decoder:结合两者优势,编码器理解输入,解码器生成输出

Q6: 什么是 Teacher Forcing?

A: Teacher Forcing 是训练 Seq2Seq 模型(包括 Transformer 解码器)的常用技术:

  • 训练时:解码器的输入是真实的目标序列(向右平移一位)
  • 推理时:解码器的输入是自己生成的序列

示例:

训练时翻译 "I love AI" → "J'aime l'IA"

时刻 解码器输入 目标输出
1 <bos> J'
2 J' aime
3 aime l'
4 l' IA
5 IA <eos>

即使时刻 2 的预测错误,时刻 3 仍使用真实的 "aime" 而非错误预测。

优点: 训练快速稳定,梯度信号清晰 缺点: 训练和推理的分布不一致( Exposure Bias)

Q7: Transformer 如何处理很长的序列?

A: 标准 Transformer 受限于 复杂度和固定的位置编码长度。处理长序列的方法:

1. 分段处理( Sliding Window)

将长序列切分成多个固定长度的段,分别处理后合并。

2. 稀疏注意力

只关注局部窗口或特定模式的位置,如: - Longformer:局部窗口 + 全局注意力 - BigBird:随机注意力 + 窗口注意力 + 全局注意力

3. 分层注意力

先在局部块内做自注意力,再在块之间做自注意力。

4. 记忆机制

如 Transformer-XL,使用额外的记忆存储历史信息,避免重复计算。

5. 高效 Transformer

  • Linformer:低秩近似
  • Performer:核方法
  • FNet:用傅里叶变换替代注意力

Q8: 为什么 BERT 用 Encoder-only, GPT 用 Decoder-only?

A: 这与它们的预训练任务相关:

BERT( Encoder-only): - 预训练任务:Masked Language Modeling( MLM) - 需要双向上下文来预测被遮盖的词 - 适合理解任务(分类、 NER 、问答)

GPT( Decoder-only): - 预训练任务:Causal Language Modeling( CLM) - 根据前文预测下一个词,只能看到左侧上下文 - 适合生成任务(续写、对话、代码生成)

设计哲学: - 如果任务需要理解双向上下文 → 用 Encoder - 如果任务需要自回归生成 → 用 Decoder - 如果任务是序列到序列转换 → 用 Encoder-Decoder

Q9: Transformer 能否处理其他模态(如图像、音频)?

A: 完全可以! Transformer 的核心是自注意力机制,不限于文本。

图像: - Vision Transformer (ViT):将图像切分成固定大小的 patch(如 16x16),每个 patch 展平成向量,加上位置编码,输入 Transformer - 效果:在大规模数据上, ViT 的性能超越 CNN

音频: - Whisper( OpenAI):用 Transformer 处理音频的梅尔频谱图,实现语音识别和翻译 - Wav2Vec 2.0:直接从原始音频波形学习表示

多模态: - CLIP:同时处理图像和文本,学习统一的嵌入空间 - Flamingo:结合视觉和语言 Transformer,实现视觉问答

关键技巧: 1. 将其他模态转换为序列表示( patch 、 token) 2. 设计合适的位置编码(如 2D 位置编码用于图像) 3. 根据模态特性调整模型结构

Q10: Transformer 的局限性是什么?

A: 尽管 Transformer 非常强大,但仍有局限:

1. 复杂度 的复杂度使得处理长序列(如长文档、高分辨率图像)成本高昂。

2. 缺乏归纳偏置

RNN 有时序偏置, CNN 有局部性偏置。 Transformer 缺乏这些先验,需要更多数据和计算才能学到这些模式。

3. 位置编码的局限

固定的正弦位置编码无法泛化到极长序列;可学习位置编码无法外推。

4. 过拟合小数据

由于参数量大,在小数据集上容易过拟合,需要大量预训练或正则化。

5. 可解释性

虽然可以可视化注意力权重,但多层多头的复杂交互仍难以完全解释。

6. 能耗

训练大型 Transformer(如 GPT-3 的 1750 亿参数)需要巨大的计算资源,环境成本高。

未来方向: - 高效 Transformer 变体(线性复杂度) - 更好的位置编码(如相对位置编码) - 与其他架构的混合(如 Transformer + CNN) - 蒸馏和量化技术降低推理成本

总结

Transformer 的出现是 NLP 历史上的里程碑。通过完全基于注意力机制的架构,它解决了 RNN 的顺序处理瓶颈和长距离依赖问题,开启了预训练大模型的时代。

核心要点回顾:

  1. 注意力机制解决了 Seq2Seq 的信息瓶颈,允许模型动态关注输入的不同部分
  2. Self-Attention 通过 Query 、 Key 、 Value 三元组,让序列内部位置直接交互
  3. Scaled Dot-Product Attention 通过缩放因子 稳定训练
  4. Multi-Head Attention 让模型同时学习多种类型的关系
  5. 位置编码为无序的注意力机制注入顺序信息
  6. 残差连接和 Layer Norm 保证深层网络的训练稳定性
  7. Encoder-Decoder 结构分工明确:编码器理解输入,解码器生成输出

Transformer 不仅统治了 NLP 领域( BERT 、 GPT 、 T5),还扩展到了计算机视觉( ViT 、 DINO)、语音( Whisper 、 Wav2Vec)、多模态( CLIP 、 Flamingo)等多个领域。理解 Transformer 的原理和实现,是深入现代深度学习的必修课。

随着技术的发展,高效 Transformer 、长上下文模型、多模态模型等方向正在快速演进。但无论如何变化, Transformer 的核心思想——让数据自己说话,通过注意力机制学习关系——将继续指引未来的研究方向。

  • 本文标题:自然语言处理(四)—— 注意力机制与 Transformer
  • 本文作者:Chen Kai
  • 创建时间:2024-02-20 15:45:00
  • 本文链接:https://www.chenk.top/%E8%87%AA%E7%84%B6%E8%AF%AD%E8%A8%80%E5%A4%84%E7%90%86%EF%BC%88%E5%9B%9B%EF%BC%89%E2%80%94%E2%80%94-%E6%B3%A8%E6%84%8F%E5%8A%9B%E6%9C%BA%E5%88%B6%E4%B8%8ETransformer/
  • 版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
 评论