参考: The Illustrated Transformer
Transformer 背景
Transformer 由 Google 团队在 2017 年论文《Attention Is All You Need》中提出,核心是完全基于自注意力机制,摒弃传统 RNN/LSTM 的循环结构与 CNN 的卷积结构。此前主流序列模型(如 RNN/LSTM)存在两大痛点:
- 并行性差:需逐词处理,无法充分利用 GPU/TPU 的并行计算能力,训练效率低。
- 长程依赖建模难:时序传递导致远距离语义关联捕捉能力弱,梯度易消失 / 爆炸。
Transformer 通过自注意力直接建模序列中任意位置的依赖,同时支持全局并行计算,彻底解决上述问题。
Transformer 核心任务
核心任务:序列到序列(Seq2Seq)转换,典型场景为机器翻译(如英→德、英→法)。
高级视角
让我们从将模型视为一个单一的黑盒开始。在机器翻译应用中,它会接收一种语言的句子,
并输出另一种语言的翻译。

打开那个 Transformer 的精彩之处,我们看到一个编码组件、一个解码组件以及它们之间的连接。

编码组件由一系列编码器堆叠而成(此处纸张上共堆放了六层编码器 (Encoder) 一一数字六并无任何神奇
之处,人们完全可以尝试采用其他排列方式)。解码组 (Decoder) 件则由同样数量的解码器堆叠而成。

编码器 (Encoder) 在结构上完全相同(但它们不共享权重)。每个编码器都分解为两个子层:

编码器 (Encoder) 的输入首先会流经一个自注意力层一一这一层有助于编码器在编码某个特定单词时同时关
注输入句子中的其他单词。我们将在后续内容中更详细地探讨自注意力机制。自注意力层的输出被馈送到一个前向神经网络。完全相同的前向网络独立应用于每个位置。
解码器 (Decoder) 包含这两个层,但它们之间有一个注意力层,帮助解码器聚焦于输入句子的相关部分(类似于seq2seq模型中注意力的作用)。

张量角度研究
既然我们已经了解了模型的主要组成部分,接下来就让我们开始研究各种向量/张量 (Tensor) ,以及它们如何在这些组成部分之间流动,从而将训练好的模型的输入转化为输出。
正如一般NLP应用的情况一样,我们首先通过嵌入算法将每个输入词转换为向量。

每个单词都被嵌入到一个大小为512的向量中。我们将用这些简单的框来表示这些向量。
编码器的嵌入与数据流转逻辑
- 嵌入仅发生在最底层编码器
所有编码器的共性抽象是:它们都会接收一个"向量列表",每个向量的维度为512。
- 最底层编码器接收的是词嵌入向量;
- 其他编码器接收的是其正下方编码器的输出结果。
-
向量列表的长度是超参数
这个列表的长度是可设置的超参数,通常会设为训练数据集中最长句子的长度。
-
输入序列的流转路径
输入序列中的每个词完成嵌入后,会依次流经编码器的两层结构。

在这里我们开始看到Transformer的一个关键特性:每个位置上的词会在编码器中流经独立的路径。在自注意力层中,这些路径之间存在依赖关系。不过,前馈层并不存在这类依赖,因此各路径在前馈层中可以并行执行。
接下来,我们会换一个更短的句子作为示例,进而探究编码器各子层中的具体变化。
现在开始编码!
正如我们之前所提及的,编码器会接收一个向量列表作为输入。它会通过以下方式处理这个列表:先将这些向量传入"自注意力"层,再传入前馈神经网络,随后将输出结果向上传递给下一个编码器。

自注意力机制概述
别被我随口用"自注意力"这个词误导了,好像这是个大家都该熟悉的概念似的。我自己也是在看《Attention is All You Need》这篇论文时,才第一次接触到这个概念。下面我们来梳理它的工作原理。
假设我们要翻译的输入句子是:
"The animal didn't cross the street because it was too tired"("这只动物没过马路,因为它太累了")
这个句子里的"it"指代什么?是街道还是动物?对人类来说这是个简单的问题,但对算法而言就没那么容易了。
当模型处理"it"这个词时,自注意力机制能让它把"it"和"animal"关联起来。
在模型处理每个词(输入序列中的每个位置)时,自注意力机制允许它查看输入序列中的其他位置,获取有助于优化当前词编码的线索。
如果你熟悉循环神经网络(RNN),可以联想一下:RNN是通过维护隐藏状态,将已处理词/向量的表示与当前处理的词结合起来。而自注意力机制,就是Transformer用来将"对其他相关词的理解"融入当前处理词的方法。

在第5个编码器(即编码器栈的顶层编码器)中对"it"这个词进行编码时,注意力机制的一部分会聚焦于"The Animal",并将其表示的一部分融入到"it"的编码结果中。
一定要查看Tensor2Tensor noteboo,在那里你可以加载一个Transformer模型,并通过这个交互式可视化工具对其进行分析。
自注意力机制详解
首先,我们先了解如何通过向量计算自注意力,再介绍它的实际实现方式------借助矩阵运算。
计算自注意力的第一步,是从编码器的每个输入向量(此处即每个词的嵌入向量)生成三个向量:对于每个词,我们会生成一个查询向量(Query)、一个键向量(Key)和一个值向量(Value)。这些向量是通过将词嵌入向量与训练过程中习得的三个矩阵相乘得到的。
需要注意的是,这些新生成的向量维度小于嵌入向量:它们的维度是64,而嵌入向量与编码器的输入/输出向量维度为512。不过这并非必须,选择更小的维度是一种架构设计,目的是让多头注意力的计算量(基本)保持稳定。

将x1与WQ权重矩阵相乘,会得到q1------即与该词对应的"查询"向量。最终,我们会为输入句子中的每个词生成对应的"查询"、"键"和"值"投影向量 W。
"查询""键""值"向量是什么?
它们是用于计算和理解注意力机制的抽象概念。等你看完下文关于注意力计算的内容,就能充分了解这些向量各自扮演的角色。
计算自注意力的第二步:计算得分
假设我们要为示例中的第一个词"Thinking"计算自注意力,需要将该词与输入句子中的每个词进行"打分"。这个分数决定了我们在编码某个位置的词时,要对输入句子其他部分投入多少注意力。
分数的计算方式是:将当前词的查询向量与待打分词的键向量做点积。比如,在处理位置1的词的自注意力时,第一个分数是q1与k1的点积,第二个分数是q1与k2的点积。

第三步与第四步操作
-
第三步:对分数进行缩放
将得分除以8(这是论文中所用键向量维度64的平方根),目的是让梯度更稳定。这里也可以选择其他数值,但8是默认选项。
-
第四步:执行Softmax操作
将缩放后的结果传入Softmax函数,该操作会对得分进行归一化处理,使所有得分均为正数且总和为1。

这个softmax得分决定了每个词在当前位置的表达程度。显然,当前位置的词会拥有最高的softmax得分,但有时关注与当前词相关的其他词也是很有用的。
第五步操作
第五步是将每个值向量与softmax得分相乘(为后续求和做准备)。这里的思路是:保留我们想要关注的词的值不变,同时弱化无关词的值(比如将它们乘以0.001这样的极小数值)。
第六步操作
第六步是将加权后的值向量求和。这一步会生成当前位置(针对第一个词)自注意力层的输出结果。

到这里,自注意力的计算就完成了。得到的向量可以传递给前馈神经网络。不过在实际实现中,为了加快处理速度,这一计算会以矩阵形式进行。既然我们已经理解了词级别计算的思路,现在就来看看矩阵形式的实现方式。
自注意力的矩阵计算
第一步是计算查询(Query)、键(Key)和值(Value)矩阵。具体做法是:将词嵌入向量打包成矩阵X,再将其与训练得到的权重矩阵( W Q W^Q WQ、 W K W^K WK、 W V W^V WV)相乘。

X 矩阵中的每一行对应输入句子中的一个词。我们再次看到了 embedding vector 嵌入向量(维度为512,对应图中的4个方框)与 q / k / v 向量(维度为64,对应图中的3个方框)在尺寸上的差异。
最后,由于我们是基于矩阵进行运算,因此可以将第二步到第六步的操作浓缩为一个公式,用于计算自注意力层的输出。

矩阵形式的自注意力计算。
Transformer的多头
这篇论文通过加入"多头"注意力机制,进一步优化了自注意力层。这一机制从两方面提升了注意力层的性能:
- 拓展了模型关注不同位置的能力。在之前的示例中,z1包含了其他所有编码的信息,但可能会被当前词本身的编码主导。比如翻译
"The animal didn't cross the street because it was too tired"这句话时,明确"it"指代哪个词会很有帮助。 - 为注意力层提供了多个"表示子空间"。接下来我们会看到,多头注意力机制下,我们不再只有一组查询/键/值权重矩阵,而是有多组(Transformer使用8个注意力头,因此每个编码器/解码器对应8组)。每组矩阵都是随机初始化的,训练完成后,每组会被用于将输入嵌入(或来自下层编码器/解码器的向量)投影到不同的表示子空间中。

