解剖注意力:从零构建Transformer的终极指南

191 阅读6分钟

解剖注意力:从零构建Transformer的终极指南

GitHub

Author: Ming


AI Generated Art

相信不用我过多介绍,你已经对 Transformer 如雷贯耳,它是当今大语言模型的核心基石,可以说没有Transformer就没有今天的人工智能浪潮。

给还不了解Transformer的读者简单介绍一下,Transformer是一种深度学习架构(完全不同于以往的循环神经网络),最初于2017年由Google团队在论文《Attention Is All You Need》中提出,一开始它被提出来是为了解决机器翻译问题,并一举取得突破,在翻译任务中取得的非常大的成功。然而,人们很快发现,Transformer 的潜力远超预期——它不仅擅长翻译,在序列和语言任务中游刃有余,还能轻松迁移到其他任务中,甚至形成“碾压”级别的优势。

因此,在这篇文章中,我们将深入 Transformer 的每一层结构,从理论到代码,从矩阵乘法开始,一步步拆解它的设计精髓。我们不仅会探讨它的组成与原理,还会教你如何亲手从零实现一个完整的 Transformer 模型。

相信我,当你在看完了本篇文章后,你完全可以自己“手搓”一个Transformer出来,更能理解其设计哲学,并灵活运用到你自己的实际项目中。当然,由于 Transformer 属于相对前沿且有一定深度的内容,为了更顺畅地阅读与实践,建议你具备以下基础:机器学习与深度学习的基本概念、线性代数基础知识,基本自然语言处理

本文中展示的神经网络架构图,除特殊注明外,均为作者原创绘制。读者可自由分享使用,但使用时请注明出处。


既然要深入理解Transformer,我们不妨先回到它诞生的起点:机器翻译问题。在Transformer尚未出现的年代,机器翻译的主流架构是什么?熟悉深度学习历史的读者一定会想到——基于循环神经网络(RNN)的Seq2Seq模型,也就是经典的编码器-解码器架构。让我们以英译中任务为例,一步步还原它的工作流程。

假设我们要翻译“How are you”这句话。首先,输入的英文单词经过Embedding层转化为词向量序列。这些向量按时间顺序依次输入循环神经网络(RNN)中,经过层层传递与更新,最终在最后一个时间步输出一个隐藏状态(hidden state)。这个状态被视为整个句子的语义浓缩,承载着“How are you”的全部信息。这个过程,就是编码器(Encoder) 的核心。

c1.jpg

需要注意的是,上图中简化为一个 RNN Cell 的模块,在实际任务中通常是多层、多单元的复杂结构——可以是LSTM,也可以是GRU。像机器翻译这样复杂度高、序列长的任务,单一RNN单元难以胜任,实际使用的是多层堆叠的RNN网络。

c3.jpg

得到这个全局隐藏状态后,解码器(Decoder) 开始工作,将其逐步转化为目标语言(中文)。如上图所示,解码器在每个时间步不仅接收上一个时间步的输出,还会将编码器输出的隐藏状态与当前输入进行合并(图中圆圈内一竖表示拼接操作,类似PyTorch中的 torch.cat() 或 NumPy 中的 np.concatenate())。

这种设计在当年被视为一种重要改进——它让解码过程始终“携带”源句子的整体信息,理论上可以在生成长序列时缓解信息衰减问题。尤其在长句子翻译中,这样做有助于模型记住开头的语境,避免翻译到句末时遗忘开头的内容。

下图是此网络架构的训练流程,具体的细节就不过多介绍了。

c2.jpg

看到这里,你可能会想,真的有必要把隐藏状态和当前输入做拼接吗?例如在生成“你好”时,我们强行注入了“How are you”的全部语义。这虽然带来了全局信息,却也不可避免地引入了噪声与冗余——毕竟在翻译当前词时,我们可能并不需要整个句子的所有信息,而只是其中某几个相关的片段。

如果你也产生了这样的疑问,那么恭喜,你已经触及了注意力机制诞生的关键动机。没错,注意力并不是 Transformer 的专利,早在Transformer出现之前,它就已经在 seq2seq 模型中用于缓解上述问题了。它的核心思想很直观:让模型学会在解码每个词时,自主选择编码器输出的哪些部分更值得关注

那么具体是如何实现的呢?下面就是一种结合了双向 RNN 与简易注意力的编码器-解码器架构:

c4.jpg

此时,编码器不再输出一个代表整个句子的单一隐藏状态,而是让输入序列中的每个词经过双向 RNN 后,都对应一个独立的语义向量OjO_j。例如,“How” 对应O0O_{0} ,“are” 对应O1O_{1},依此类推。解码时,我们不再将整个句子信息一股脑儿注入,而是动态计算一个上下文向量ctc_{t},它由所有OjO_{j}加权求和得到:

ct=jq(t,j)Ojc_{t} = \sum_{j}q_{(t,j)}O_{j}

这里面的q(t,j)q_{(t,j)}就是比例系数,也可以说是注意力分数,它表示在解码第tt个词时,编码器第jj个输出的重要程度。它是如何得到的呢?通常通过当前解码器的隐藏状态sts_{t}与每一个编码器输出OjO_{j}进行匹配计算得到,计算方式如上图右边所示,就是将sts_{t}OjO_{j}输入到一个可以训练的简单全链接神经网络中去,计算得到匹配分数et,je_{t,j},然后对所有et,je_{t,j}进行softmax归一化,就可以得到权重q(t,j)q_{(t,j)}

这样一来,模型就能动态地为不同编码器输出分配合适的“注意力”。比如翻译“你”时,它可能给“you”较高的权重,而忽略“How”和"are"。这个机制本质上是一种可学习的“信息筛选器”,让解码过程既保持上下文感知,又避免无关噪声的干扰。

再举一个计算机视觉领域的经典例子——U-Net。它在图像分割领域几乎家喻户晓,其架构的核心特征是对称的编码器-解码器结构以及贯穿始终的跳跃连接(Skip Connections),网络架构图如下所示:

c5.png

「图片来源:《U-Net: Convolutional Networks for Biomedical Image Segmentation》」

图中灰色的箭头就是跳跃连接,这些跳跃连接将编码器浅层捕获的、富含细节和空间信息的特征图,直接传递到解码器的对应层,与经过深层抽象的特征进行融合。

其实观察一下,我们不难发现,**真的有必要将早期特征毫无保留地全部传递过去吗?**早期的特征固然包含精确定位所需的细节,但也混杂了大量与分割主题无关的纹理、噪声或背景干扰。全部融合,固然保证了信息的“不丢失”,却也引入了“不纯粹”。

这就催生了 Attention U-Net 网络架构。它的解决方案直观而巧妙:在跳跃连接上增设一个注意力门控。这个门控机制会自动学习,为传递过来的每一个空间位置的特征计算一个0到1之间的权重。重要的、与当前分割目标相关的特征得以强调(权重接近1),而不相关或冗余的背景信息则被抑制(权重接近0)。这个过程,与前述RNN中的注意力在精神上高度同源,都是对信息流的主动筛选与加权

如果上面的内容你一时没有完全理解,也不用担心——这部分其实已经涉及到 RNN 时期自然语言处理的核心建模思路。了解它们对理解 Transformer 有帮助,但并不是必须的。我主要就是想表达“注意力”这个机制在很久以前就存在了,“注意力”远非Transformer的专属,只不过Transformer它彻底抛弃了循环与卷积的固有结构,将“注意力”推至舞台中央,作为构建模型最核心、甚至是唯一的算子,并以此为基础,设计了一套简洁、对称且空前强大的架构。接下来,我们就正式进入Transformer的世界,看看它是如何彻底抛弃循环结构,完全基于注意力来重构序列建模的。


首先,我们需要明确 Transformer 究竟做了什么。简单来说,它的核心机制是将一个序列转换为另一个序列,同时让序列中的每个元素都能“注意到”序列中的其他元素。

让我们来看一个具体的例子。假设我们有三个词的向量表示(词嵌入):

# 原始的三个向量
X =  [[0.431, 0.313, ..., 0.507],   # I 的词向量,记作 x₁
     [0.396, 0.836, ..., 0.105],   # love 的词向量,记作 x₂
     [0.852, 0.381, ..., 0.541]]   # apple 的词向量,记作 x₃

# ==== 经过一个 Transformer 层后得到:====

# 输出仍然是三个向量,但已融入了上下文信息
Y =  [[0.624, 0.362, ..., 0.802],   # 记作 y₁
     [0.572, 0.260, ..., 0.249],   # 记作 y₂
     [0.158, 0.458, ..., 0.066]]   # 记作 y₃

经过Transformer层输出的这三个向量都不再仅仅是自身原本的含义,而是携带了其他词语信息的“语境化”表示。那么它是如何捕捉其他词语的注意力信息的呢?如果让你来设计,你会如何实现这种“注意力”?

如果让我来设计,要让 y1y_{1} 包含 x1,x2,x3x_{1},x_{2},x_{3} 的信息,最直接的方式就是对它们进行加权求和:

y1=q1x1+q2x2+q3x3y_{1}=q_{1}x_{1} + q_{2}x_{2} + q_{3}x_{3}

其中q1+q2+q3=1q_1 + q_2 + q_3 = 1qiq_{i} 表示 xix_i 对当前输出 y1y_{1} 的重要性权重,也就是注意力得分。那么现在问题转化为:如何合理地计算这些权重?

在这里我们要回顾一下线性代数中的向量点积运算,比如向量(1,3)(1,3)和向量(2,1)(-2,1)进行点积运算,它们的计算结果就是1×(2)+3×1=11 \times (-2) + 3 \times 1 = 1,计算结果是一个标量,其实就是逐元素相乘后在加起来。那么向量点积的数学意义是什么呢?点积的结果是一个标量,它在几何上反映了两个向量在方向上的对齐程度:方向越接近,值越大;方向越接近正交,值越接近零;方向相反则为负值。因此,点积可以自然地作为两个向量相似性的一种度量

那么,计算注意力权重的思路就清晰了:我们可以用 x1x_{1} 与序列中每个向量(包括自身)的点积,来初步衡量它们之间的相似度:

p1=x1x1=3.41p2=x1x2=0.19p3=x1x3=2.04q=softmax(p)p_{1} = x_{1} \cdot x_{1} = 3.41 \\ p_{2} = x_{1} \cdot x_{2} = 0.19 \\ p_{3} = x_{1} \cdot x_{3} = 2.04 \\ q = \text{softmax}(p)

最终,我们便得到了用于加权求和的注意力权重 qq。这个过程直观上就是:y1y_{1} 的生成,是基于“x1x_{1} 与序列中每个元素相似度”所决定的注意力分配。 y2y_{2}y3y_{3} 的生成过程完全同理,只是分别以 x2x_{2}x3x_{3} 作为查询的中心。

c6.jpg

最后,我们再回过头看到那个输出矩阵YY,其中的每一个向量 yiy_i 都是序列信息融合后的结果,它代表了一种以自身为视角、对整个序列的注意力聚焦。

以上我们讲解了标准的注意力计算过程,它让序列中的每个词都能“看到”整个序列的所有信息。然而,这种“全知视角”在序列生成任务(如对话、文本创作)中却会带来一个根本性问题——信息泄漏。

一部精彩的悬疑电影之所以吸引人,在于观众和主角一样,对未来的剧情一无所知,只能根据已呈现的线索进行推测和期待。如果观众提前知道了凶手是谁(即获得了“未来信息”),那么整个观影的推理过程和悬念就荡然无存。Transformer 在生成文本时也是如此:它应该像一个“实时编剧”,只能基于已经写出的剧情来构思下一句话。

从技术视角看,问题具体何在?我们以训练一个极简版 ChatGPT 来生成 “I Love Apple” 这句话为例。在训练时,我们会将完整的句子输入模型,并让模型学习预测下一个词:

  • 当模型看到 “I” 时,它应该学习预测 “Love”
  • 当模型看到 “I Love” 时,它应该学习预测 “Apple”

关键在于,在标准注意力下,计算 “Love” 这个词的表示 y2y_{2} 时,它会与包括 “Apple” (x3x_{3}) 在内的所有词计算注意力。这意味着,模型在训练预测 “Love” 的下一个词时,已经“偷看”到了答案 “Apple”。这就像考试时直接把标准答案放在考卷上,模型无需费力“思考”和“推理”,只需简单地将注意力高度集中在下一个词上即可。这种数据泄漏会导致模型无法学会真正的语言建模能力。

那么,如何为模型戴上“眼罩”,屏蔽未来的信息呢?解决方案就是 掩码注意力(Masked Attention)

其核心思想非常直观:在计算某个位置的输出时,只允许它关注该位置之前(包括自身)的所有位置。具体来说:

  • 计算 y1y_{1} (对应 “I”) 时,只允许关注 x1x_{1}
  • 计算 y2y_{2} (对应 “Love”) 时,只允许关注 x1,x2x_{1},x_{2}
  • 计算 y3y_{3} (对应 “Apple”) 时,才允许关注全部 x1,x2,x3x_{1},x_{2},x_{3}

如何实现这种屏蔽?很简单,没有你想象的那么复杂,保持之前所有的算法步骤不变,你想屏蔽谁,就把谁的pp置为负无穷,这样它在经过softmax后,对应的qq自然就变成0了。


建议你将前面的自注意力计算过程亲手推导一遍。这种“笨功夫”能帮你建立起扎实的直觉——当你亲手用数字演算过注意力如何流动后,对后续抽象概念的理解会清晰得多。如果你已经完全掌握了上一部分,那么恭喜你,你已经非常接近Transformer的核心了。接下来我们更进一步。

还是上面的例子,让我们仔细观察这个流程图。你会发现,同一个词嵌入向量需要同时承担三种不同的角色: 第一,作为查询者(蓝色部分),它要主动去“询问”自己与其他向量的关系; 第二,作为被查询者(橙色部分),它被动地接受其他向量的“询问”,并提供比较的依据; 第三,作为输出内容(紫色部分),它最终要参与加权求和,构成新表示的一部分。