在多头注意力机制中,我们为每个注意力头单独设置 Q / K / V 权重矩阵,从而得到不同的 Q / K / V 矩阵。和之前的操作一样,我们将 X 与 W Q / W K / W V W^Q / W^K / W^V WQ/WK/WV 矩阵相乘,以生成 Q / K / V 矩阵。
如果我们执行上述提到的自注意力计算,只是用不同的权重矩阵重复八次(8 个注意力头),最终会得到八个不同的Z矩阵。

这给我们带来了一个小挑战。前馈层并不期望接收八个矩阵------
它需要的是一个单一矩阵(每个词对应一个向量)。所以我们需要一种方法,把这八个矩阵压缩成一个单一矩阵。
该怎么做呢?我们先将这些矩阵拼接起来,再将其与一个额外的权重矩阵 W O W^O WO 相乘。

上图的操作是:
- 拼接所有注意力头(对应图中:Z₀、Z₁、Z₂、Z₃、Z₄、Z₅、Z₆、Z₇ 这些矩阵拼接)
- 与一个随模型共同训练的权重矩阵 W O W^O WO 相乘(对应图中:拼接后的矩阵与 W O W^O WO 矩阵相乘)
- 最终结果就是融合了所有注意力头信息的 Z 矩阵,我们可以将其传入前馈神经网络(FFNN)(对应图中:输出的 Z 矩阵)
这差不多就是多头自注意力机制的全部内容了。我知道这里涉及的矩阵数量不少,我尝试把它们都整合到一张可视化图里,这样就能在同一个地方查看所有矩阵了。
- 这是我们的输入句子
- 对每个词进行嵌入处理
- 拆分为 8 个注意力头。我们将 X 或 R 与权重矩阵相乘
- 利用得到的 Q / K / V 矩阵计算注意力
- 拼接得到的 Z 矩阵,再与权重矩阵 W O W^O WO 相乘,生成该层的输出
除了第 0 个编码器外,其他所有编码器都不需要向量词嵌入操作(Embedding)。我们直接从其正下方编码器的输出开始

既然我们已经了解了注意力头,现在回到之前的示例,看看在对示例句子中的"it"进行编码时,不同的注意力头分别聚焦于何处:

在我们对"it"这个词进行编码时,一个注意力头主要聚焦在"the animal"上,而另一个则聚焦在"tired"上------从某种意义上说,模型对"it"这个词的表征,融合了"animal"和"tired"两者的部分表征。
不过,如果我们把所有注意力头都加到图里,内容的解读难度就会变大:

利用位置编码表示序列顺序
到目前为止,我们所描述的模型存在一个缺陷:它缺少一种表示输入序列中词序的方式。
为解决这一问题,Transformer 会给每个输入嵌入向量添加一个向量。这些向量遵循模型学到的特定模式,帮助模型确定每个词的位置,或是序列中不同词之间的距离。其核心思路是:将这些值添加到嵌入向量后,当嵌入向量被投影为 Q / K / V 向量、以及在点积注意力计算过程中,嵌入向量之间会形成有意义的距离。

为了让模型感知词的顺序,我们会添加位置编码向量------这些向量的值遵循特定的模式。
假设嵌入的维度是4,实际的位置编码会是这样的:

一个嵌入维度为4的简易示例对应的位置编码实例。
这个模式具体是什么样的呢?
在下面的图中,每一行对应一个位置编码向量。也就是说,第一行就是我们要添加到输入序列中第一个词的嵌入向量上的向量。每一行包含512个值------每个值都介于 1 和 -1 之间。我们对这些值进行了颜色编码,以便清晰呈现其模式。

这是针对 20 个词(对应行)、嵌入维度为 512(对应列)的位置编码实例。
你可以看到它在中间位置被分成了两部分,这是因为左半部分的值由一个函数(使用正弦函数)生成,
右半部分则由另一个函数(使用余弦函数)生成,之后这两部分会被拼接起来,构成每个位置编码向量。
Transformer的残差操作
在继续介绍之前,我们需要提一下编码器架构中的一个细节:每个编码器中的每个子层(自注意力层、前馈层)都带有残差连接,且子层之后会紧跟一个层归一化步骤。

如果我们要将自注意力相关的向量与层归一化操作可视化,效果会是这样的:

这一点同样适用于解码器的子层。要是我们构想一个包含2个堆叠编码器和解码器的Transformer,它的结构大概会是这样的:

Transformer的解码器
既然我们已经介绍了编码器侧的大部分概念,基本上也能理解解码器各组件的工作原理了。不过我们还是来看看这些组件是如何协同工作的。
编码器先对输入序列进行处理,最顶层编码器的输出会被转换为一组注意力向量K和V。这些向量会被每个解码器用于其"编码器-解码器注意力"层,帮助解码器聚焦于输入序列中的合适位置:

完成编码阶段后,我们进入解码阶段。解码阶段的每一步都会从输出序列(此处指英语翻译句子)中输出一个元素。
后续步骤会重复这一过程,直到遇到一个特殊符号------它标志着Transformer解码器已完成输出。每一步的输出会作为下一个时间步的输入传入最底层的解码器,而解码器会像编码器那样向上传递其解码结果。并且和处理编码器输入时一样,我们会对这些解码器输入进行嵌入处理并添加位置编码,以此标识每个词的位置。
解码器中的自注意力层与编码器中的自注意力层运作方式略有不同:
在解码器中,自注意力层仅被允许关注输出序列中更早的位置。这是通过在自注意力计算的softmax步骤前对后续位置进行掩码(将其设为 -inf)来实现的。
"编码器-解码器注意力"层的工作方式与多头自注意力类似,不同之处在于它会从其下一层生成查询矩阵,并从编码器堆叠的输出中获取键和值矩阵。
最后的线性层与Softmax层
解码器堆叠会输出一个浮点数向量。如何将这个向量转化为单词呢?这就是最后线性层的任务,而线性层之后会紧跟一个Softmax层。
线性层是一个简单的全连接神经网络,它会将解码器堆叠生成的向量,映射为一个维度大得多的向量,称为logits向量。
假设我们的模型掌握了10,000个独特的英语单词(即模型的"输出词汇表"),这些单词是从训练数据中学到的。这会让logits向量的宽度达到10,000个单元------每个单元对应一个独特单词的得分。这就是我们对线性层输出的解读方式。
随后,Softmax层会将这些得分转化为概率(所有概率均为正数,且总和为1.0)。我们会选择概率最高的单元,将其对应的单词作为当前时间步的输出。

这张图从底部的解码器堆叠输出向量开始,随后这个向量会被转换为一个输出单词。
- 我们词汇表中的哪个单词与这个索引对应?
- 获取值最高的单元的索引(argmax)
- log_probs(对数概率)
- vocab_size(词汇表大小)
- Softmax(激活函数)
- logits(对数几率向量)
- Linear(线性层)
- Decoder stack output(解码器堆叠输出)
训练流程回顾
我们已经介绍了训练好的Transformer的完整前向传播过程,现在简要了解一下模型训练的核心思路会很有帮助。
在训练阶段,未训练的模型会执行完全相同的前向传播流程。但由于我们是在带标签的训练数据集上训练模型,所以可以将其输出与实际的正确输出进行对比。
为了更直观地说明,我们假设输出词汇表只包含6个单词("a" "am" "I" "thanks" "student",以及""------即"sentence end(句子结束)"的缩写)。

我们模型的输出词汇表是在预处理阶段创建的,甚至在开始训练之前就已经完成了。
一旦我们定义好输出词汇表,就可以用一个宽度相同的向量来表示词汇表中的每个单词。这也被称为独热编码。例如,我们可以用以下向量来表示单词 "am":

示例:我们输出词汇表的独热编码
在这次回顾之后,我们来讨论模型的损失函数------这是我们在训练阶段优化的指标,目的是得到一个训练好且准确率尽可能高的模型。
损失函数
假设我们正在训练模型。这是训练阶段的第一步,我们用一个简单的示例来训练它------将"merci"翻译成"thanks"。
这意味着,我们希望输出是一个指向"thanks"这个单词的概率分布。但由于模型尚未经过训练,目前还不太可能实现这一点。

由于模型的参数(权重)都是随机初始化的,(未训练的)模型会生成一个每个单元/单词对应值都很随意的概率分布。
我们可以将其与实际输出进行对比,然后通过反向传播调整模型的所有权重,让输出更接近预期结果。
怎么比较两个概率分布呢?我们只需用其中一个减去另一个。想了解更多细节,可以查看交叉熵 和KL散度。
但要注意,这是个过度简化的例子。更实际的情况是,我们会使用长度超过一个单词的句子。比如------输入:"je suis étudiant",预期输出:"i am a student"。这实际上意味着,我们希望模型依次输出概率分布,要求如下:
- 每个概率分布由一个宽度为词汇表大小的向量表示(在我们这个简易示例中是6个字,但实际中通常是30000或50000这样的数字)
- 第一个概率分布中,与单词"i"对应的单元概率最高
- 第二个概率分布中,与单词"am"对应的单元概率最高
- 以此类推,直到第五个输出分布指向" < EOS > "符号,该符号在包含10000个元素的词汇表中也有对应的单元