c7.jpg

让一个向量同时完成这三项任务,就像让一位演员在同一幕戏中分饰三个角色——虽然可能做到,却难免顾此失彼、表达受限。在计算意义上,这种“身兼数职”会导致向量维度被迫承载多种相互冲突的语义信息,从而降低模型的表达效率与灵活性。

于是很自然地,我们想到一个改进方案:将原来单一的向量拆分成三个独立的向量,让它们各司其职。如何拆分呢?我们引入三个可训练的权重矩阵 WqW_qWkW_kWvW_v,分别用于生成查询向量(Query)、键向量(Key)与值向量(Value)

xik=xiWkxiq=xiWqxiv=xiWvx_{i}^{k} = x_{i} \cdot W_{k} \\ x_{i}^{q} = x_{i} \cdot W_{q} \\ x_{i}^{v} = x_{i} \cdot W_{v}

c8.jpg

这样一来,每个词向量通过三个不同的线性变换,演化出三个分工明确的“化身”:

  • xiqx_i^q 专注于表达“我想关注什么”,
  • xikx_i^k 专注于表达“我能提供什么特征用于被匹配”,
  • xivx_i^v 则纯粹承载“我本身的实际内容”。

后续的注意力计算流程与之前完全一致,只是将原先的 xix_i 替换为对应的 q,k,vq, k, v

c9.jpg

Wq,Wk,WvW_q, W_k, W_v 作为可训练的参数,使得模型能够通过数据自动学习到最适合当前任务的注意力模式。相比之前固定使用原始向量进行点积的“硬编码”方式,这种可学习的拆分机制极大增强了模型的表达能力。

从语义分工的角度来看,这种设计赋予模型更细腻的注意力调控能力,QKV根据自己不同的职责,表达不同的语义信息,这样模型自然而然就会有更强的表达能力。

  • Query 体现了一种“主动性”,相当于在问:“我现在需要什么样的信息”
  • Key 体现了一种“应答性”,相当于在回答:“我这里有这样的特征”
  • Value 则是真正被传递的实质内容,匹配成功后贡献到输出中。

各环节专业分工,整个系统才能高效运转、灵活适应复杂任务。这正印证了亚当·斯密的那句话:分工是提高效率的源泉。 在注意力机制中,Query、Key、Value的明确职责划分与专业化,便是模型智能得以涌现的效率之源。


接下来,让我们从矩阵的视角重新审视并实现上述的注意力计算过程。如果使用循环逐个处理上述流程,虽然很直观,但在实际计算的时候会非常低效。而将这些操作转化为矩阵运算,不仅能让表达更加简洁,更重要的是能够充分利用现代硬件(尤其是GPU)强大的并行计算能力,实现极致的加速。

注意:下面的计算过程有点绕,推荐自己拿笔在草稿纸上自己推一篇,尤其是要注意每个矩阵的形状

我们首先将整个序列形式化。假设序列长度为 seq_lenseq\_len,每个词的特征维度为 embed_sizeembed\_size,那么整个输入序列很自然地可以表示为一个形状为 (seq_len,embed_size)(seq\_len, embed\_size) 的矩阵 XX。每一行 X[i]X[i] 就对应一个词的嵌入向量 xix_i。Transformer 的核心目标,正是将这个输入矩阵 XX,通过一系列可学习的注意力操作,转化为一个蕴含了丰富上下文信息的输出矩阵 YY,其形状通常与 XX 保持一致。

为了实现可学习的注意力,我们需要引入三组独立的权重矩阵:WqW_qWkW_kWvW_v。它们的形状设计如下:

  • WqW_qWkW_k 的形状均为 (embed_size,k)(embed\_size, k)
  • WvW_v 的形状为 (embed_size,out_size)(embed\_size, out\_size)

这里出现了两个可调参数 kkout_sizeout\_size

  • out_sizeout\_size 决定了输出矩阵 YY 的特征维度。在标准 Transformer 中,为了便于残差连接和层标准化等操作,通常令 out_size=embed_sizeout\_size = embed\_size,即保持输入输出维度一致。
  • kk是一个需要精心设计而非随意指定的关键超参数。它决定了注意力匹配过程的“特征分辨率”或“比较深度”。你可以将其理解为在计算注意力时,模型用于比对Query和Key的“特征专精度”。较大的 kk 意味着模型可以使用更丰富的特征进行精细匹配,但也可能带来过拟合风险;较小的 kk 则迫使模型学习更泛化、更精简的匹配模式。这个维度的选择,本质上是在模型表达力与计算效率之间寻求平衡,它如同摄影中的“对焦精度”——并非越高越好,而是要适配任务的需求。暂且搁置不谈。

有了这些权重矩阵,我们便可以一次性为整个序列生成其对应的查询(QQ)、键(KK)和值(VV)矩阵:

Q=XWq(seq_len,k)K=XWk(seq_len,k)V=XWv(seq_len,out_size)\begin{matrix} Q = X\cdot W_{q} & (seq\_len,k) \\ K = X\cdot W_{k} & (seq\_len,k) \\ V = X\cdot W_{v} & (seq\_len,out\_size) \end{matrix}

这样一来,XX中的每一个向量xix_i 便通过三个独立的线性变换被“拆分”成了xiq,xik,xivx_i^q,x_i^k,x_i^v,其中xiq=Q[i],xik=K[i],xiv=V[i]x_{i}^{q}= Q[i],x_{i}^{k}= K[i],x_{i}^{v}= V[i]

接下来,我们计算注意力权重。核心思想是计算每一对 (xiq,xjk)(x^q_i, x^k_j) 的相似度,这可以通过矩阵乘法高效完成。

P=QKTk   (seq_len,seq_len)P = \frac{Q \cdot K^T }{\sqrt{k}}\space\space\space (seq\_len,seq\_len)

矩阵 PP 的每个元素 PijP_{ij} 表示第 ii 个位置的查询向量 xiqx^q_i 与第 jj 个位置的键向量 xjkx^k_j 之间的原始注意力得分(或称相似度)。

c18.jpg

这里之所以要除以一个缩放因子k\sqrt{k},主要是在应用 softmax 之前稳定梯度的传播,为了确保计算稳定;当我们设置的 kk 较大时,点积 qkq \cdot k 的绝对值可能会变得很大,这会导致 softmax 函数的梯度趋近于零(饱和区),从而引发梯度消失问题。缩放操作能够有效缓解这一现象,确保训练过程的稳定性。

如果此时你需要实现掩码注意力(如解码器中所用),此时的操作将变得异常简单,令矩阵PP的上三角区域的值全为负无穷或者一个极大的负数即可(通常取109 or inf-10^9 \space \text{or} \space -\text{inf}),除此之外,再无需其他任何操作,简单方便。