在这个单句样本的训练示例(Target)中,我们用来训练模型的目标概率分布。
在足够大的数据集上对模型进行足够时间的训练后,我们希望生成的概率分布会是这样的:

希望经过训练后,模型能输出我们预期的正确翻译。
当然,如果这个短语本身就在训练数据集中,这并不能真正体现模型的性能(请查看交叉验证)。
注意,每个位置都会分到一点概率,哪怕它不太可能是该时间步的输出------------
这是softmax一个非常有用的特性,对训练过程很有帮助。
现在,由于模型是逐个生成输出的,我们可以认为模型会从概率分布中选择概率最高的单词,然后舍弃其余部分。这是一种实现方式(称为"贪心解码(greedy decoding)")。
另一种方式是保留排名靠前的几个单词(比如前两个,例如 "i" 和 "a" ),然后在下一步中运行两次模型:一次假设第一个输出位置是单词 "i",另一次假设第一个输出位置是单词"a",然后保留同时考虑第 1 和第 2 个位置时误差更小的那个版本。我们会对第2、3个位置重复这个过程......以此类推。
这种方法称为"束搜索(beam search) ",在我们的示例中,束大小(beam_size)是2(意味着始终在内存中保留两个部分假设,即未完成的翻译),而输出束数(top_beams)也是2(意味着我们会返回两个翻译结果)。这些都是可以调整实验的超参数。

还可以用维特比解码等,其他章节在再介绍。
Transformer论文图