Pmasked[i,j]={P[i,j]jij>iP_{masked}[i,j] = \left\{\begin{matrix} P[i,j] & j\le i\\ -\infty & j> i \end{matrix}\right.

随后,我们对矩阵 PP(或 PmaskedP_{masked})的每一行应用 softmax 函数进行归一化,确保每一行的所有权重之和为 1,从而得到最终的注意力权重矩阵 SS

S=softmax(P,dim=1)   (seq_len,seq_len)S = \text{softmax}(P,\text{dim}=1) \space \space \space (seq\_len,seq\_len)

SS 矩阵的每一行 S[i]S[i] 都是一个概率分布,它精确地刻画了在生成输出 yiy_i 时,模型应该将多少“注意力”分配给输入序列中的每一个位置 jj

最后,我们将这个注意力权重矩阵 SS 与值矩阵 VV 相乘,即可得到输出矩阵 YY

Y=SV   (seq_len,out_size)Y = S \cdot V \space\space\space (seq\_len,out\_size)

这一步的本质是加权求和。YY 的每一行 yiy_i 都是 VV 中所有行的加权组合,权重由 S[i]S[i] 决定。因此,yiy_i 不再是孤立的信息,而是融合了整个序列相关信息的“语境化”表示。

我们将以上的计算过程,就是通过输入XX,最后计算得到YY的流程用如下图表示:(下图中的ZZ就是我们文中的输出YY,后文中均用ZZ表示输出)

c10.jpg

以上,就是“自注意力”的计算过程,我们可以将其封装为一个“自注意力(Self Attention)”模块,就如同上图所示。

至此,我们仅用几行清晰的矩阵运算,就完成了将输入序列 XX 转换为上下文感知的输出序列 YY 的全部过程。非常的优雅、高效。

另外,这种方式相比传统的循环神经网络(LSTM,GRU等),效率提升了许多,为什么?RNN的核心是其循环结构。为了处理序列中的第 t 个元素,它必须等待第 t-1 个元素的隐状态计算完成。这种严格的时序依赖关系就像一条单行生产线,工序必须一个接一个进行。在训练时,这被称为“串行依赖”,意味着模型无法同时处理一个批次中所有序列位置的计算。这就意味着即使拥有强大的GPU,计算也必须按时间步展开,无法充分利用GPU成千上万个核心的并行能力。

而Transformer通过注意力机制,将序列中所有元素之间的关联计算,一次性表述为矩阵乘法。对于GPU来说,这是它最擅长处理的稠密矩阵运算,可以调用海量计算核心同时完成数以百万计的点积运算。而计算位置 i 与位置 j 的注意力权重,不再依赖于位置 i-1j-1 的任何中间结果。这种设计彻底打破了时序枷锁。


相信认真读到这里的你,心中可能会浮现一个重要的疑问:序列数据中至关重要的时序信息去哪儿了? Transformer的“自注意力”机制似乎只专注于计算词与词之间的关联强度,却完全忽略了它们在序列中的相对位置或绝对距离

举个例子,考虑以下两个句子:

这两个句子包含完全相同的词语。如果只依赖自注意力机制,模型为“爱”这个词计算出的上下文表示,在两个句子中可能会非常相似,因为它在两个句子里都与“我”和“你”发生了关联。然而,任何一个懂中文的人都知道,词语的顺序彻底改变了句子的含义。自注意力机制在计算时,并没有内置任何关于“我”在“你”之前还是之后的概念,它丢失了语言中这种根本性的时序结构

**这是一个非常关键的洞察!**这是原始自注意力机制的一个核心局限:它本质上是一种置换不变操作。无论输入的词序如何打乱,只要词向量集合不变,自注意力计算出的输出集合在理论上就是不变的。这对于理解语言、代码、音乐等任何依赖顺序的结构来说是致命的缺陷。

不过,这个看似严重的缺陷早已被Transformer的设计者预见并优雅地解决了。解决方案就是在将词嵌入向量输入Transformer层之前,为它们注入位置信息,这个关键的模块称为位置编码(Positional Encoding)。具体做法是生成一个与词嵌入向量维度相同长度也相同的“位置向量pip_{i}”,并将其与词嵌入向量相加。这个位置向量不是随机的,它必须能够唯一且有效地表征每个词在序列中的绝对位置(甚至相对位置),如此一来,输入模型的就不再是单纯的词义向量 xix_i,而是词义与位置信息的融合体 xi+pix_i + p_i。自注意力机制在此基础上计算关联时,就会自然而然地同时考虑“你是什么词”以及“你出现在哪里”这两重信息。

关于“位置编码”的深入剖析,我将其安排在了文末的附录章节,以便于主线阐述的连贯性,感兴趣的读者可随时参考。


至此,你已经掌握了Transformer最核心的动力单元——自注意力(Self-Attention)。它赋予了序列中每个元素“纵观全局”的能力。然而,一个真正强大的系统,绝不应满足于单一的观察视角。

如果只用一个自注意力机制,就好比只用一套固定的滤镜观察世界,它可能擅长捕捉某种模式,但会忽略其他同样重要的模式。

于是,多头注意力(Multi-Head Attention) 应运而生。它的设计哲学非常直观:与其让一个注意力机制勉力捕捉所有类型的关系,不如并行部署多个独立的注意力“头”,让每个头自由地、专业化地学习并关注序列中不同方面的依赖模式

举个例子:将多头注意力想象成一个高级别的专家委员会。面对一份复杂的文件(输入序列),委员会主席(模型)不会只询问一位专家。相反,他会同时召集:

  • 一位法律专家(头1),审视条款间的逻辑与合规性;
  • 一位财务专家(头2),分析数据间的勾稽关系;
  • 一位技术专家(头3),评估方案实施的可行性。

每位专家独立撰写自己的分析报告(每个头的输出)。最后,主席将所有报告汇总(拼接),并综合形成一份最终的决策文件。这种方式远比依赖单一专家的判断更为全面和稳健。

多头注意力的功能和“自注意力(self-Attention)”模块是一样的,本质依然是将一个序列转化为另一个序列。

既然是多头注意力,其实我们就可以想到了,就是多堆叠几个Self-Attention层就行了,有几个头,就有几个Self-Attention层。

假设我们有一个形状为 (seq_len,embed_size)(seq\_len, embed\_size) 的输入矩阵 XX。现在,我们不再只使用一个自注意力层,而是准备 hh 个(例如 h=4h=4独立的自注意力层。每个层都有自己独有的、互不共享的三组权重矩阵 Wq(i),Wk(i),Wv(i){W_q^{(i)}, W_k^{(i)}, W_v^{(i)}},其中 i=1,2,...,hi=1,2,...,h

我们将同一个输入 XX 并行地送入这 hh 个自注意力层中,每个头都会输出一个形状为 (seq_len,out_size)(seq\_len, out\_size) 的矩阵。接下来,我们将这些输出矩阵进行合并(也可以叫拼接)成一个新矩阵ZoutZ_{out}ZoutZ_{out}的形状为(seq_len,out_size×h)(seq\_len, out\_size \times h)

c11.jpg

当然,为了确保输入输出矩阵的特征/维度相同,在每个自注意层的输出时要把out_sizeout\_size设置为“emb_size/hemb\_size/h

首先,还是再强调一下这个符号(圆圈内一竖)的意思,它就是合并操作(类似PyTorch中的 torch.cat() 或 NumPy 中的 np.concatenate()),例如:

# 假设有三个头,每个头输出维度为 out_size = 2
Z_1 = [[1, 2],  
         [4, 5],   
         [1, 9]]   

Z_2 = [[5, 9],
         [8, 7],
         [1, 6]]

Z_3 = [[3, 0],
         [2, 2],
         [4, 8]]

# 沿特征维度(dim=1)拼接
Z_out =    [[1, 2, 5, 9, 3, 0],  # 位置1:融合了3个头的视角
            [4, 5, 8, 7, 2, 2],  # 位置2:融合了3个头的视角
            [1, 9, 1, 6, 4, 8]]  # 位置3:融合了3个头的视角

这就是多头注意力了,是不是非常简单!我们就可以将上面的这个算法流程封装成Multi Head Attention模块。

小提示:如果你给每个自注意力层的注意力权重PP加了掩码,那么封装成的多头注意力就叫做Masked Multi Head Attention。本质完全和Multi Head Attention一样,就是有无掩码的区别。


接下来,让我们开始动手构建Transformer的编码器模块。与之前介绍的注意力模块一脉相承,编码器层的核心功能同样是将一个序列转换为另一个序列,并且保持输入与输出的维度一致。下图清晰地展示了一个标准Transformer编码器层(Transformer Encoder Layer)的内部结构,相信大家都能看懂。

c12.jpg

观察这个结构图,你会发现它主要由两大核心组件构成:

  1. 多头自注意力层(Multi-Head Self-Attention):这就是我们上一节所构建的模块,负责让序列中的每个位置都能汇聚全局的上下文信息。
  2. 前馈神经网络层(Feed-Forward Network, FFN):一个应用于每个位置上的独立、全连接网络,负责对汇聚后的信息进行非线性变换和深层处理。

这两个核心组件都被**残差连接(Residual Connection)层归一化(Layer Normalization)**所包裹,形成了“子层 -> 加残差 -> 归一化”的稳定计算单元。

这里要注意一点,就是在线性层Linear那里,第一个线性层将输入向量的维度扩大到一个比较大的维度large_sizelarge\_size,第二个线性层再将其压缩回原始的 embed_sizeembed\_size

你可能觉得很奇怪,为什么要这样做呢?其实在更高的维度空间中,模型能够学习到更复杂、更细微的特征组合模式。模型从“看山是山”的阶段,到“看山不是山”的阶段,最后又回到了“看山还是山”的阶段,虽然看似首尾相同,其实两个的境界已经不一样了。它已经实现了在更高层次上的回归与升华。

另外,还有一个需要注意的是这里的LayerNorm层,它和我们常见到的BatchNorm非常相似,都是归一化;二者目标相似(加速训练、稳定梯度),但作用维度截然不同。BatchNorm是对同一特征通道,跨不同样本进行归一化,而LayerNorm对单个样本,跨不同特征通道进行归一化。

假设我们有一个简单的数据,形状为 (batch_size=3, embed_size=4)

X = [[1, 2, 3, 4], 
     [5, 6, 7, 8],  
     [9, 10,11,12]] 
  • 批归一化(BN) 的操作维度:对于第一个特征通道(即所有数据的第一个数字),它会收集 X[0,0]=1, X[1,0]=5, X[2,0]=9这3个值,计算它们的均值与方差,然后用这个统一的分布去归一化这3个值。它对每个特征通道独立进行此操作。
  • 层归一化(LN) 的操作维度:对于样本1的第一个位置 X[0] = [1,2,3,4],它会计算这个长度为4的向量自身的均值和方差,然后用这个均值和方差去归一化这个向量本身。它对每个样本的每个位置独立进行此操作。

LayerNorm在这里的好处就是无论序列多长,归一化都在每个位置的向量内进行,因此天生适应变长序列。


再接下来,让我们开始构建 Transformer 的解码器模块。解码器的核心功能同样是将一个序列转换为另一个序列,但它与编码器有一个关键区别:解码器接收两个输入。下图展示了一个标准 Transformer 解码器层(Decoder Layer)的完整结构。相信大家都能看懂,就不做具体讲解了。

c13.jpg

这里需要额外强调,原始输入矩阵XX的形状和输出SoutS_{out}的形状是完全相同的,只不过这个附加的输入YoutY_{out}有点不一样,它的维度和XX相同,但是序列长度可以不一样。相信你已经看出来了,这个YoutY_{out}就是上一节我们讲的编码器的输出。

另外,我相信很多人在YoutY_{out}这里很很多不解,我当时学习的时候就在这个地方卡了很久,不知道YoutY_{out}是如何输入到多头注意力中的,我把这个部分的多头注意力模块进行了拆解,如下图所示

c15.jpg

如图所示:

  • 查询矩阵 QQ 由解码器输入 XX 经过线性变换得到:Q=XWqQ = X W_q
  • 键矩阵 KK 和值矩阵 VV 则由编码器输出 YoutY_{out} 经过各自独立的线性变换得到:K=YoutWkK = Y_{out} W_k, V=YoutWvV = Y_{out} W_v

此后的计算流程与标准自注意力完全相同。这样一来,解码器中的每个位置都能够根据自身的需要(QQ),有选择地从编码器输出的所有位置(K,VK, V)中汇聚信息。这个环节的学术名称就是“交叉注意力”。

最后,还有一个关键问题:就是为什么编码器的输出YoutY_{out}的序列长度new_seq_lennew\_seq\_len和原始输入XX的长度seq_lenseq\_len可以不一样?其实很简单,你自己试一试就知道了,将这两个不一样长度的矩阵输入到自注意力矩阵中,按照上述讲解的自注意力矩阵算法,自己动手,看看其输出矩阵的形状是什么样子的。

最后你会发现输出序列长度与解码器输入长度 seq_lenseq\_len 保持一致,而与编码器输出长度 new_seq_lennew\_seq\_len 无关。正是这一特性赋予了 Transformer 极大的灵活性,使其能够轻松处理如机器翻译中源语言与目标语言句子长度不等的情况,实现了输入与输出序列长度的解耦


最后的最后,我们将上文中讲解的编码器层(Transformer Encoder Layer)与解码器层(Transforme Decoder Layer)模块,按照论文《Attention Is All You Need》中的蓝图,层层堆叠、顺序连接,最终构建出 Transformer 模型的完整架构。其整体结构如下图所示,它清晰展示了数据从输入到输出的完整流程:

c14.jpg

「最右侧的Transformer架构图来源:《Attention Is All You Need》」

由于 Transformer 架构设计的初衷就是为了解决序列到序列(Seq2Seq)任务,特别是机器翻译,因此我们以中英翻译为例来阐释这个网络的工作流程。你会看到,其设计哲学与人类翻译的过程有异曲同工之妙。

先来看左侧的编码器堆栈,它是由nn个完全相同的编码器层(Transformer Encoder Layer)堆叠而成。信息在层间逐级传递、不断被提炼和抽象,在最后一层输出的是一个富含整个输入序列上下文信息的高级语义表示矩阵。这个高级语义矩阵将被复制多份,传输给解码器堆栈中的每一个解码器层作为“知识参考”。

再来看右侧的解码器堆栈,它同样是由nn个完全相同的解码器层(Transforme Decoder Layer)堆叠而成。它的职责是参考编码器提供的“高级语义矩阵”,自主地、逐个单词地生成目标语言句子

可以这样理解,假设我们要翻译“人山人海”,我们肯定不是直接逐字翻译成“people mountain people sea”,这太怪了。而是我们在理解了“人山人海”所表达的意思后(这就是编码器干的事情),然后按照这个意思翻译成英文(这就是解码器),“a huge crowd of people”。

题外话:说到翻译,我想到了一个有趣的解读。很多人都说中国的古诗词,文言文,很难翻译成英文,因为翻译后就丢失了原有的中文的那种感觉和意境,比如“孤舟蓑笠翁,独钓寒江雪”,你怎么翻译都翻译不出这种味道;同样的,一些英文诗只有在英文的语境下才能体会它的美,翻译成中文就变成大白话了。或许正如一句名言所说:什么是诗?诗,就是在翻译中丢失的东西(Lost in Translation)。

那如何使用这个网络呢?如何利用它训练一个中译英翻译器呢?

就以中文“我喜欢深度学习”翻译成英文“I like deep learning”为例,来详细解析其训练流程。这本质上是一个序列到序列(Seq2Seq)的监督学习任务:源句作为输入,目标句作为标签。

首先进行数据预处理与分词:

  • 源语言(中文)分词:[“我”, “喜欢”, “深度学习”],序列长度 Lsrc=3L_{src}=3
  • 目标语言(英文)分词并添加特殊标记:
    • 解码器输入:[“<sos>”, “I”, “like”, “deep”, “learning”],序列长度 Lout=5L_{out}=5
    • 标签:[“I”, “like”, “deep”, “learning”,"<eos>"],序列长度 Ltgt=5L_{tgt}=5

注意这里要给目标句子加上开始标签<sos>和结束标签<eos>,要不然解码器就不知道从哪里开始,到哪里结束生成一个句子了。

若你对词向量(word embeddings)或特殊标记的作用还不熟悉,建议先了解自然语言处理中的词表示基础。不过没关系,我马上就会写一篇专门关于词向量的新的文章,即使0基础自然语言处理也能看懂,看到这里不妨给我个关注吧。(๑˃̵ᴗ˂̵)و ✨

分词后的序列通过词嵌入层转换为稠密向量表示:

  • 中文序列通过一个嵌入层(Embedding1)转换为词向量矩阵 XsrcX_{src}
  • 英文序列通过另一个嵌入层(Embedding2)转换为词向量矩阵 XoutX_{out}

例如

# 假设 维度 = 512
X_src = [[0.12, 0.71, ..., 0.32],  # “我”的词向量
         [0.15, 0.31, ..., 0.46],  # “喜欢”的词向量
         [0.09, 0.52, ..., 0.87]]  # “深度学习”的词向量

X_out = [[0.01, 0.80, ..., 0.10],  # <sos>
         [0.45, 0.23, ..., 0.67],  # “I”
         [0.33, 0.19, ..., 0.55],  # “like”
         [0.87, 0.41, ..., 0.22],  # “deep”
         [0.29, 0.64, ..., 0.38]]  # “learning”

这两个矩阵输入到Transformer中后经过一通计算,最后在解码器最后一层输出的就是和XoutX_{out}相同形状的矩阵OO,例如:

# 解码器输出示意(每个向量对应一个时间步的表示)
O =  [[0.74, 0.22, ..., 0.62],  # 对应 "<sos>" 位置的输出 , 最终要和标签"I"作损失
      [0.35, 0.58, ..., 0.56],  # 对应 "I" 位置的输出 , 最终要和标签"like"作损失
      [0.53, 0.40, ..., 0.41],  # 对应 "like" , 和标签"deep"作损失
      [0.18, 0.63, ..., 0.79],  # 对应 "deep" , 和标签"learning"作损失
      [0.07, 0.73, ..., 0.24]]  # 对应 "learning" , 和标签"<eos>"作损失

最后,矩阵 OO 经过一个线性层(Linear),将维度从 embed_sizeembed\_size 映射到英文词表大小 vv,得到矩阵 ZZ。接着,对 ZZ 的每一行应用 softmax 函数,将其转换为一个概率分布矩阵 PP

其中 Pi,jP_{i,j} 表示第 ii 个时间步(对应目标序列第 ii 个位置)生成词表中第 jj 个词的概率。最终 PP 的每一行都是一个在词表上的概率分布,例如第一行对应从 <sos> 之后生成第一个词的概率分布,第二行对应生成第二个词的概率分布,依此类推。

在训练时,我们使用交叉熵损失函数比较 PP 与真实目标序列的 one-hot 标签,具体而言,对于目标序列 XtgtX_{tgt}=[I,like,deep,learning,<eos>],我们将其转化为 one-hot 向量形式。由于英文词表大小为vv,则每个单词对应一个vv维的 one-hot 向量,其中对应单词索引的位置为 1,其余为 0。

不要忘了,解码器是带有掩码的,因此序列中的每个子序列都是看不到它的下一个词的,但是能看到它前面的所有词。正是因为如此,它才能这样进行训练。

这其实也就是意味着,如果未来在编码器输入“我喜欢深度学习”的情况下,我在解码器输入[<sos>],我期望它输出[I]的概率最大;在解码器输入[<sos>,I],我期望它输出[I,like];在解码器输入[<sos>,I,like],我期望它输出[I,like,deep];在解码器输入[<sos>,I,like,deep],我期望它输出[I,like,deep,learning];在解码器输入[<sos>,I,like,deep,learning],我期望它输出[I,like,deep,learning,<eos>],最后在遇见<eos>或者输出长度到达预设上限时就停止输入,完成生成。

通常在推理中,采用自回归的方式生成序列,我们只取输出序列的最后一个位置的输出(仅关注最新一步的预测结果),将其加入到输入序列的末尾再次进行输入(就像上面演示的那样)。但是,这种逐词生成的方式在长序列任务中会导致效率问题,因为每一步都需要重新计算整个序列的表示。不过目前已有许多优化方法,但这已超出本篇基础指南的范畴。就不过多介绍了。

题外话:

上面的例子是使用单一样本进行训练的,而在实际训练中,为了提高硬件利用率和训练稳定性,我们通常会采用批次训练(Batch)。此时,输入的张量形状将变为 (batch_size,seq_len,embed_size)(batch\_size, seq\_len?, embed\_size)

但这里存在一个现实问题,就是同一个批次中的句子长度往往各不相同。无法直接组合成一个张量,因此也就无法输入到Transformer中进行训练。

常用的解决方法就是引入填充机制,即在较短序列的末尾添加特殊的填充符号(如<pad>),使该批次中所有序列长度一致。然后在模型中添加掩码来屏蔽填充位置的影响,具体来说就是在注意力机制中,将填充位置对应的注意力权重设置为一个极小的值(如 109-10^9),使得在 softmax 之后这些位置的权重接近 0。更加具体的操作方式就不在这里讲解了,感兴趣的朋友可以自己去搜索资料了解学习。

不知你是否注意到,Transformer 这种逐词生成模式,与如今对话式大语言模型的响应方式如出一辙——是的,这正是因为 GPT 系列模型本质上就是由 Transformer 的解码器部分演变而来。仅使用编码器部分,则构成了另一里程碑模型 BERT 的核心架构。到这里,你已经离当今的大语言模型的本质又更进一步!

至此,你已经掌握了 Transformer 的核心架构与运行逻辑。那么,在你未来的实际项目中,是否每次都需要“全盘照搬”整个Transformer结构呢?我觉得并不需要。Transformer 的强大之处恰恰在于其模块化设计:你可以根据任务需要,灵活选取其中的自注意力层、交叉注意力层、位置编码等组件,甚至对它们进行改造或重组,反正你都已经了解它的原理了,拆开重构也不是什么难事。

这种“拆解-重组-创新”的思维,正是深度学习模型演进中的常见路径。其实到这里,你会发现,那些令人生畏的前沿网络架构,其实大多是由我们熟悉的基本模块——如 Softmax\text{Softmax}Linear\text{Linear}LayerNorm\text{LayerNorm}、残差连接,CNN等——通过巧妙的排列组合而来。例如,将 Transformer 的自注意力与卷积模块结合,便催生了 ConvTransformer;在视觉任务中仅使用编码器并调整注意力机制,就诞生了 Vision Transformer (ViT)。有时候,仅仅是两个已有结构的有机融合,就能在特定任务上取得突破,这本身就是一个重要的创新方向。

真正的学习,始于理解,成于重构,最终抵达创新。不妨从一个小实验开始。理论与实践的交汇处,正是灵感迸发的地方。未来或许有一天,你会站在这些基础模块之上,搭建出属于自己、甚至影响某个领域的新架构。期待你在理解 Transformer 之后,不仅能复现它,更能拆解它、改进它,最终超越它。深度学习的世界里,没有终极的模型,只有不断演进的思想——而读到这里的你,已经走在了这条路上。

附录1:位置编码

如上文所述,自注意力机制能够出色地捕捉序列元素之间的相关性,但其计算过程本质上是无序的——它无法区分“我爱自然语言处理”与“自然语言处理爱我”之间的顺序差异。为了弥补这一关键缺陷,我们需要为模型引入位置信息

位置编码的核心思想非常直观:为序列中每个位置的词向量添加一个独特的“位置标记”。这个标记是一个与词向量维度相同的向量,最终通过相加的方式与原始词向量融合,从而让每个输入都“记住”自己在序列中的位置。

一个最直观的方案:用二进制或自然数的独热编码来表示位置。例如,对于一个三维向量,可以这样设计位置编码:

# 原始输入序列 (例如3个词,每个词用3维向量表示)
X = [[1.34, 2.83, 0.51],
     [4.12, 5.10, 3.11],
     [0.41, 3.71, 1.33]]

# 一种简单的位置编码(此处仅为示意,并非Transformer实际所用)
P_simple = [[0, 0, 1],  # 位置0
            [0, 1, 0],  # 位置1
            [0, 1, 1]]  # 位置2

# 融合了位置信息的最终输入
X_input = X + P_simple = = [[1.34, 2.83, 1.51],
     						[4.12, 6.10, 3.11],
     						[0.41, 4.71, 2.33]]

对,就是这么简单粗暴,只不过一般情况下,我们不用二进制递增序列来表示位置编码向量,而是用正余弦位置编码序列来表示(原论文中的方法)。但其本质都是一样的。

对照上述的例子,正余弦位置编码矩阵如下所示

P=[f0(0)f1(0)f2(0)f0(1)f1(1)f2(1)f0(2)f1(2)f2(2)]P = \begin{bmatrix} f_{0}(0) & f_{1}(0) & f_{2}(0)\\ f_{0}(1)& f_{1}(1)&f_{2}(1) \\ f_{0}(2)& f_{1}(2)&f_{2}(2) \end{bmatrix}
fi(t)={sin(t108i/D)i为偶数cos(t108i/D)i为奇数f_{i}(t) = \left\{\begin{matrix} sin(\frac{t}{10^{8i/D}} ) & i为偶数 \\ cos(\frac{t}{10^{8i/D}} ) & i为奇数 \end{matrix}\right.

其中tt为序列中第tt个向量,DD表示词向量的维度,ii表示第ii维,ii从0开始计算

其实当时我学到位置编码的时候,我就有一个疑问,为什么可以直接将位置编码和词向量进行相加?这样做难道不会破坏原有词向量本身所包含的信息吗?为什么这个相加操作就能赋予原序列位置信息?

下面举个形象一点的例子,想象一下我们的输入序列是一组图片(承载语义信息),而位置编码是一组透明的、印有位置序号(如1,2,3…)的胶片。

c16.jpg

将胶片叠加到图片上后,你既能清晰地看到图片内容(语义),也能毫不费力地识别出叠加在上面的数字(位置)。两者信息是共存的。

c17.jpg

你可能会说,不对呀,我们人脑可是见过很多图片和数字的,当然能轻松的看出来图片中嵌入了数字,但是Transformer它能看出来吗?加入的这些序列信息难道不会构成噪声吗?

不要忘了,Transformer可是大语言模型的基础架构,它可是非常强大的,Transformer区分原始序列中包含的序列信息可以说是再简单不过的事情了。

从数学上来解释,位置编码就是利用了高维表示空间的冗余度和深度神经网络的强大解耦能力,词向量通常存在于数百甚至数千维的高维空间中。在高维空间中,向量方向极其丰富,两个随机向量几乎总是近似正交的(点积接近零)。词向量和位置编码向量可以看作是从不同“坐标系”出发的向量。简单的相加,相当于在高维空间中进行了一次“合成”。在高维空间中,低相关性的向量相加不会导致信息丢失,反而形成唯一复合表示。

这里我们暂不深入探讨为何简单的二进制编码不被采用,而正弦余弦编码却如此有效——这背后涉及到很多数学理论上的东西了。但如果你对正余弦位置编码感兴趣,非常推荐看看这位UP主的讲解:王木头学科学

然而,技术总是在演进。在当今大语言模型的实践中,标准的正弦余弦位置编码已不再是主流的选择。研究者们发现,直接让模型关注元素的相对位置往往比记住绝对位置更为有效和泛化。因此,一系列更先进的相对位置编码方法应运而生,例如:ALiBi,RoPE, YaRN, T5 Relative...

由于RoPE旋转位置编码比较流行,下面就简单的讲解一下其算法流程

我们抛开所有上面讲过的内容,仅从数学的角度看看如何对任意矩阵XX进行RoPE变换

  • XXmmdd列矩阵(共mm个维度为dd的向量,dd必须为偶数)

  • xn[j]x_{n}^{[j]}表示第nn行第jj列的数

对每一行的向量做如下操作:

将第nn行向量划分为d/2d/2个二维子向量,即[xn[1],xn[2]],[xn[3],xn[4]],[xn[5],xn[6]]...[xn[d1],xn[d]][x_{n}^{[1]},x_{n}^{[2]}],[x_{n}^{[3]},x_{n}^{[4]}],[x_{n}^{[5]},x_{n}^{[6]}]...[x_{n}^{[d-1]},x_{n}^{[d]}],然后依次进行下面的运算

[xn[i]xn[i+1]]=[cos(nθi)sin(nθi)sin(nθi)cos(nθi)][xn[i]xn[i+1]]\begin{bmatrix} x_{n}^{[i]}\\ x_{n}^{[i+1]} \end{bmatrix} = \begin{bmatrix} cos(n\theta_{i} ) & -sin(n\theta_{i} ) \\ sin(n\theta_{i} ) & cos(n\theta_{i} ) \end{bmatrix} \begin{bmatrix} x_{n}^{[i]} \\ x_{n}^{[i+1]} \end{bmatrix}

上式中的等号在算法实现中表示赋值更新操作,即用计算后的新值替换原向量中的对应元素。经过对每一组二维子向量依次进行上述变换后,我们便得到了 RoPE 变换后的新矩阵XX'

θi=100002(i1)d , i[1,2,...,d2]\theta_{i} = 10000^{-\frac{2(i-1)}{d} } \space , \space i \in [1,2,...,\frac{d}{2} ]

在Transformer中,只需要对编码器中的QQ矩阵和KK矩阵进行RoPE变换(Q=RoPE(Q),K=RoPE(K)Q = RoPE(Q),K = RoPE(K))就行,其它的操作流程完全不变,注意VV矩阵不需要进行RoPE变换,并且RoPE旋转位置编码应该在编码器的每一层都对QQKK应用。这样就可以去掉原始论文的架构图上的位置编码操作了。

Q=XWq(seq_len,k)K=XWk(seq_len,k)V=XWv(seq_len,out_size)\begin{matrix} Q = X\cdot W_{q} & (seq\_len,k) \\ K = X\cdot W_{k} & (seq\_len,k) \\ V = X\cdot W_{v} & (seq\_len,out\_size) \end{matrix}
Q=RoPE(Q)K=RoPE(K)Q = RoPE(Q)\\ K = RoPE(K)
P=QKTk   (seq_len,seq_len)P = \frac{Q \cdot K^T }{\sqrt{k}}\space\space\space (seq\_len,seq\_len)

如果你想对RoPE有更加深层次的理解,非常推荐观看作者武辰的视频讲解

不要看到RoPE这么复杂就感到害怕,你只需把握一个核心本质:它们本质上都是对原始注意力分数矩阵 QKTQK^T 进行修正。 无论具体操作是加一个偏置项,还是对查询和键进行旋转,其根本目的都是变着法子让模型建立起“距离”与“关注度”之间的关系:让相距较远的词对(对应的 qkqk 注意力分数)受到抑制,让相距较近的词对得到增强。

可以用下面这个图来简单的理解,距离越远的词,其注意力权重就会乘以一个越小系数

c19.jpg

只要你理解了这个本质,未来你也可以设计一个新的位置编码算法。

附录2:Pytorch中的Transformer组件

如何用Pytorch快速搭建Transformer中的组件?其实Pytorch中有现成的Transformer组件函数,都打包好了,可以直接调用,下面就一一讲解,若想更加深入的理解学习,推荐自行查阅官方手册

a2.1 TransformerEncoderLayer

a1.jpg

初始化参数:

  • d_model: 输入序列的特征数量
  • nhead: 多头注意力的头数量
  • dim_feedforward: 前向传播隐藏层数量,默认值为2048
  • batch_first: 输入输出维度顺序,默认False,但一般情况下强制其为True
  • dropout: 默认为0.1
  • activation: 激活函数,默认为relu,还可以使用gelu(gelu对序列任务表现较好,但是计算量大)

输入矩阵形状: (batch_size, seq_len, d_model)

# 使用示例
encoder_layer = nn.TransformerEncoderLayer(d_model=6, nhead=6,batch_first=True,activation="gelu")
inputs = torch.rand(1, 10, 6)
output = encoder_layer(inputs)
output.shape	# 输出(1,10,6)
# 添加掩码
seq_len = 10  # 序列长度
# 创建上三角掩码 (对角线以上为True)
src_mask = torch.triu(torch.ones(seq_len, seq_len), diagonal=1).bool()
output = encoder_layer(inputs, src_mask=src_mask)
output.shape	# 输出(1,10,6)

注意:Transformer编码器(Encoder)通常不需要上三角掩码,因为编码器应该能够看到整个输入序列。上三角掩码(因果掩码)通常是用于解码器(Decoder)中,防止模型在预测时看到未来的信息

a2.2 TransformerEncoder

多个TransformerEncoderLayer堆叠结构

a2.jpg

初始化参数:

  • encoder_layer: 一个TransformerEncoderLayer层实例

  • num_layers: 几个TransformerEncoderLayer层堆叠

# 使用示例
encoder_layer = nn.TransformerEncoderLayer(
    d_model=6, nhead=6, batch_first=True, activation="gelu"
)	# 先初始化一个TransformerEncoderLayer实例
transformer_encoder = nn.TransformerEncoder(encoder_layer, num_layers=4)    # 4层堆叠
inputs = torch.rand(10, 12, 6)
outputs = transformer_encoder(inputs)
outputs.shape	# 输出(10,12,16)
# 添加掩码,注意,一般在编码器中不加掩码
seq_len = 12  # 序列长度
mask = torch.triu(torch.ones(seq_len, seq_len), diagonal=1).bool()
output = transformer_encoder(inputs, mask=mask)
output.shape	# 输出(10,12,16)

a2.3 TransformerDecoderLayer

a3.jpg

初始化参数:

  • d_model: 输入序列的特征数量
  • nhead: 多头注意力的头数量
  • dim_feedforward: 前向传播隐藏层数量,默认值为2048
  • batch_first: 输入输出维度顺序,默认False,但一般情况下强制其为True
  • dropout: 默认为0.1
  • activation: 激活函数,默认为relu,还可以使用gelu(gelu对序列任务表现较好,但是计算量大)

输入矩阵形状: X=(batch_size,seq_len1,d_model),Yout=(batch_size,seq_len2,d_model)X = (batch\_ size, seq\_ len_{1}, d\_ model),Y_{out} = (batch\_ size, seq\_len_{2}, d\_ model)

decoder_layer = nn.TransformerDecoderLayer(d_model=12, nhead=4,batch_first=True)
Y_out = torch.rand(2, 10, 12)
X = torch.rand(2, 20, 12)   # 注意两个序列长度可以不一样
seq_len = 20
mask = torch.triu(torch.ones(seq_len, seq_len), diagonal=1).bool()  # 加掩码,当然也可以不加,主要看你的任务是什么
output = decoder_layer(X, Y_out,tgt_mask=mask)
output.shape	# 输出(2,20,12)

a2.4 TransformerDecoder

多个TransformerDecoderLayer堆叠结构

初始化参数:

  • encoder_layer: 一个TransformerDecoderLayer层实例

  • num_layers: 几个TransformerDecoderLayer层堆叠

使用方法和TransformerEncoder同理,不在此过多介绍