图片核心内容解读
该图片展示的是Transformer模型的经典架构图(对应2017年论文《Attention Is All You Need》提出的原始结构),清晰呈现了"编码器-解码器"双端框架及各核心组件的层级关系,是理解Transformer工作原理的关键可视化参考。
图片需重点关注的8个核心要点
1. 整体架构:编码器-解码器对称设计
- 左侧为编码器(Encoder):处理输入序列(Inputs),核心是"多头注意力(Multi-Head Attention)+ 前馈网络(Feed Forward)+ 残差连接与层归一化(Add & Norm)"的堆叠结构(标注"Nx",表示N个相同编码器层重复,论文中N=6)。
- 右侧为解码器(Decoder):生成目标序列(Outputs),在编码器组件基础上新增"掩码多头注意力(Masked Multi-Head Attention)",同样以"Nx"表示N个解码器层堆叠,确保与编码器层数匹配。
2. 输入预处理:嵌入与位置编码的必选步骤
- 嵌入层(Embedding) :
- 编码器侧"Input Embedding"将输入序列的离散token(如单词、子词)转换为连续的向量表示(通常为512维,即
d_model); - 解码器侧"Output Embedding"处理目标序列,但需注意输入为"shifted right(右移一位)"的序列------这是为了避免解码器在生成第t个token时提前获取第t个及后续的"未来信息",符合自回归生成逻辑。
- 编码器侧"Input Embedding"将输入序列的离散token(如单词、子词)转换为连续的向量表示(通常为512维,即
- 位置编码(Positional Encoding) :
- 因Transformer无循环结构,无法天然感知token位置,需通过正弦/余弦函数生成固定位置向量(公式参考搜索摘要1、4、6),并与嵌入向量相加(而非拼接),确保模型捕捉序列的时序关系。
3. 编码器核心:多头注意力的"全局依赖建模"
- Multi-Head Attention(多头注意力) :
- 是编码器的核心,让每个token能"关注"输入序列中所有其他token,直接建模全局语义关联(如"我爱北京天安门"中,"爱"可同时关联"我""北京""天安门");
- 原理是将输入向量投影到8个(论文默认值)不同子空间,并行计算注意力后拼接,再通过线性层输出,可捕捉多维度的依赖关系(如语法结构、语义相似性)。
- Add & Norm的强制绑定 :
每个注意力层、前馈网络层后均紧跟"Add(残差连接:输入+子层输出)"和"Norm(层归一化)",作用是缓解深层网络梯度消失、稳定训练,这是Transformer能堆叠多层的关键设计。
4. 解码器核心1:掩码多头注意力的"防信息泄露"
- Masked Multi-Head Attention(掩码多头注意力) :
- 是解码器独有的组件,在多头注意力计算前,对"未来位置"(如生成第3个token时,第4、5...个token的位置)添加负无穷掩码(-∞),使Softmax后这些位置的注意力权重趋近于0;
- 核心目的:确保自回归生成的"单向性",避免模型"偷看"未来信息(如翻译"Je suis étudiant"为"I am a student"时,生成"am"只能依赖前一个token"I",而非后续的"a""student")。
5. 解码器核心2:编码器-解码器注意力的"跨序列关联"
- Encoder-Decoder Attention(编码器-解码器注意力) :
- 是连接编码器与解码器的关键,让解码器的每个token能"关注"编码器输出的输入序列特征;
- 具体机制:注意力计算的"查询(Query)"来自解码器前一层输出,"键(Key)"和"值(Value)"来自编码器的最终输出------如翻译任务中,解码器生成"student"时,可精准关联编码器中"étudiant"(法语"学生")的特征,确保输入与输出的语义对齐。
6. 前馈网络:独立处理的"局部特征精修"
- Feed Forward(前馈网络) :
- 结构为"两层全连接网络+ReLU激活"(公式:
FFN(x) = max(0, xW₁ + b₁)W₂ + b₂),作用是对注意力层输出的"全局特征"进行独立的非线性变换(每个token的处理互不干扰); - 核心是"精修局部细节",补充注意力层未捕捉到的局部语义模式,如对"红色苹果"中"红色"与"苹果"的属性关联进行强化。
- 结构为"两层全连接网络+ReLU激活"(公式:
7. 输出层:从向量到概率分布的转换
- 解码器最终输出需经过Linear(线性层) 和Softmax :
- 线性层将解码器输出的512维向量映射到"目标词汇表大小"的维度(如30k、50k维,即
vocab_size); - Softmax将线性层输出的"对数几率(logits)"转换为概率分布(所有概率和为1),模型选择概率最高的token作为当前步的输出。
- 线性层将解码器输出的512维向量映射到"目标词汇表大小"的维度(如30k、50k维,即
8. 组件重复标识"Nx":层数的可扩展性
- 图中编码器、解码器均标注"Nx",表示核心组件(如编码器的"多头注意力+前馈网络"、解码器的"掩码多头注意力+编码器-解码器注意力+前馈网络")需重复N次;
- 原始论文中N=6,但后续模型(如GPT-3)可根据任务需求增加层数(如96层),需注意"编码器与解码器层数必须一致",否则无法通过"编码器-解码器注意力"建立有效关联。

这张图展示了Transformer的核心组件------缩放点积注意力(Scaled Dot-Product Attention)和多头注意力(Multi-Head Attention),是理解Transformer"注意力机制"的关键可视化参考,以下是需重点关注的要点:
一、左侧:缩放点积注意力(Scaled Dot-Product Attention)
这是Transformer中注意力计算的基础单元,核心是计算"查询(Q)"与"键(K)"的关联程度,再用该关联度对"值(V)"加权求和。
1. 输入与流程
- 输入是3个向量矩阵:
- Q(Query,查询):当前token的特征向量(如"我"的向量);
- K(Key,键):所有token的特征向量(如"我""爱""北京"的向量);
- V(Value,值):所有token的特征向量(通常与K共享参数或结构一致)。
- 计算流程:
MatMul(Q×K^T):计算Q与每个K的点积,得到"原始注意力得分"(体现Q与K的关联强度);Scale(除以√d_k):关键操作 ------因点积会随K的维度d_k(通常为64,是d_model=512除以头数8的结果)增大而数值膨胀,导致Softmax后梯度消失,除以 d k \sqrt{d_k} dk 可稳定数值范围;Mask(可选):仅在解码器的"掩码注意力"中使用,对"未来位置"的得分加负无穷掩码(-inf),避免模型"偷看"后续信息(只有 "解码器的自注意力" 需要这个 Mask);SoftMax:将得分转换为概率分布(所有位置的权重和为1),体现Q对每个K的"关注程度";MatMul(*V):用注意力权重对 V 加权求和,得到当前 Q 的"注意力输出"。
为什么只有 "解码器的自注意力" 需要这个 Mask?
要结合不同注意力的作用场景来看:
- 解码器的自注意力:作用是让解码器的当前 token 关注 "目标序列中已生成的 token"(比如生成第 3 个 token 时,只能看第 1、2 个 token)。
- 如果不加 Mask,模型会 "偷看" 目标序列中第 3 个及之后的 "未来 token",违背 "自回归生成" 的逻辑(生成过程是从左到右、依次依赖前文),所以必须用未来位置掩码 "屏蔽未来信息"。
- 编码器的自注意力:作用是让输入序列的任意 token 关注整个输入序列(比如 "我爱北京" 中,"爱" 可以同时关注 "我""北京")。
- 输入序列是 "完整已知" 的,不存在 "未来信息",因此不需要 Mask,模型需要建模全局依赖关系。
- 编码器 - 解码器注意力:作用是让解码器的当前 token 关注整个输入序列(比如翻译时,生成 "student" 要关注输入的 "étudiant")。
- 输入序列是 "完整已知" 的,同样不存在 "未来信息",因此不需要 Mask,模型需要基于整个输入序列的特征来生成当前 token。
只有解码器的自注意力需要 "未来位置掩码",目的是避免模型在自回归生成时获取 "尚未生成的未来 token 信息";而编码器的自注意力、编码器 - 解码器注意力,因关注的是 "完整已知的序列",所以不需要这种 Mask。
2. 需注意的细节
- Q/K/V的维度匹配 :Q和K必须是同维度(
d_k),V的维度d_v通常与d_k相同; - Mask的作用场景 :仅在解码器的自注意力中启用,编码器的自注意力、编码器-解码器注意力无需Mask;
- "缩放"是必选项:这是"缩放点积注意力"与普通点积注意力的核心区别,是Transformer能稳定训练的关键设计之一。
二、右侧:多头注意力(Multi-Head Attention)
这是对"缩放点积注意力"的扩展,让模型同时捕捉多维度的语义关联(如语法、语义、位置等)。
1. 流程
- 输入Q/K/V先通过独立的Linear层 ,分别投影到
h个(通常为8)不同的子空间,得到h组Q/K/V; - 对每组Q/K/V并行计算缩放点积注意力 (共
h次独立计算); - 将
h个注意力输出拼接(Concat),再通过一个Linear层整合,得到最终的多头注意力输出。
2. 需注意的细节
- "多头"的意义 :每个头从不同特征维度关注信息(如一个头关注"主谓搭配",另一个头关注"动宾搭配"),避免单头注意力的信息瓶颈;
- 参数共享与维度 :每个头的Linear层是独立参数,拼接后的维度需与输入
d_model一致(如8个头,每个头输出64维,拼接后为512维); - "h"是超参数 :头数
h需能整除d_model(如512÷8=64),通常取值为8、16等,需根据任务调整。
三、整体关联:从基础单元到复合组件
- 多头注意力是 以缩放点积注意力为"子模块" 构建的,二者是"基础操作 -- 复合操作"的关系;
- 编码器的自注意力、解码器的自注意力、编码器-解码器注意力,本质都是"多头注意力",区别仅在于 Q / K / V 的来源(如编码器-解码器注意力的 Q 来自解码器,K / V 来自编码器)。
这些设计的核心目标是:让模型能高效、多维度地建模序列中任意两个token的依赖关系,这也是Transformer超越传统RNN的关键优势。
代码角度研究(Transformer论文图对应)
1. 导入依赖库
python
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
import math
- numpy:用于数值计算,如生成掩码矩阵、位置编码的数学运算
- torch:PyTorch核心库,提供张量操作、自动求导等功能
- torch.nn:PyTorch神经网络模块,包含层、激活函数、损失函数等
- torch.optim:优化器模块,提供Adam等优化算法
- math:基础数学运算库,用于位置编码的对数、指数计算
2. 生成批次数据
python
def make_batch(sentences):
# 帮助给文本数据预先处理成网络所可以处理的形式的简单例子
# :param sentences: 输入句子、输出句子和目标句子
# :return: 句子中单词在词汇表中的索引
input_batch = [[src_vocab[n] for n in sentences[0].split()]]
output_batch = [[tgt_vocab[n] for n in sentences[1].split()]]
target_batch = [[tgt_vocab[n] for n in sentences[2].split()]]
return torch.LongTensor(input_batch), torch.LongTensor(output_batch), torch.LongTensor(target_batch)
- 功能:将文本句子转换为词汇表索引的张量,适配模型输入格式
- 输入:包含源句子、目标输入句子、目标标签句子的列表
- 输出:三个LongTensor类型的批次数据(编码器输入、解码器输入、目标标签)
- 细节:通过词汇表映射将单词转为数字索引,是NLP任务的基础数据预处理步骤
3. 生成后续掩码
python
def get_attn_subsequent_mask(seq):
# 生成"后续掩码",防止模型在预测序列中的元素时"偷看"到后续的元素
# seq: [batch_size, tgt_len] seq序列数据的维度 批次大小*目标序列长度
# attn_shape: [batch_size, tgt_len, tgt_len] 注意力矩阵的形状
attn_shape = [seq.size(0), seq.size(0), seq.size(0)]
# 生成一个上三角符号矩阵,对角线之下全是0,以上是1,保证每个元素看不到其后面的元素
subsequence_mask = np.triu(np.ones(attn_shape), k=1)
# NumPy数组→Torch 张量→布尔类型
subsequence_mask = torch.from_numpy(subsequence_mask).byte()
# 返回形状:[batch_size, tgt_len, tgt_len]
return subsequence_mask
- 功能:生成解码器自注意力的后续掩码,避免模型看到未来的token
- 输入:解码器输入序列([batch_size, tgt_len])
- 核心逻辑 :
- 生成上三角矩阵(k=1表示对角线以上为1),1代表需要遮挡的位置
- 转换为PyTorch布尔张量,后续在注意力计算中会将这些位置设为极小值
- 输出:[batch_size, tgt_len, tgt_len]的掩码矩阵
4. 缩放点积注意力
python
class ScaledDotProductAttention(nn.Module):
def __init__(self):
# 初始化 ScaledDotProductAttention 类的实例,并确保它作为 nn.Module 的子类拥有所有必要的功能和特性
super(ScaledDotProductAttention, self).__init__()
def forward(self, Q, K, V, attn_mask):
# 缩放点积计算注意力分数,给mask
# 输入进来的维度分别是:
# Q: [batch_size x n_heads x len_q x d_k]
# K: [batch_size x n_heads x len_k x d_k]
# V: [batch_size x n_heads x len_k x d_v]
# scores首先经过matmul函数得到 [batch_size x n_heads x len_q x len_k]
scores = torch.matmul(Q, K.transpose(-1, -2)) / np.sqrt(d_k)
# 应用mask,将mask对应的位置设为极小值
scores.masked_fill_(attn_mask, -1e9)
attn = nn.Softmax(dim=-1)(scores)
context = torch.matmul(attn, V)
return context, attn
- 功能:实现Transformer核心的缩放点积注意力机制
- 初始化方法:继承nn.Module并初始化父类,无额外参数
- 前向传播逻辑 :
- 计算Q和K的点积,除以√d_k(缩放因子,防止点积值过大)
- 应用掩码:将mask位置的分数设为-1e9,Softmax后权重趋近于0
- Softmax归一化得到注意力权重,与V加权求和得到上下文向量
- 输入:Q(查询)、K(键)、V(值)、attn_mask(掩码矩阵)
- 输出:context(上下文向量)、attn(注意力权重)
5. 多头注意力
python
class MultiHeadAttention(nn.Module):
def __init__(self):
super(MultiHeadAttention, self).__init__()
# 输入进来的QKV是相等的,我们会使用映射线性层给每个映射到多个的Q,Wk,Wv
self.W_Q = nn.Linear(d_model, d_k * n_heads)
self.W_K = nn.Linear(d_model, d_k * n_heads)
self.W_V = nn.Linear(d_model, d_v * n_heads)
self.Linear = nn.Linear(n_heads * d_v, d_model)
self.layer_norm = nn.LayerNorm(d_model)
def forward(self, Q, K, V, attn_mask):
# 这个多头分为这几个步骤,首先映射分头,然后计算attn_scores,然后计算attn_value;
# 输入进来的数据形状:
# Q: [batch_size x len_q x d_model]
# K: [batch_size x len_k x d_model]
# V: [batch_size x len_k x d_model]
residual, batch_size = Q, Q.size(0)
# (B, S, D) -proj-> (B, S, D) -split-> (B, S, H, W) -trans-> (B, H, S, W)
# 下面这个是先映射,后分头,一定要注意的是分头之后维度是一致的,所以一看这里都是dk
q_s = self.W_Q(Q).view(batch_size, -1, n_heads, d_k).transpose(1, 2) # q_s: [batch_size x n_heads x len_q x d_k]
k_s = self.W_K(K).view(batch_size, -1, n_heads, d_k).transpose(1, 2) # k_s: [batch_size x n_heads x len_k x d_k]
v_s = self.W_V(V).view(batch_size, -1, n_heads, d_v).transpose(1, 2) # v_s: [batch_size x n_heads x len_k x d_v]
# 输入进行的attn_mask形状是 batch_size x len_q x len_k,
# 然后经过下面这个代码得到 新的attn_mask :[batch_size x n_heads x len_q x len_k],
# 就是把pad信息重复n_heads次
attn_mask = attn_mask.unsqueeze(1).repeat(1, n_heads, 1, 1)
# 计算 ScaledDotProductAttention 函数
# 得到的结果有两个:
# context: [batch_size x n_heads x len_q x d_v]
# attn: [batch_size x n_heads x len_q x len_k]
context, attn = ScaledDotProductAttention()(q_s, k_s, v_s, attn_mask)
context = context.transpose(1, 2).contiguous().view(batch_size, -1, n_heads * d_v) # context: [batch_size x len_q x n_heads x d_v]
output = self.Linear(context)
return self.layer_norm(output + residual), attn # output: [batch_size x len_q x d_model]
- 功能:将注意力机制拆分为多个头,捕捉不同维度的语义信息
- 初始化逻辑 :
- W_Q/W_K/W_V:将输入映射到n_heads个独立的Q/K/V空间
- Linear:拼接多头结果后映射回d_model维度
- layer_norm:层归一化,稳定训练
- 前向传播核心步骤 :
- 线性映射:将Q/K/V从d_model维度映射到n_headsd_k/n_headsd_v
- 维度拆分与转置:将多头维度前置,适配缩放点积注意力输入
- 掩码扩展:将掩码矩阵扩展到多头维度
- 计算缩放点积注意力:每个头独立计算
- 拼接多头结果:将多头输出合并并映射回原维度
- 残差连接+层归一化:提升训练稳定性
- 输入:Q/K/V(原始输入)、attn_mask(掩码矩阵)
- 输出:归一化后的注意力输出、注意力权重
6. 前馈神经网络
python
class PoswiseFeedForwardNet(nn.Module):
def __init__(self):
# 全连接前馈网络,这里Conv1d用线性层
super(PoswiseFeedForwardNet, self).__init__()
self.conv1 = nn.Conv1d(in_channels=d_model, out_channels=d_ff, kernel_size=1)
self.conv2 = nn.Conv1d(in_channels=d_ff, out_channels=d_model, kernel_size=1)
# 层归一化 在Transformer中常适用于卷积训练过程
self.layer_norm = nn.LayerNorm(d_model)
# 原代码中的dropout
self.dropout = nn.Dropout(p=0.1)
def forward(self, inputs):
# residual = inputs ;inputs :[batch_size, len, d_model]
residual = inputs
# 换一下维度,因为卷积核期望输入维度为[batch_size, channels, length]
output = nn.ReLU()(self.conv1(inputs.transpose(1, 2)))
# 维度换回来
output = self.conv2(output).transpose(1, 2)
# 补全原代码的dropout
output = self.dropout(output)
# 残差连接+归一化
return self.layer_norm(output + residual)
- 功能:对注意力输出进行非线性变换,增强模型表达能力
- 初始化逻辑 :
- 1维卷积替代全连接层(计算等价,效率更高)
- conv1:升维到 d_ff,conv2:降维回 d_model(d_ff 一般为d_model 的四倍)
- dropout:随机失活,防止过拟合
- layer_norm:层归一化
- 前向传播逻辑 :
- 维度转置:适配Conv1d的输入格式([batch_size, channels, length])
- 卷积+ReLU:非线性变换
- 降维卷积+维度还原
- dropout+残差连接+层归一化
- 输入:[batch_size, len, d_model]的注意力输出
- 输出:[batch_size, len, d_model]的非线性变换结果
7. 位置编码
python
class PositionalEncoding(nn.Module):
def __init__(self, d_model, dropout=0.1, max_len=5000):
# PositionalEncoding 代码实现
super(PositionalEncoding, self).__init__()
# 位置编码的实现其实很简单,直接对着公式敲代码就可以,下面这个代码只是其中一种实现方式;
# 从理解来讲,需要注意的是偶数和奇数在公式上有一个共同部分,用log函数提取出来;
# pos代表的是单词在句子中的索引,这点要注意;比如max_len是128,那么索引就是从0.1.2.......127
# 假设我的d_model是512,25个符号中点从0到了255,那么2i对应的取值就是0,2.4......510
self.dropout = nn.Dropout(p=dropout)
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
# 这里需要注意的是pe[:, 0::2]这个用法,就是从0开始到后面,步长为2,其实代表的是偶数位置
pe[:, 0::2] = torch.sin(position * div_term)
# 这里需要注意的是pe[:, 1::2]这个用法,就是从1开始到后面,步长为2,其实代表的是奇数位置
pe[:, 1::2] = torch.cos(position * div_term)
# 上面代码获取之后得到的pe: [max_len, d_model]
# 下面这个代码之后,我们用到的pe形状是,[max_len x d_model]
pe = pe.unsqueeze(0).transpose(0, 1)
# 定一个缓冲区,其实简单理解为这个参数不更新就可以
self.register_buffer('pe', pe)
def forward(self, x):
# x: [seq_len, batch_size, d_model]
x = x + self.pe[:x.size(0), :]
return self.dropout(x)
- 功能:为输入序列添加位置信息,弥补Transformer无顺序感知的缺陷
- 初始化核心逻辑 :
- 生成max_len长度的位置编码矩阵pe,偶数位用正弦函数,奇数位用余弦函数
- register_buffer:将pe设为非训练参数,节省显存
- 前向传播逻辑 :
- 将位置编码与词嵌入相加(广播机制适配序列长度)
- dropout后输出,增强泛化能力
- 输入:[seq_len, batch_size, d_model]的词嵌入
- 输出:添加位置信息后的嵌入向量
8. 编码器层
python
class EncoderLayer(nn.Module):
def __init__(self):
# EncoderLayer:包含两个部分,多头注意力机制和前馈神经网络
super(EncoderLayer, self).__init__()
self.enc_self_attn = MultiHeadAttention()
self.pos_ffn = PoswiseFeedForwardNet()
def forward(self, enc_inputs, enc_self_attn_mask):
# 下面这个就是像做注意力层,输入是enc_inputs,形状是[batch_size x seq_len_q x d_model]
# 需要注意的是最初的QKV都是等于这个输入的
enc_outputs, attn = self.enc_self_attn(enc_inputs, enc_inputs, enc_inputs, enc_self_attn_mask) # enc_inputs to same Q,K,V
enc_outputs = self.pos_ffn(enc_outputs) # enc_outputs: [batch_size x len_q x d_model]
return enc_outputs, attn
- 功能:编码器的基本单元,包含自注意力和前馈网络
- 初始化逻辑:实例化多头自注意力和前馈网络
- 前向传播逻辑 :
- 自注意力计算:Q=K=V=enc_inputs,捕捉源序列内部依赖
- 前馈网络计算:对自注意力输出做非线性变换
- 输入:enc_inputs(编码器输入)、enc_self_attn_mask(编码器自注意力掩码)
- 输出:编码器层输出、自注意力权重
9. 编码器
python
class Encoder(nn.Module):
def __init__(self):
# Encoder 部分包含三个部分:词向量embedding,位置编码部分,注意力层和前馈神经网络
super(Encoder, self).__init__()
# 这个其实就是去定义生成一个矩阵,大小是 src_vocab_size x d_model
self.src_emb = nn.Embedding(src_vocab_size, d_model)
# 位置编码情况,这里是固定的正余弦函数,也可以使用类似词向量的nn.Embedding获得一个可以更新学习的位置编码
self.pos_emb = PositionalEncoding(d_model)
# 使用ModuleList对多个encoder进行堆叠,因为后续的encoder并没有使用词向量和位置编码
self.layers = nn.ModuleList([EncoderLayer() for _ in range(n_layers)])
def forward(self, enc_inputs):
# 这里我们的 enc_inputs 形状是: [batch_size x source_len]
# 下面这个代码通过src_emb,进行表定位,enc_outputs输出形状是[batch_size, src_len, d_model] 可嵌入
enc_outputs = self.src_emb(enc_inputs)
# 这里就是位置编码,把两者相加放入到了这个函数里面,从这里可以去看一下位置编码函数的实现;
# 位置编码函数其实是写死的,没有太大的时空空间
enc_outputs = self.pos_emb(enc_outputs.transpose(0, 1)).transpose(0, 1) # 这里的维度变换,可能是因为序列长度在第一个维度
# get_attn_pad_mask是为了得到句子中pad的位置信息,给到模型后面,在计算自注意力和交互注意力的时候去掉pad符号的影响
enc_self_attn_mask = []
enc_self_attns = []
# 遍历所有编码器层
for layer in self.layers:
enc_outputs, enc_self_attn = layer(enc_outputs, enc_self_attn_mask)
enc_self_attns.append(enc_self_attn)
return enc_outputs, enc_self_attns
- 功能:完整的编码器模块,堆叠多个编码器层,处理源序列
- 初始化逻辑 :
- src_emb:源语言词嵌入层,将单词索引转为d_model维向量
- pos_emb:位置编码层
- layers:堆叠n_layers个EncoderLayer
- 前向传播逻辑 :
- 词嵌入:将输入索引转为词向量
- 位置编码:添加位置信息(维度转置适配位置编码输入)
- 逐层计算:遍历所有编码器层,输出最终编码结果
- 输入:enc_inputs([batch_size, source_len]的源序列索引)
- 输出:编码器最终输出、各层自注意力权重
10. 解码器层
python
class DecoderLayer(nn.Module):
def __init__(self):
super(DecoderLayer, self).__init__()
self.dec_self_attn = MultiHeadAttention() # 解纠缠
self.dec_enc_attn = MultiHeadAttention() # 编码器-解码器
self.pos_ffn = PoswiseFeedForwardNet()
def forward(self, dec_inputs, enc_outputs, dec_self_attn_mask, dec_enc_attn_mask):
# 解码器的自注意力层处理解码器的输入,同时使用后续掩码(dec_self_attn_mask)防止"偷看"未来的信息。
dec_outputs, dec_self_attn = self.dec_self_attn(dec_inputs, dec_inputs, dec_inputs, dec_self_attn_mask)
# 编码器-解码器注意力层将得到解码器自注意力的输出(dec_outputs)作为查询,
# 编码器的输出(enc_outputs)作为键和值,并利用掩码(dec_enc_attn_mask)
dec_outputs, dec_enc_attn = self.dec_enc_attn(dec_outputs, enc_outputs, enc_outputs, dec_enc_attn_mask)
# 全连接前馈网络
dec_outputs = self.pos_ffn(dec_outputs)
return dec_outputs, dec_self_attn, dec_enc_attn
- 功能:解码器的基本单元,包含自注意力、编码器-解码器注意力、前馈网络
- 初始化逻辑:实例化解码器自注意力、编码器-解码器注意力、前馈网络
- 前向传播逻辑 :
- 解码器自注意力:捕捉目标序列内部依赖,带后续掩码
- 编码器-解码器注意力:将解码器输出与编码器输出做交叉注意力,捕捉源-目标序列依赖
- 前馈网络:非线性变换
- 输入:dec_inputs(解码器输入)、enc_outputs(编码器输出)、dec_self_attn_mask(解码器自注意力掩码)、dec_enc_attn_mask(编码器-解码器注意力掩码)
- 输出:解码器层输出、解码器自注意力权重、编码器-解码器注意力权重
11. 解码器
python
class Decoder(nn.Module):
def __init__(self):
super(Decoder, self).__init__()
self.tgt_emb = nn.Embedding(tgt_vocab_size, d_model)
self.pos_emb = PositionalEncoding(d_model)
self.layers = nn.ModuleList([DecoderLayer() for _ in range(n_layers)])
def forward(self, dec_inputs, enc_inputs, enc_outputs):
# dec_inputs:[batch_size x target_len] enc_inputs这个应该只是为了告诉那些是pad符号
dec_outputs = self.tgt_emb(dec_inputs) # batch_size, tgt_len, d_model
dec_outputs = self.pos_emb(dec_outputs.transpose(0, 1)).transpose(0, 1) # [batch_size, tgt_len, d_model]
# get_attn_pad_mask 自注意力层的时候的pad部分,得到一个符号矩阵 pad的地方是1
dec_self_attn_pad_mask = []
# get_attn_subsequent_mask 这个做的是自注意力的mask部分,就是当前单词之后看不到,使用一个上三角为1的矩阵
dec_self_attn_subsequent_mask = get_attn_subsequent_mask(dec_inputs)
# 两个矩阵相加,大于0的为1,不大10的为0,为1的在之后就会被1e-11无限小 填充掩码和后续掩码如何相加
dec_self_attn_mask = torch.gt((dec_self_attn_pad_mask + dec_self_attn_subsequent_mask), 0)
# 这个做的是交互注意力机制中的mask矩阵, enc的输入是K,去找这个K里面哪些是pad符号,给到后面的流程,
# 注意哦,我Q肯定也是行pad符号,但是这里我不在意的
dec_enc_attn_mask = []
dec_self_attns, dec_enc_attns = [], []
# 遍历所有解码器层
for layer in self.layers:
dec_outputs, dec_self_attn, dec_enc_attn = layer(dec_outputs, enc_outputs, dec_self_attn_mask, dec_enc_attn_mask)
dec_self_attns.append(dec_self_attn)
dec_enc_attns.append(dec_enc_attn)
return dec_outputs, dec_self_attns, dec_enc_attns
- 功能:完整的解码器模块,堆叠多个解码器层,处理目标序列
- 初始化逻辑 :
- tgt_emb:目标语言词嵌入层
- pos_emb:位置编码层
- layers:堆叠n_layers个DecoderLayer
- 前向传播逻辑 :
- 词嵌入+位置编码:与编码器逻辑一致
- 掩码生成:组合pad掩码和后续掩码,防止看到pad和未来token
- 逐层计算:遍历所有解码器层,输出最终解码结果
- 输入:dec_inputs(解码器输入)、enc_inputs(编码器输入)、enc_outputs(编码器输出)
- 输出:解码器最终输出、解码器自注意力权重、编码器-解码器注意力权重
12. Transformer整体模型
python
class Transformer(nn.Module):
def __init__(self):
# 从整体网络结构来看,分为三个部分:编码层,解码层,输出层;
super(Transformer, self).__init__()
self.encoder = Encoder() # 编码层
self.decoder = Decoder() # 解码层
# 输出层 d_model 是我们解码层每个token输出的维度大小,之后会做一个 tgt_vocab_size 大小的映射
self.projection = nn.Linear(d_model, tgt_vocab_size, bias=False)
def forward(self, enc_inputs, dec_inputs):
# 这里有两个数据进行输入,一个是enc_inputs,形状为[batch_size, src_len], 主要是作为编码段的输入;
# 一个dec_inputs,形状为[batch_size, tgt_len], 主要是作为解码段的输入
# enc_outputs就是主要输出形式,[batch_size, src_len], 输出由自己的函数内部确定,
# 想要什么指定输出什么,可以是全部tokens的输出,可以是特定一层的输出;也可以是中间某某些参数
# enc_self_attns这里假设情况的是QK转置相乘之后Softmax之后的矩阵值,代表的是每个单词和其他单词的相关性。
enc_outputs, enc_self_attns = self.encoder(enc_inputs)
# dec_outputs 是decoder主要输出,用于后续的Linear映射;
# dec_self_attns类似于enc_self_attns 是代表每个单词对decoder中输入的其余单词的相关性;
# dec_enc_attns是解码器对编码器输出的注意力权重
dec_outputs, dec_self_attns, dec_enc_attns = self.decoder(dec_inputs, enc_inputs, enc_outputs)
# dec_outputs缩减到词表大小
dec_logits = self.projection(dec_outputs) # dec_logits: [batch_size x src_vocab_size x tgt_vocab_size]
return dec_logits.view(-1, dec_logits.size(-1)), enc_self_attns, dec_self_attns, dec_enc_attns
- 功能:整合编码器、解码器、输出层,形成完整的Transformer模型
- 初始化逻辑 :
- encoder:编码器实例
- decoder:解码器实例
- projection:输出层,将解码器输出映射到目标词表大小
- 前向传播逻辑 :
- 编码:处理源序列得到编码器输出
- 解码:结合源序列编码结果和目标序列,得到解码器输出
- 输出映射:将解码器输出转为词表概率分布,并展平维度适配损失计算
- 输入:enc_inputs(源序列)、dec_inputs(目标输入序列)
- 输出:展平后的词表概率分布、各层注意力权重
13. 主函数(训练入口)
python
if __name__ == '__main__':
# 句子的输入部分
sentences = ['ich möchte ein bier P', 'S i want a beer', 'i want a beer E']
# Transformer Parameters
# Padding Should be Zero
# 构建词表 请清楚src和tgt
src_vocab = {'P': 0, 'ich': 1, 'möchte': 2, 'ein': 3, 'bier': 4}
src_vocab_size = len(src_vocab)
tgt_vocab = {'P': 0, 'i': 1, 'want': 2, 'a': 3, 'beer': 4, 'S': 5, 'E': 6}
tgt_vocab_size = len(tgt_vocab)
src_len = 5 # length of source
tgt_len = 5 # length of target
# 模型参数
d_model = 512 # Embedding Size
d_ff = 2048 # FeedForward dimension,usually which is 4 times of the d_model norm
d_k = d_v = 64 # dimension of K(=Q), V
n_layers = 6 # number of Encoder of Decoder Layer
n_heads = 8 # number of heads in Multi-Head Attention
# 初始化模型
model = Transformer()
# 损失函数:交叉熵损失
criterion = nn.CrossEntropyLoss()
# 优化器:Adam
optimizer = optim.Adam(model.parameters(), lr=0.001)
# 生成批次数据
enc_inputs, dec_inputs, target_batch = make_batch(sentences)
# 训练循环
for epoch in range(20):
# 清空梯度
optimizer.zero_grad()
# 前向传播
outputs, enc_self_attns, dec_self_attns, dec_enc_attns = model(enc_inputs, dec_inputs)
# 计算损失
loss = criterion(outputs, target_batch.contiguous().view(-1))
# 打印训练信息
print('Epoch:', '%04d' % (epoch + 1), 'cost =', '{:.6f}'.format(loss))
# 反向传播
loss.backward()
# 更新参数
optimizer.step()
- 功能:定义训练数据、模型参数,执行模型训练流程
- 核心步骤 :
- 数据定义:德语→英语的翻译样本,构建源/目标词表
- 参数配置:设置Transformer核心超参数(与原论文一致)
- 模型初始化:创建Transformer实例、损失函数、优化器
- 数据预处理:将文本转为模型可接受的张量格式
- 训练循环:
- 清空梯度:避免梯度累积
- 前向传播:模型预测
- 损失计算:交叉熵损失衡量预测与真实标签的差距
- 反向传播:计算梯度
- 参数更新:Adam优化器更新模型参数
- 输入:无(内置训练数据)
- 输出:打印每轮训练的损失值,更新模型参数
完整代码
python
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
import math
# 1. 生成批次数据
def make_batch(sentences):
# 帮助给文本数据预先处理成网络所可以处理的形式的简单例子
# :param sentences: 输入句子、输出句子和目标句子
# :return: 句子中单词在词汇表中的索引
input_batch = [[src_vocab[n] for n in sentences[0].split()]]
output_batch = [[tgt_vocab[n] for n in sentences[1].split()]]
target_batch = [[tgt_vocab[n] for n in sentences[2].split()]]
return torch.LongTensor(input_batch), torch.LongTensor(output_batch), torch.LongTensor(target_batch)
# 2. 生成后续掩码
def get_attn_subsequent_mask(seq):
# 生成"后续掩码",防止模型在预测序列中的元素时"偷看"到后续的元素
# seq: [batch_size, tgt_len] seq序列数据的维度 批次大小*目标序列长度
# attn_shape: [batch_size, tgt_len, tgt_len] 注意力矩阵的形状
attn_shape = [seq.size(0), seq.size(0), seq.size(0)]
# 生成一个上三角符号矩阵,对角线之下全是0,以上是1,保证每个元素看不到其后面的元素
subsequence_mask = np.triu(np.ones(attn_shape), k=1)
# NumPy数组→Torch 张量→布尔类型
subsequence_mask = torch.from_numpy(subsequence_mask).byte()
# 返回形状:[batch_size, tgt_len, tgt_len]
return subsequence_mask
# 3. 缩放点积注意力
class ScaledDotProductAttention(nn.Module):
def __init__(self):
# 初始化 ScaledDotProductAttention 类的实例,并确保它作为 nn.Module 的子类拥有所有必要的功能和特性
super(ScaledDotProductAttention, self).__init__()
def forward(self, Q, K, V, attn_mask):
# 缩放点积计算注意力分数,给mask
# 输入进来的维度分别是:
# Q: [batch_size x n_heads x len_q x d_k]
# K: [batch_size x n_heads x len_k x d_k]
# V: [batch_size x n_heads x len_k x d_v]
# scores首先经过matmul函数得到 [batch_size x n_heads x len_q x len_k]
scores = torch.matmul(Q, K.transpose(-1, -2)) / np.sqrt(d_k)
# 应用mask,将mask对应的位置设为极小值
scores.masked_fill_(attn_mask, -1e9)
attn = nn.Softmax(dim=-1)(scores)
context = torch.matmul(attn, V)
return context, attn
# 4. 多头注意力
class MultiHeadAttention(nn.Module):
def __init__(self):
super(MultiHeadAttention, self).__init__()
# 输入进来的QKV是相等的,我们会使用映射线性层给每个映射到多个的Q,Wk,Wv
self.W_Q = nn.Linear(d_model, d_k * n_heads)
self.W_K = nn.Linear(d_model, d_k * n_heads)
self.W_V = nn.Linear(d_model, d_v * n_heads)
self.Linear = nn.Linear(n_heads * d_v, d_model)
self.layer_norm = nn.LayerNorm(d_model)
def forward(self, Q, K, V, attn_mask):
# 这个多头分为这几个步骤,首先映射分头,然后计算attn_scores,然后计算attn_value;
# 输入进来的数据形状:
# Q: [batch_size x len_q x d_model]
# K: [batch_size x len_k x d_model]
# V: [batch_size x len_k x d_model]
residual, batch_size = Q, Q.size(0)
# (B, S, D) -proj-> (B, S, D) -split-> (B, S, H, W) -trans-> (B, H, S, W)
# 下面这个是先映射,后分头,一定要注意的是分头之后维度是一致的,所以一看这里都是dk
q_s = self.W_Q(Q).view(batch_size, -1, n_heads, d_k).transpose(1, 2) # q_s: [batch_size x n_heads x len_q x d_k]
k_s = self.W_K(K).view(batch_size, -1, n_heads, d_k).transpose(1, 2) # k_s: [batch_size x n_heads x len_k x d_k]
v_s = self.W_V(V).view(batch_size, -1, n_heads, d_v).transpose(1, 2) # v_s: [batch_size x n_heads x len_k x d_v]
# 输入进行的attn_mask形状是 batch_size x len_q x len_k,
# 然后经过下面这个代码得到 新的attn_mask :[batch_size x n_heads x len_q x len_k],
# 就是把pad信息重复n_heads次
attn_mask = attn_mask.unsqueeze(1).repeat(1, n_heads, 1, 1)
# 计算 ScaledDotProductAttention 函数
# 得到的结果有两个:
# context: [batch_size x n_heads x len_q x d_v]
# attn: [batch_size x n_heads x len_q x len_k]
context, attn = ScaledDotProductAttention()(q_s, k_s, v_s, attn_mask)
context = context.transpose(1, 2).contiguous().view(batch_size, -1, n_heads * d_v) # context: [batch_size x len_q x n_heads x d_v]
output = self.Linear(context)
return self.layer_norm(output + residual), attn # output: [batch_size x len_q x d_model]
# 5. 前馈神经网络
class PoswiseFeedForwardNet(nn.Module):
def __init__(self):
# 全连接前馈网络,这里Conv1d用线性层
super(PoswiseFeedForwardNet, self).__init__()
self.conv1 = nn.Conv1d(in_channels=d_model, out_channels=d_ff, kernel_size=1)
self.conv2 = nn.Conv1d(in_channels=d_ff, out_channels=d_model, kernel_size=1)
# 层归一化 在Transformer中常适用于卷积训练过程
self.layer_norm = nn.LayerNorm(d_model)
# 原代码中的dropout
self.dropout = nn.Dropout(p=0.1)
def forward(self, inputs):
# residual = inputs ;inputs :[batch_size, len, d_model]
residual = inputs
# 换一下维度,因为卷积核期望输入维度为[batch_size, channels, length]
output = nn.ReLU()(self.conv1(inputs.transpose(1, 2)))
# 维度换回来
output = self.conv2(output).transpose(1, 2)
# 补全原代码的dropout
output = self.dropout(output)
# 残差连接+归一化
return self.layer_norm(output + residual)
# 6. 位置编码
class PositionalEncoding(nn.Module):
def __init__(self, d_model, dropout=0.1, max_len=5000):
# PositionalEncoding 代码实现
super(PositionalEncoding, self).__init__()
# 位置编码的实现其实很简单,直接对着公式敲代码就可以,下面这个代码只是其中一种实现方式;
# 从理解来讲,需要注意的是偶数和奇数在公式上有一个共同部分,用log函数提取出来;
# pos代表的是单词在句子中的索引,这点要注意;比如max_len是128,那么索引就是从0.1.2.......127
# 假设我的d_model是512,25个符号中点从0到了255,那么2i对应的取值就是0,2.4......510
self.dropout = nn.Dropout(p=dropout)
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
# 这里需要注意的是pe[:, 0::2]这个用法,就是从0开始到后面,步长为2,其实代表的是偶数位置
pe[:, 0::2] = torch.sin(position * div_term)
# 这里需要注意的是pe[:, 1::2]这个用法,就是从1开始到后面,步长为2,其实代表的是奇数位置
pe[:, 1::2] = torch.cos(position * div_term)
# 上面代码获取之后得到的pe: [max_len, d_model]
# 下面这个代码之后,我们用到的pe形状是,[max_len x d_model]
pe = pe.unsqueeze(0).transpose(0, 1)
# 定一个缓冲区,其实简单理解为这个参数不更新就可以
self.register_buffer('pe', pe)
def forward(self, x):
# x: [seq_len, batch_size, d_model]
x = x + self.pe[:x.size(0), :]
return self.dropout(x)
# 7. 编码器层
class EncoderLayer(nn.Module):
def __init__(self):
# EncoderLayer:包含两个部分,多头注意力机制和前馈神经网络
super(EncoderLayer, self).__init__()
self.enc_self_attn = MultiHeadAttention()
self.pos_ffn = PoswiseFeedForwardNet()
def forward(self, enc_inputs, enc_self_attn_mask):
# 下面这个就是像做注意力层,输入是enc_inputs,形状是[batch_size x seq_len_q x d_model]
# 需要注意的是最初的QKV都是等于这个输入的
enc_outputs, attn = self.enc_self_attn(enc_inputs, enc_inputs, enc_inputs, enc_self_attn_mask) # enc_inputs to same Q,K,V
enc_outputs = self.pos_ffn(enc_outputs) # enc_outputs: [batch_size x len_q x d_model]
return enc_outputs, attn
# 8. 编码器
class Encoder(nn.Module):
def __init__(self):
# Encoder 部分包含三个部分:词向量embedding,位置编码部分,注意力层和前馈神经网络
super(Encoder, self).__init__()
# 这个其实就是去定义生成一个矩阵,大小是 src_vocab_size x d_model
self.src_emb = nn.Embedding(src_vocab_size, d_model)
# 位置编码情况,这里是固定的正余弦函数,也可以使用类似词向量的nn.Embedding获得一个可以更新学习的位置编码
self.pos_emb = PositionalEncoding(d_model)
# 使用ModuleList对多个encoder进行堆叠,因为后续的encoder并没有使用词向量和位置编码
self.layers = nn.ModuleList([EncoderLayer() for _ in range(n_layers)])
def forward(self, enc_inputs):
# 这里我们的 enc_inputs 形状是: [batch_size x source_len]
# 下面这个代码通过src_emb,进行表定位,enc_outputs输出形状是[batch_size, src_len, d_model] 可嵌入
enc_outputs = self.src_emb(enc_inputs)
# 这里就是位置编码,把两者相加放入到了这个函数里面,从这里可以去看一下位置编码函数的实现;
# 位置编码函数其实是写死的,没有太大的时空空间
enc_outputs = self.pos_emb(enc_outputs.transpose(0, 1)).transpose(0, 1) # 这里的维度变换,可能是因为序列长度在第一个维度
# get_attn_pad_mask是为了得到句子中pad的位置信息,给到模型后面,在计算自注意力和交互注意力的时候去掉pad符号的影响
enc_self_attn_mask = []
enc_self_attns = []
# 遍历所有编码器层
for layer in self.layers:
enc_outputs, enc_self_attn = layer(enc_outputs, enc_self_attn_mask)
enc_self_attns.append(enc_self_attn)
return enc_outputs, enc_self_attns
# 9. 解码器层
class DecoderLayer(nn.Module):
def __init__(self):
super(DecoderLayer, self).__init__()
self.dec_self_attn = MultiHeadAttention() # 解纠缠
self.dec_enc_attn = MultiHeadAttention() # 编码器-解码器
self.pos_ffn = PoswiseFeedForwardNet()
def forward(self, dec_inputs, enc_outputs, dec_self_attn_mask, dec_enc_attn_mask):
# 解码器的自注意力层处理解码器的输入,同时使用后续掩码(dec_self_attn_mask)防止"偷看"未来的信息。
dec_outputs, dec_self_attn = self.dec_self_attn(dec_inputs, dec_inputs, dec_inputs, dec_self_attn_mask)
# 编码器-解码器注意力层将得到解码器自注意力的输出(dec_outputs)作为查询,
# 编码器的输出(enc_outputs)作为键和值,并利用掩码(dec_enc_attn_mask)
dec_outputs, dec_enc_attn = self.dec_enc_attn(dec_outputs, enc_outputs, enc_outputs, dec_enc_attn_mask)
# 全连接前馈网络
dec_outputs = self.pos_ffn(dec_outputs)
return dec_outputs, dec_self_attn, dec_enc_attn
# 10. 解码器
class Decoder(nn.Module):
def __init__(self):
super(Decoder, self).__init__()
self.tgt_emb = nn.Embedding(tgt_vocab_size, d_model)
self.pos_emb = PositionalEncoding(d_model)
self.layers = nn.ModuleList([DecoderLayer() for _ in range(n_layers)])
def forward(self, dec_inputs, enc_inputs, enc_outputs):
# dec_inputs:[batch_size x target_len] enc_inputs这个应该只是为了告诉那些是pad符号
dec_outputs = self.tgt_emb(dec_inputs) # batch_size, tgt_len, d_model
dec_outputs = self.pos_emb(dec_outputs.transpose(0, 1)).transpose(0, 1) # [batch_size, tgt_len, d_model]
# get_attn_pad_mask 自注意力层的时候的pad部分,得到一个符号矩阵 pad的地方是1
dec_self_attn_pad_mask = []
# get_attn_subsequent_mask 这个做的是自注意力的mask部分,就是当前单词之后看不到,使用一个上三角为1的矩阵
dec_self_attn_subsequent_mask = get_attn_subsequent_mask(dec_inputs)
# 两个矩阵相加,大于0的为1,不大10的为0,为1的在之后就会被1e-11无限小 填充掩码和后续掩码如何相加
dec_self_attn_mask = torch.gt((dec_self_attn_pad_mask + dec_self_attn_subsequent_mask), 0)
# 这个做的是交互注意力机制中的mask矩阵, enc的输入是K,去找这个K里面哪些是pad符号,给到后面的流程,
# 注意哦,我Q肯定也是行pad符号,但是这里我不在意的
dec_enc_attn_mask = []
dec_self_attns, dec_enc_attns = [], []
# 遍历所有解码器层
for layer in self.layers:
dec_outputs, dec_self_attn, dec_enc_attn = layer(dec_outputs, enc_outputs, dec_self_attn_mask, dec_enc_attn_mask)
dec_self_attns.append(dec_self_attn)
dec_enc_attns.append(dec_enc_attn)
return dec_outputs, dec_self_attns, dec_enc_attns
# 11. Transformer整体模型
class Transformer(nn.Module):
def __init__(self):
# 从整体网络结构来看,分为三个部分:编码层,解码层,输出层;
super(Transformer, self).__init__()
self.encoder = Encoder() # 编码层
self.decoder = Decoder() # 解码层
# 输出层 d_model 是我们解码层每个token输出的维度大小,之后会做一个 tgt_vocab_size 大小的映射
self.projection = nn.Linear(d_model, tgt_vocab_size, bias=False)
def forward(self, enc_inputs, dec_inputs):
# 这里有两个数据进行输入,一个是enc_inputs,形状为[batch_size, src_len], 主要是作为编码段的输入;
# 一个dec_inputs,形状为[batch_size, tgt_len], 主要是作为解码段的输入
# enc_outputs就是主要输出形式,[batch_size, src_len], 输出由自己的函数内部确定,
# 想要什么指定输出什么,可以是全部tokens的输出,可以是特定一层的输出;也可以是中间某某些参数
# enc_self_attns这里假设情况的是QK转置相乘之后Softmax之后的矩阵值,代表的是每个单词和其他单词的相关性。
enc_outputs, enc_self_attns = self.encoder(enc_inputs)
# dec_outputs 是decoder主要输出,用于后续的Linear映射;
# dec_self_attns类似于enc_self_attns 是代表每个单词对decoder中输入的其余单词的相关性;
# dec_enc_attns是解码器对编码器输出的注意力权重
dec_outputs, dec_self_attns, dec_enc_attns = self.decoder(dec_inputs, enc_inputs, enc_outputs)
# dec_outputs缩减到词表大小
dec_logits = self.projection(dec_outputs) # dec_logits: [batch_size x src_vocab_size x tgt_vocab_size]
return dec_logits.view(-1, dec_logits.size(-1)), enc_self_attns, dec_self_attns, dec_enc_attns
# 12. 主函数(训练入口)
if __name__ == '__main__':
# 句子的输入部分
sentences = ['ich möchte ein bier P', 'S i want a beer', 'i want a beer E']
# Transformer Parameters
# Padding Should be Zero
# 构建词表 请清楚src和tgt
src_vocab = {'P': 0, 'ich': 1, 'möchte': 2, 'ein': 3, 'bier': 4}
src_vocab_size = len(src_vocab)
tgt_vocab = {'P': 0, 'i': 1, 'want': 2, 'a': 3, 'beer': 4, 'S': 5, 'E': 6}
tgt_vocab_size = len(tgt_vocab)
src_len = 5 # length of source
tgt_len = 5 # length of target
# 模型参数
d_model = 512 # Embedding Size
d_ff = 2048 # FeedForward dimension
d_k = d_v = 64 # dimension of K(=Q), V
n_layers = 6 # number of Encoder of Decoder Layer
n_heads = 8 # number of heads in Multi-Head Attention
# 初始化模型
model = Transformer()
# 损失函数:交叉熵损失
criterion = nn.CrossEntropyLoss()
# 优化器:Adam
optimizer = optim.Adam(model.parameters(), lr=0.001)
# 生成批次数据
enc_inputs, dec_inputs, target_batch = make_batch(sentences)
# 训练循环
for epoch in range(20):
# 清空梯度
optimizer.zero_grad()
# 前向传播
outputs, enc_self_attns, dec_self_attns, dec_enc_attns = model(enc_inputs, dec_inputs)
# 计算损失
loss = criterion(outputs, target_batch.contiguous().view(-1))
# 打印训练信息
print('Epoch:', '%04d' % (epoch + 1), 'cost =', '{:.6f}'.format(loss))
# 反向传播
loss.backward()
# 更新参数
optimizer.step()