Attention in transformers, step-by-step | Deep Learning Chapter 6

How might LLMs store facts | Deep Learning Chapter 7

重要的概念

query、key和value。这三个词翻译成中文就是查询、键、值

可以理解为一种行为:当厨师在锅里准备放下一个调料时,他会看一眼菜谱。书会将他的查询query映射到书中相关的标签key,如辣椒,盐等等,然后书中会展示最匹配的调料value

SourceTargetSource(源)指的是输入。这里是菜谱。Target(目标)指的是输出。这里是锅里正在炒的菜(以及厨师脑子里已经生成的做菜步骤)

Self-AttentionAttention。上述的行为其实是属于 Attention(或者叫 Cross-Attention),而不是 Self-Attention。Attention 是拿着钥匙(Query)去开别人家的锁(Key)。

Self-Attention 是“把家里所有的零件(Q, K, V)摆在地上,看看谁和谁能拼在一起。

Cross-Attention可以理解为厨师做菜,Source是菜单/菜谱。Target是厨师锅里的菜。Cross-Attention 发生的过程就是当厨师(模型)在锅里准备放下一个调料(生成下一个词)时,他会抬头看一眼菜单(Source)。Query是厨师的询问,说明现在锅里有鸡肉了,下一步该放什么?Key是菜单上的每一行标题(如“主料“,调味”)。Value是菜单上对应的具体内容(如“葱段”、“盐”)。最终厨师通过 Query 去匹配菜单的 Key,最后拿到了 Value 放入锅中。这就是Target(锅里的进展)对 Source(静态菜单)的注意力。

厨师阅读菜单的时候,发生的是Self-Attention。菜单上写着“将洗净的辣椒切段,因为它很”。模型为了理解这个“它”是指辣椒还是指手,会自动把“它”和“辣椒”关联起来。这是Source 内部元素之间的互看。“它”产生的 Query 向量,在空间中的方向会指向“寻找那些具有名词属性且容易辣的东西”。而“辣椒”产生的 Key 向量,正好在空间中的那个位置。

注意力机制的直观理解

接下来的学习会以文本生成为例。

Token

指的是文本被切割成的小块,一般是单词或者是单词片段。

注意,Token 不是由Transformer模型本身分割的,而是由一个专门的预处理工具——分词器(Tokenizer)完成的。

分词器是在数据进入模型之前独立工作的。先把长文本切成标准规格的小块,再送进Transformer。

现在的主流模型(如 GPT-4, Llama)既不按字符切,也不完全按单词切,而是采用一种折中的方案。

方法 例子:unhappiness 优点 缺点
Word-level (按词) unhappiness 含义明确 词表太大(几百万个词),遇到新词(如网络流行语)就傻眼。
Character-level (按字符) u, n, h, a, p... 词表极小(几十个字母) 字符本身没意义,序列太长,增加模型计算负担。
Subword-level (子词 - 主流) un, happi, ness 兼顾两者 将常见词保留完整,生僻词拆成碎块。

这个主流分法有点像词根词缀的分法,但是这其实是一种统计意义上的分法,在结果上确实表现得非常像“词根词缀”拆解法。人类看到 unhappiness,我们知道 un- 是否定前缀,happy 是词根,-ness 是名词后缀。而分词器是扫描了数以亿计的文本,发现 un 这个组合出现的频率高得离谱,ness 也是。于是它会认为两个碎块很有用,要把它们存进词表里。

所以这种分法往往与语言学结构高度吻合。但是事实上,如果the经常出现在there里,而re也是高频碎块,它可能会把there拆成the和 re。这在语言学上是错的,但对模型来说,只要能降低词表大小且覆盖所有字符就行。

用到了分词主要是为了建立数学联系:当两个向量在空间里靠得近,它们就产生了注意力。

将Token关联到嵌入向量的高维向量。

简单来说,嵌入向量(Embedding Vector)就是单词在模型中的表示,模型会把所有的单词转换为坐标。

image-20260123192231878

在这一张图中,每一个单词都被转换成了上千个数字,每一个数字都可以被理解为一个属性维度,而大小则可以被理解为它们代表了在某个抽象维度上的偏移。虽然模型内部的维度很难用人类语言精准描述,但我们可以为了直观理解进行类比。假设一个只有 3 维的嵌入向量:一维表示的是是否是生物(-1到1),二维是体积大小,三维是是否昂贵。那么有:

  • 猫”可能是[0.9, -0.5, -0.2]
  • “狗” 可能是[0.9, -0.1, -0.3](因为都是生物,第一项很接近)
  • “手机”可能是[-0.8, -0.6, 0.7](非生物,且比较贵)

而Transformer会计算这些向量之间的角度。如果两个向量指向的方向差不多,模型就认为它们之间需要产生强烈的注意力。

这个向量是怎么来的?它是练出来的。模型刚开始完全是乱猜的,给每个词分配一串随机数字。模型在读了海量文本后发现,猫和狗经常出现在宠物、吃、可爱这种词周围。为了让预测更准,模型会自动调整数字,让猫和狗的向量在坐标系里靠得非常近。

注意力模块不仅细化了一个词的含义,还允许模型互相传递这些嵌入向量所蕴含的信息。

在没有注意力模块之前,每个词的向量是孤立的。比如单词 Bank。训练好的向量包含了一点 银行 的意思,也包含了一点 河岸 的意思,是个模糊的混合体。注意力模块的作用是,如果周围的词有 Money 、 Interest ,注意力模块会把这些词向量中的金融特征提取出来,将这些信息组合为向量,加到 Bank 的向量中。此时 Bank 的向量被在空间中的位置发生了偏移,变成了一个纯粹代表 银行 的向量。这个例子中的K Q V:

Query (Q) : Bank发出了信号,需要知道Bank在这个上下文中的具体意义,在数学意义上,Q是针对当前词想要关注的方向。

Key (K):是Money/Interest的标签,是由由周围的词(如 Money)产生。数学上K用来和Q做点积。如果Q(想要钱的信息)和K(我是钱)匹配度高,计算出来的注意力权重(Score)就会非常大。

Value (V):Money/Interest 传递的实质信息。既然匹配上了,Money 就把它的“金融属性”打包成一个向量发过去。这是最终被融合到 Bank 向量上的那部分实质内容。

注意,单纯的词嵌入向量是不含位置信息的。而在实际的预测过程中,模型还会加上位置向量,代表了这个Token在一个句子中的位置,因为如果只给模型一堆词向量,模型分不清“狗咬人”和“人咬狗”,因为对模型来说这只是两个完全一样的向量集合。所以在这些词向量进入注意力模块之前,模型会给每个向量强行加上一个微小的、特制的位置向量(Positional Encoding)。比如I saw a saw.第一个saw是动词,而第二个就是名词,会有区别,他们的数值就会产生细微差别。这样模型就能通过数学计算知道这个词应该出现在哪一个位置。

有了这些带有位置和基础语义的向量,就可以通过$W_Q, W_K, W_V$矩阵进行线性变换。

$W_Q$矩阵

$W_Q$ 是一个具体任务。Transformer 并不是只有一个 $W_Q$,它拥有成百上千个并行的微型任务。因为Transformer是多头注意力机制,所以第一个头可能在问这个词的前面形容词的个数,另一个可能是在问前面名词个数,这里说是任务,实际上在数学中,他更像是一个视角,他放大了这个词的一些属性。在训练时,它是动态的。 模型通过不断看大量的句子,利用梯度下降算法去反复调整 $W_Q$ 矩阵里的每一个数字。在推理/使用时,它是“静态”的。一旦模型练好了,这个 $W_Q$ 矩阵里的数值就固定死了。

$W_K$矩阵

$W_K$ 的作用就是将句子中每一个词的原始向量,也转换到一个同样的抽象任务空间里,把它变成一张方便被索引的名片(Key)。$W_Q$(Query)给出了一个具体任务,那么$W_K$(Key)就是每一个Token这个任务的具体的权重。具体来说,以“狗”这个词为例:

Embedding:它是“狗”这个词的各种属性分解,可能包含“动物”、“尺寸”、“寿命”、“忠诚度”等属性,将他压缩成了一串数字,称为向量。

$W_Q$:它不是在制造属性,而是在筛选属性。它定义了在当前的注意力头里,我们只关心动物性和位置。它把这两者的权重调高,让 $Q$ 变成了一个带有强烈搜索指向的向量。

$W_K$:它让所有的词(不仅仅是狗)都根据 $W_Q$ 的偏好来重新表达。它把所有词的 Embedding 转化为不同的 $K$,这个 $K$ 重点突出了“我是动物”这个标签,以便能被 $Q$ 发现。

$Q \cdot K$:这步运算就是属性吻合度检测,当需求(Q)和拥有(K)在某些维度上同时呈现出高分,点积就会变得巨大。这个分值就是“注意力权重”。

image-20260123213100771

这张图是假设$W_Q$为:有没有哪一个形容词在我前面。就会产生这样的结果,相乘结果越大说明得分越高,也就是越相关。

为什么要搞得这么复杂?既然 Embedding 里已经有“动物”属性了,为什么不能直接拿来比?答案是为了上下文灵活性。在“狗咬人”里,模型需要注意狗的“攻击性”。在“狗很可爱”里,模型需要注意狗的“外观属性”。同一个 Embedding,通过不同的 $W_Q$ 和 $W_K$(不同的头),可以在第一种情况下提取“攻击性”进行匹配,在第二种情况下提取“可爱度”进行匹配。

这样模型就能够根据语境理解单词。

$W_Q$ 定义了“任务”:在这个视角下,哪些属性是加分项。

$W_K$ 提取了“名片”:把词的原始含义转化成该视角下的属性值。

$Q \cdot K$ 实现了“匹配”:属性越吻合,分值越高,产生的权重就越大。

回到那张图,然后将这些经过SoftMax后得到:

image-20260123213845905

这个叫做注意力模式(Attention Pattern),在论文中被表示为一个公式:

image-20260123214116705

这里面有一个V,先不考虑,$\sqrt{d_k}$被称为缩放因子(Scaling Factor),它的出现是为了解决一个数学问题:防止梯度消失。

$d_k$ 代表Key向量的维度。简单来说,就是向量里有多少个数字。

要除以它的平方根是因为Softmax对巨大的数值非常不敏感,点积会随着维度变大而爆炸,假设向量里的每个数字平均大小是 1。如果向量只有 2 维,点积结果大概是 2;但如果向量有 1000 维,点积结果可能就会冲到 1000 左右。Softmax 函数在输入数值非常大(比如 100 或 1000)时,其梯度(导数)会变得极其微小,几乎接近于 0。如果分数太高,Softmax 算出来的概率会变成极其极端的 $[0.9999…, 0.0000…]$。一旦进入这个状态,模型在训练时就无法通过梯度来更新权重了。也叫梯度消失。通过除以 $\sqrt{d_k}$,模型强行把那些可能“爆炸”的点积得分拉回到一个适中的范围。

掩码

此外还有一个细节,在训练的过程中,对于给定的示例文本训练时,模型会预测出下一个词的概率高低,然后最小化损失函数来调整权重,但是更效率的做法是同时预测每一个初始Token子序列的下一个Token,比如可以同时预测第一个‘the’的后一个Token,也可以预测‘creature’的下一个Token,这样每一个训练样本可以提供多次训练机会,这也是Transformer训练比老一代模型(如 RNN)快得多的原因:

image-20260124104808697

但是存在一个问题,我们需要让模型看不到后面的词,不然后面的词会影响前面的词,存在数据泄漏。所以在softmax之前,往往会将后面的值初始化为负无穷:

image-20260124105219170

这在训练阶段是必须防止的。但其实在测试/生成阶段(比如和已经训练好的模型聊天时),其实不需要专门设置负无穷,因为那时候未来的词根本还没生出来,模型想抄也抄不到。GPT 系列 (ChatGPT, Llama, Claude)这类模型会在两个阶段都设置掩码,但是像是BERT这类模型在任何阶段都不使用这种防偷看的掩码。

这个是在自注意力机制下的,如果是交叉注意力,那么其实根本就不用管掩码的问题。

$W_V$矩阵

然后$V$(Value)其实就是负责怎么把实质内容搬运过去的指令集。

为什么不直接加原向量,而要用 $V$ 转换一下?

$V$ 的作用是告诉模型应该搬运什么信息。它能够过滤杂质,因为原始的 Embedding $\vec{E}$ 包含了太多的信息(比如这个词的词性、位置、首字母是什么等)。但在当前的注意力任务中(比如“寻找前面的形容词”),creature 只需要 blue 这个词中关于“蓝色”的具体语义。$W_V$ 矩阵也是一个信息提取器。它把 blue 的原始向量中那些对当前任务有意义的实质性内容提取出来,变成了 $\vec{V}$。

$V$ 与 $K$ 的区别,其实是标签与内容的区别,具体来说$K$ (Key):是给别人看的,为了让 $Q$ 找到自己,它决定了权重是多少。$V$ (Value):是给别人用的(为了让 $Q$ 吸收自己),它决定了搬运的内容是什么。比如去买书,书的封面和标题是 $K$,它是吸引买家买下它的理由(产生权重);书里的文字内容是 $V$,他是最后带回家读进脑子里的东西。数学上是加权求和实现的,$Q$ 和 $K$ 算出了权重(比如 $0.58$)。通过$W_V$ 把该词变成 $\vec{V}$。然后将 $\vec{V}$ 乘以 $0.58$。最后把这个缩放后的向量加到产生 $Q$ 的那个词(creature)的 $\vec{E}_4$ 向量上。

image-20260123224816601

在自注意力机制中,每一个词都在同时扮演“提问者”和“被选者”的角色。在这一层处理结束时,不仅仅是 creature 变聪明了,句子里的每一个词都根据上下文完成了更新:

  • 对于 creature:它吸收了 fluffyblue 的信息,变成了“蓬松的蓝色的生物”。
  • 对于 forest:它可能通过自己的 $Q$ 找到了前面的 verdant(翠绿的),从而吸收了“绿色”的特征,变成了“翠绿的森林”。
  • 对于 roamed:它可能关注到了 creature,从而在向量里补充了“是谁在漫游”的信息。

这种全员同步更新的过程,就是图中下方那一排 $\Delta \vec{E}_1, \Delta \vec{E}_2 \dots$ 的含义。每一个词都算出了一个所有词的权重,然后加回到自己原来的向量上,生成了全新的 $\vec{E}’$。

注意这个是单头注意力的情况,它只代表了模型一种观察句子的方式,但是如果只用这一个矩阵,模型可能就没精力去管“主谓关系”或“代词指代”了。这就是为什么真正的 Transformer 是多头的:它们会并行运行好几个这样的内容,每个头都有自己的 $W_Q, W_K, W_V$。比如头 1让名词找形容词;头 2 让动词找主语;头 3找逻辑转折词。

右侧公式$\vec{E}_n + \Delta \vec{E}_n = \vec{E}’_n$中,

  1. $\vec{E}_n$ (原始值):保持了词语原本的含义(比如“我是个生物”)。
  2. $\Delta \vec{E}_n$ (变化量):这是通过刚才那一堆 $Q, K, V$ 计算出来的上下文中需要注意的内容。
  3. $\vec{E}’_n$ (最终结果):这是带有上下文理解的词向量。

这种“加法”操作在深度学习里叫残差连接(Residual Connection)。它的好处在于即便注意力机制算错了,原始的词意也不会丢失,模型只会把注意力看作一种“补充修正”。这个概念最早来自 2015 年的 ResNet 论文。在数学上,如果你想让一个函数 $f(x)$ 学习某种变换,直接学 $f(x)$ 可能很难。但如果我们将输出定义为 $H(x) = x + f(x)$,那么 $f(x)$ 只需要学习输出与输入之间的差值(即 $H(x) - x$)。

image-20260125095005646

以GPT-3为例,每个模块内使用96个注意力头,即有96个不同的键和查询矩阵,会产生96种注意力模式,每个注意力头都会有独特的值矩阵,用于产生96个值向量序列,这张图中的中间部分展示了每一个token,每个头都会给出要加入到这个位置的嵌入中的变化量,所以对于原始的嵌入,要加上所有注意力头给出的变化量。

在具体实现中,还有一个细节,之前提到为了增加运行效率,可以对这个大矩阵进行低秩分解:

image-20260124114735640

简单来说,模型并没有直接使用一个包含 $1.5$ 亿参数($12,288^2$)的大矩阵,而是把它拆成了两个“瘦长”的矩阵相乘:

  • 矩阵 A ($Value_\downarrow$):大小为 $128 \times 12,288$(将高维压缩到低维)。
  • 矩阵 B ($Value_\uparrow$):大小为 $12,288 \times 128$(将低维重新映射回高维)。

$W_V$ 的效果 $\approx$ 矩阵 B $\times$ 矩阵 A,这种低秩分解(Low-rank Decomposition)和 SVD(奇异值分解)有点像,不同的是SVD 分解:通常是指已经有一个现成的大矩阵,为了分析它或者压缩它,用数学手段把它拆成三个矩阵的乘积 ($A = U\Sigma V^T$)。这会保留前 $k$ 个最大的奇异值,从而得到一个低秩的近似矩阵。而Transformer 中的低秩设计是,模型在设计之初,就根本没打算创建一个完整的大矩阵。工程师直接让模型去学习两个小的瘦长矩阵(矩阵 A 和 矩阵 B)。因此对每一个注意力头的$W_V$都做了一个分割:

image-20260125095036229

但是实际实现中$Value\uparrow$和$Value\downarrow$的说法会有所变化,所有的$Value\uparrow$会合在一起说成是输出矩阵,与整个注意力模块相关联,而每一个注意力头的$W_V$其实是$Value\downarrow$矩阵,即那些将嵌入向量投影到低纬度空间的矩阵。

多层感知机/前馈神经网络

这部分占了大部分的模型参数。其实和传统的模型差不多,大致意思就是把信息打散了存在某一块里面。

有理论认为,这一块提供了额外的容量来存储事实。

以“迈克尔乔丹打篮球”这一事实为例,假设这个高维空间里面,有一个向量能够表示名字是迈克尔,另外有一个向量表示姓氏是乔丹,并且这两个向量几乎是垂直的。

image-20260125095423417

视频里面说垂直的原因在于,在高维空间中,无数个互不干扰的方向,非常空旷,而随机抽取两个向量,它们之间夹角接近 $90^\circ$ 的概率极高。显然,对于一个一个N维空间,最多只有N个两两垂直的向量,但是放宽一点限制,比如89°、91°也算正交,那么这些向量的数量是随维度的增加指数级增长的。这被称为高维空间的近似正交性,Johnson-Lindenstrauss (JL) 引理:在 $N$ 维空间中,你可以找到多达 $e^{N \cdot \epsilon^2}$ 个向量,它们两两之间的夹角都在 $90^\circ \pm \epsilon$ 之间。所以当从高维空间随机采样向量时,这些向量的分量往往表现出正态分布的特性,而这个特性会导致近似正交,证明:

因为在 $N$ 维空间有两个随机单位向量 $\vec{A}$ 和 $\vec{B}$。它们的内积(点积)决定了它们是否交:

由于每个分量 $a_i$ 和 $b_i$ 都近似服从均值为 0 的正态分布:

  1. 每一项 $a_i b_i$ 也是一个随机变量,其均值依然是 0。
  2. 当把这 $N$ 个项加起来时,根据大数定律,正值和负值会极大概率相互抵消。
  3. 随着维度 $N$ 越来越大,这个总和(内积)会极其趋近于 0。

而内积趋近于 0,意味着夹角趋近于 $90^\circ$。

其实在机器学习中,通常都是希望模型学到的特征是独立且不相关的。这个可以说是模型训练后的理想结果;此外,模型在梯度下降时,如果发现两个概念混淆导致了误差,它也会这两个向量在空间中尽可能的正交,直到它们互不干扰(即趋向垂直)。这也是为什么再后面降到MLP的具体设计中,要把词向量进行升维。

如果有一个向量,表示迈克尔乔丹这个人,那么他与姓名这两个向量的点积一定是1(这里是假设1表示完美的信号匹配,在真实的 Transformer 模型中,点积经常大于 1,如果点积大于 1,通常意味着该特征被强化了,这也是为什么注意力机制公式里会看到 $\frac{QK^T}{\sqrt{d_k}}$。那个分母 $\sqrt{d_k}$ 就是为了把可能由于高维产生的巨大点积数值“压”回来,防止它太大导致 Softmax 饱和)。

image-20260125095438986

此外还有一个假设:由于“迈克尔·乔丹”这个文本跨越了两个Token(一个是左边的迈克尔,另一个是右边的乔丹),他要假设前面的注意力模块,成功将信息传递给这两个向量中的第二个(乔丹模块)确保能编码全名。

这个假设实际上是在解释模型如何处理分布式特征。在文本中,“Michael” 和 “Jordan” 是两个独立的 Token。在第一层,Michael 对应的向量只知道自己是“迈克尔”,Jordan 对应的向量只知道自己是“乔丹”。而MLP 层是逐位置计算的,它不会看旁边的词。如果信息不传递,Jordan 位置的 MLP 永远不知道前面站着一个 Michael,也就无法触发“篮球飞人”这个特定事实的提取。因此,必须有一个前置步骤,把分散的信息“汇聚”到一个地方。

在MLP里面,每一个向量都会通过一些操作,最终会得到另一个维数相同的向量,将得到的向量与原本的向量增加,而增加的结果就是输出值。

image-20260125095451202

注意这个时候,向量之间是不会交流的。

在刚才的例子中,注意力模块发现“乔丹”和“迈克尔”关系紧密,于是把“迈克尔”这个向量的信息拷贝并叠加到了“乔丹”的向量上。得到了一个编码了“名字迈克尔”和“姓氏乔丹”的向量,这个向量流入MLP,经过这一系列的运算,能够输出包含“篮球”方向的向量,再将其与原向量相加,就会得到输出向量。

image-20260125095500370

MLP 的第一层矩阵是线性变换 (Linear)。其中某一个神经元(一组权重)专门在等一个特定的信号:即“名=迈克尔”且“姓=乔丹”。如果这个混合向量里的两个信号同时足够强,点积结果就会很大,从而通过 ReLU 这个函数激活。一旦被激活了,MLP 的第二层线性层就会输出一个全新的向量。这个向量的方向代表了“篮球”这个概念。

最后一步就是残差的相加”:

模型并没有把“乔丹”变成“篮球”,而是在“乔丹”的向量里添加了“篮球”这个属性。而经过这一层处理,这个向量就像变成了一个“带有篮球运动员标签的乔丹”。这个进化的向量会被传送到下一层,去寻找更多的关联(比如预测下一个词会不会是“灌篮”)。

所以在论文图中也有看到,其实在经过注意力和前馈神经网络(MLP)之后都有一个“Add”的操作,两者的区别在于Attention它打破了原本词向量间的孤立,让“乔丹”向量拿到了“迈克尔”的信息。MLP它识别出这个组合,产出对应的“事实”向量。而残差连接能把事实写回向量中。

注意力解决了 “谁和谁有关” 的问题。它把分散在序列各处的信息聚拢到一个向量里。MLP 解决了 “这些信息合在一起意味着什么” 的问题。它从聚拢的信息中提取出深层的、隐含的事实(比如“篮球”)。

其实MLP的结构和以前的传统神经网络很像,其实就是标准的三层架构:

  • 输入层:维度为 $d_{model}$。
  • 隐藏层:通过线性变换(Linear)将维度拉伸到 $d_{ff}$,然后接一个非线性激活函数(ReLU 或 GELU)。
  • 输出层:再通过一个线性变换缩回到 $d_{model}$。

以前的神经网络通常尝试用一个巨大的MLP完成所有任务(识别、理解、输出)。Transformer 里的 MLP不再负责寻找词与词的关系,而是专心做存储。这也是为什么都在说MLP负责存储在训练中获得的知识。

具体来说,第一层就是一个线性层,就是传统 BP神经网络 的核心逻辑类似。

image-20260125100716255

MLP 的内部计算公式是标准的 $W \vec{E}_i + \vec{B}$:

image-20260125095520539

然后就是传统的神经网络模型,用ReLu将线性表现为非线性:

image-20260125095528883

当然在大多数模型中,会用一个叫做GeLu(高斯误差线性单元):

image-20260125095540470

因为ReLU (Rectified Linear Unit)的逻辑非常死板。只要点积结果是 $-0.00001$,输出就是 $0$;只要是正数,就原样输出。那么这样在训练中容易出现“神经元死亡”现象:如果一个神经元的偏置项变成了很大的负数,它可能永远无法被激活,导致这部分“内存”废掉了。而GeLu它结合了概率的思想。当输入是负数但接近 $0$ 时,它不会立刻切断信号,而是允许一点点带权重的负值信号(抑制信号)传过去。在处理“迈克尔·乔丹”这样的复杂语义时,GELU 的表现更像人类的模糊逻辑,现实中的向量信号不会像假设的“刚好是 1”那么完美,GELU 允许模型在信号稍有偏差或带有轻微噪音时,依然能保留一部分有用的信息,而不是直接把它杀掉。

经过这一部分的处理之后,最后一步是将这个向量降维至原始维度。在第一层(Up-projection)之后,向量被拉伸到了极高的维度(如 51,200 维),这是为了提供足够的检测空间。所以我们需要一个巨大的 $12,288 \times 51,200$ 的矩阵,把这 5 万多个神经元的输出“加权求和”,压缩回原始的 12,288 维。

image-20260125100933460

在训练初期,这个矩阵里的数值也是随机的乱码。最初,当“迈克尔·乔丹”神经元被激活时,这个降维矩阵可能随机地输出一个代表“厨师”或“天空”的向量。模型发现预测错了,梯度信号就会顺着路径传回来,修改这个降维矩阵里对应那一列的数值。经过无数次修正,那一列的数值慢慢变成了一个指向篮球语义的向量。

image-20260125101649720

这个矩阵的列数和中间隐藏层的神经元数量一样多(比如 5 万列)。在这里,按列来看矩阵更符合知识提取的含义:

假设我们有一个矩阵 $A$(对应 MLP 的降维矩阵)和一个向量 $\vec{x}$(对应神经元的激活值):

这个公式的意思是:矩阵 $A$ 乘以向量 $\vec{x}$,等于矩阵 $A$ 的各列向量以向量 $\vec{x}$ 的各个分量为系数进行线性组合。上图中可表示为:

这里的每一个 $n_m$ 都是中间层神经元的激活值(也就是检测器的结果),而每一列 $\vec{C}_m$ 就是这个神经元对应的”结论向量”。更通俗一些可以理解为当多个神经元同时激活时,模型就把这些列向量按比例加在一起,比如:$0.8 \cdot [\text{篮球}] + 0.5 \cdot [\text{飞人}] + 0.2 \cdot [\text{棒球}]$,最终得到的 $\Delta\vec{E}$ 就是这些语义的“混合结论”。把这个矩阵记为$W\downarrow$偏置向量记为$B\downarrow$,然后把这些残差加回去,就表示模型学到了新的结论:

image-20260125103622914

GPT3的参数量计算

image-20260124110107947

  1. 词向量层 (Embedding & Unembedding),其实就是词典。
  • Embedding: 把文字(Token)变成初始向量。
  • Unembedding: 把最后算完的向量变回文字。
  • 参数量: 取决于词库的大小(GPT-3 约有 5 万个词)和向量的维度(12,288 维)。这部分虽然重要,但在总参数量中占比很小。
  1. 注意力机制相关 (Key, Query, Value, Output):GPT-3 有 96 层,每一层都有96 个注意力头。
  • Key, Query, Value ($W_K, W_Q, W_V$): 每一层、每一个头都有这三组矩阵。它们负责词与词之间的信息交换。
  • Output ($W_O$): 当 96 个头各自算出结果后,需要一个矩阵把这些不同的视角重新合并在一起。
  • 每一层都有巨大的矩阵来处理 12,288 维的向量。因为每一层有96个头,所以每个头分配到的是12288/96=128,128×12288大小的$W_K, W_Q, W_V$

image-20260124114526262

上文也有提到,为了符合矩阵运算$W_V$应该是12288×12288,但实际上会对大矩阵进行低秩分解,做了一个分割:

image-20260125095036229

但是实际实现中$Value\uparrow$和$Value\downarrow$不叫这个名字,所有的$Value\uparrow$会合在一起说成是输出矩阵,与整个注意力模块相关联,而每一个注意力头的$W_V$其实是$Value\downarrow$矩阵,即那些将嵌入向量投影到低纬度空间的矩阵。所以真正的$W_V$矩阵是12288(词向量大小)× 每一个注意力头需要的维度(12288/96=128)。Output ($W_O$)的维度则是他的转置矩阵的维度。

  1. 前馈神经网络 (Up-projection & Down-projection),在每一层注意力计算完之后,模型会进入一个“全连接层”(Feed Forward)。
  • Up-projection: 把向量维度拉伸得非常大(GPT-3 将 12,288 维拉伸到 49,152 维,乘上4倍)。这是模型推理的地方。
  • Down-projection: 再把维度压缩回 12,288 维,传递给下一层。
  • 这是模型中参数量最大的部分。大约 2/3 的参数都集中在这里。

image-20260125104248008

一个Token维度的变化:

阶段 维度变化 具体的改变
1. 初始输入 $1 \to 12,288$ 查表嵌入(Embedding)。一个单词 ID 变成了一个 1.2 万维的稠密向量。
2. 注意力层 保持 $12,288$ 信息的交流。向量通过 Attention 吸收了周围词的信息,但体型没变。
具体来说,一开始是12,288 维的 $\vec{E}_i$。
然后被96个注意力头分身成 96 对 $\vec{Q}, \vec{K}, \vec{V}$,每组 128 维。
各组根据 $\vec{Q}$ 和 $\vec{K}$ 打分,确定注意力分数。
根据分数,并按照各自对应的$\vec{V}$ 加权求和,形成 96 个 128 维的$\Delta \vec{E}_i$。
把$\Delta \vec{E}_i$拼回 12288 维。
通过残差连接(Add),把这些情报加回原来的 $\vec{E}_i$ 上。
3. MLP 升维 $12,288 \to 49,152$ 升维(Up-projection)。向量被拉伸到近 5 万维,去触发那些“事实检测器”。
4. 激活函数 保持 $49,152$ 筛选(GELU)。5 万个维度中,只有少数符合逻辑的“神经元”保持活跃。
5. MLP 降维 $49,152 \to 12,288$ 降维(Down-projection)。把筛选出的新知识压缩回原始宽度。
6. 循环往复 重复以上过程 96 次 迭代。GPT3设置了96层(n_layer参数),每一层都像这样升维再降维,向量就能获取更多的知识。
7. 最终输出 $12,288 \to 50,257$ 解密(Unembedding)。向量映射到 5 万个单词概率上,决定吐出哪个字。

*其实这个对于GPT来说的n_layer参数在其他模型中叫做Decoder层的维度,传统 Transformer(如翻译任务)需要 Encoder 读入中文,Decoder 产出英文。GPT 系列是纯 Decoder 架构(Decoder-only),它本质上是一个“接龙”模型。它把之前写好的所有字都当成“已有的输入”,直接塞进一串 Decoder 里去预测下一个字。

Transformer源码解读

GPT-3是只用了Decoder,但是实际上在原始论文中,Transformer的架构是用于机器翻译的。找了一个Transformer的源码:https://github.com/graykode/nlp-tutorial/tree/master

更好的阅读体验/jupyteerNoteBook源码链接:z01prime/TransformerCodeTutorial: pytorch的Transformer的代码实现

环境

windows 11

Python 3.11.14

包环境:

  • numpy 1.23.5
  • torch 2.2.2
  • matplotlib 3.7.0

也没有这么严格的要求,能不冲突就行

导入一些包

首先要先导入一些常用的库

1
2
import os
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"

解决可能会有的老环境问题

1
import numpy as np

在 Transformer 中主要用于处理矩阵运算、生成位置编码(Positional Encoding)的三角函数序列,以及处理一些数组形状的变换。

1
import torch

这个是PyTorch 的核心库,提供了张量操作,支持GPU加速

1
import torch.nn as nn

PyTorch 的神经网络模块(neural network)

1
import torch.optim as optim

提供一些优化器,包含了如 Adam、SGD 等优化器,用于根据损失函数计算的梯度来更新模型参数。

1
import matplotlib.pyplot as plt

画图用的

数据集

因为是演示,所以简单提供一些数据就行。写的简陋些,就假设要训练一个德语翻译为英语的任务:ich mochte ein bier P翻译为i want a beer

代码里准备了三份数据:

enc_inputs: ich mochte ein bier P (Encoder 看到的原话)

dec_inputs: S i want a beer (Decoder 看到的“辅助信息”,告诉它:从 S 开始,后面跟着这些词)

target: i want a beer E (模型真正的“学习目标”,告诉它:最后要输出这些词,并以 E 结束)

这种机制在深度学习中被称为 Teacher Forcing(老师引导/强制教学)。dec_inputs和target有点像,因为dec_inputs是输入,用于给模型参考的。而target是目标,是标准答案。它们的关系是错开一位的:

时间步 (Step) Decoder 输入 (dec_inputs) 模型尝试预测的词 标准答案 (target_batch)
第 1 步 S $\rightarrow$ i
第 2 步 S i $\rightarrow$ want
第 3 步 S i want $\rightarrow$ a
第 4 步 S i want a $\rightarrow$ beer
第 5 步 S i want a beer $\rightarrow$ E

dec_inputs 总是比 target 早一个位置(这也是论文中的Transformer框架图里面为什么还注上了Shift Right)。模型在每个位置的任务都是:根据已经看到的词,预测下一个词。

这个是训练部分,当然在真实的测试场景根本没有target,因为还不知道答案。

1
sentences = ['ich mochte ein bier P', 'S i want a beer', 'i want a beer E']

词表与Target/Source长度

词表

那么除了提供训练的句子,还要有一个词表用于分词,所以自己定义了一个词表:

这个是source的词表:src_vocab = {'P': 0, 'ich': 1, 'mochte': 2, 'ein': 3, 'bier': 4}

这个是Target的词表:tgt_vocab = {'P': 0, 'i': 1, 'want': 2, 'a': 3, 'beer': 4, 'S': 5, 'E': 6}

tgt_vocabsrc_vocab多了 ‘S’ 和 ‘E’。并且src_vocab有一个‘P’.因为src_vocab是给Encoder用的(德语),而tgt_vocab是给Decoder用的(英语)。

这些都是特殊字符:

S (Start / BOS - Beginning of Sentence),他是解码器的起始符号。在推理(翻译)时,解码器(Decoder)第一步并不知道该输出什么。我们会喂给它一个 S,告诉它从这里开始翻译第一个词。

E (End / EOS - End of Sentence),是句子的终止符号。训练时,当翻译完最后一个词后,模型要输出一个 E表示结束了。这样在实际使用时,看到模型输出了 E,就知道翻译结束了,否则它可能会无限循环下去。

P (Padding),就是一个填充符号。用于对齐。如果一个 Batch里,第一句有 5 个词,第二句只有 4 个词,我们会给第二句补上一个 P,让两句话长度一致,方便并行计算。具体来说,Padding 的目标是让整个 Batch 变成一个 矩形矩阵($Batch_Size \times Seq_Len$)。这里‘p’是0是有原因的,如果位置向量是全 0,那么根据公式:
这意味着填充位 ‘P’ 在空间中没有任何位置偏移,它不会携带任何顺序信息。

还有一个好处是掩码的时候算的很快,因为判断一个数是否等于 0 是最快的。之后代码中的get_attn_pad_mask函数会有用到。

这里说的“词”都是指Token,而不是字符数或单词数,当然真实情况下分词器不一定就是恰好一个单词一个单词分,是按照每个字母组合的概率来分,分出来的有点像是词根词缀

1
2
3
4
5
6
7
8
# Transformer Parameters
# Padding Should be Zero
src_vocab = {'P': 0, 'ich': 1, 'mochte': 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}
number_dict = {i: w for i, w in enumerate(tgt_vocab)} # 做一个反向的映射数字->单词
tgt_vocab_size = len(tgt_vocab)

number_dict用于解码,把模型输出的数字,翻译回人能看的单词。

比如: [1, 2, 3, 4, 6] → i want a beer E

Target/Source长度

1
2
src_len = 5 # length of source
tgt_len = 5 # length of target

还要设置source和target的长度,这里统一设置为5

比如传进去的batch_size参数设为2,那么比如有这样的句子:

Gib mir ein Glas Wasser

ich mochte ein bier

规定按照单词来分词的话,那么显然第一句和第二句的长度不一样,第二句少了一个Token,要补一个P,这样让两句话长度一致,方便并行计算。所以这也是为什么我给的句子中是“ich mochte ein bier P”而不是“ich mochte ein bier”

在实际的大规模模型(如 GPT 或 BERT)中,一般会使用专门的 Tokenizer(如 Byte-Pair Encoding, BPE)。但在这段代码里,我使用的是最原始的空格切分:input_batch = [[src_vocab[n] for n in sentences[0].split()]]

在时间序列任务中,为了让 Decoder 更有序地预测,通常会给它一段已知的历史数据作为前缀(即https://github.com/thuml/Time-Series-Library 启动脚本中的label_len部分),这和 NLP 的 Teacher Forcing 非常像。

时间序列参数 含义 对应你代码中的部分
seq_len 历史观察窗口长度 (Look-back window) src_len (Encoder 输入的长度,这里是 5)
label_len Decoder 能看到的“已知答案”长度 (作为提示) dec_inputs 中的 S 及其后续部分
pred_len 最终要预测的未来长度 target 中除去前缀后的部分

分完词后,要转换为数字,这也叫Numericalization(数值化),把这些Token映射成词表里的索引数字。例如ich变成1。所以要写一个函数负责数值化,这里起名为make_batch(见后续的代码实现),然后还要对其进行向量化(Embedding)将数字索引变成浮点数向量比如[0.12, -0.55… ]之类的。pytorch提供了一个方法,在模型里我用nn.Embedding负责向量化。

这样就完成了框架图中的Embedding部分:

image-20260127142731814

超参数设置:

1
2
3
4
5
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
参数名 大小 深度说明
d_model 512 模型维度。它是 Embedding 的长度,也是残差连接(Residual)路径上向量的长度。
d_ff 2048 前馈网络中间层维度。在 FFN 中,向量会先从 512 升维到 2048,再降回 512。
d_k = d_q = d_v 64 单头维度。每个注意力头在计算时,会将 512 维“切开”进入 64 维的子空间进行观察。
n_layers 6 堆叠层数。论文中 Encoder 和 Decoder 分别由 6 个完全相同的层堆叠而成(即 $N=6$)。
n_heads 8 头数**。决定了模型同时在多少个不同的语义子空间中进行并行注意力计算。

模型代码

多头注意力机制

这个部分实现了注意力公式:

1
2
3
4
5
6
7
8
9
10
class ScaledDotProductAttention(nn.Module):
def __init__(self):
super(ScaledDotProductAttention, self).__init__()

def forward(self, Q, K, V, attn_mask):
scores = torch.matmul(Q, K.transpose(-1, -2)) / np.sqrt(d_k) # scores : [batch_size x n_heads x len_q(=len_k) x len_k(=len_q)]
scores.masked_fill_(attn_mask, -1e9) # Fills elements of self tensor with value where mask is one.
attn = nn.Softmax(dim=-1)(scores)
context = torch.matmul(attn, V)
return context, attn
  • torch.matmul:这个是pytorch的批量矩阵乘法,它会保持前两个维度(batch 和 n_heads)不动,只对最后两个维度执行矩阵乘法。计算的形状变化:表示的意义是对于每一个头,查询句中的每一个词(len_q)对键句中的每一个词(len_k)的关注程度。当然如果是二维矩阵直接用.T也是可以的,对于这种 4 维张量,只想交换最后两个维度还是直接用transpose会更好些
  • masked_fill_:直接原地修改scores这个变量,将所有之前算出来的attn_mask中维True的位置全部换成一个很小的数字,这样在做softmax之后,$e^{-1e9}$就约等于0
  • context是$\vec{E}_n + \Delta \vec{E}_n = \vec{E}’_n$中$\Delta \vec{E}_n$的一部分,是每一个头产生的增量。

此外关于为什么对最后一个维度做Softmax,是因为公式中是$Q K^T$,所以得到的矩阵行是Q,列是K的相关信息,逐列做归一化,就是在计算每个Token的Q对于句子中每一个Token产生的K的注意力分数的归一化。以那一句德语为例,这句德语:[‘ich’, ‘mochte’, ‘ein’, ‘bier’, ‘P’]由于参数是batch_size = 1, n_heads = 8, d_model = 512, d_k = 64。可以得到矩阵的整体形状是[1, 8, 5, 5]。为了直观,只看其中一个 Head,它的横轴(列)是 Key,纵轴(行)是 Query,可能得注意力分数是:

Query \ Key ich (1) mochte (2) ein (3) bier (4) P (0) 行总和
ich 0.8 0.1 0.05 0.05 0.0 1.0
mochte 0.2 0.6 0.1 0.1 0.0 1.0
ein 0.05 0.1 0.7 0.15 0.0 1.0
bier 0.05 0.1 0.25 0.6 0.0 1.0
P 0.2 0.2 0.2 0.2 0.2 1.0

Softmax 的公式是 $P_i = \frac{e^{x_i}}{\sum e^{x_j}}$,所以最后一行就算全是-1e9那么平均下来每个也能分到0.2。这个注意力分数会影响结果,所以我会在最后计算 Loss 的时候,直接设置ignore_index=0,这样计算 Loss 的时候,在PyTorch 检查 target_batch时,凡是标签值为 0(即字符 ‘P’)的位置,产生的损失都会被直接标记为 0,直接把 P 这个位置产生的任何输出结果给物理抹除。

框架中对应的MultiHeadAttention部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MultiHeadAttention(nn.Module):
def __init__(self):
super(MultiHeadAttention, self).__init__()
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):
# 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)
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 = attn_mask.unsqueeze(1).repeat(1, n_heads, 1, 1) # attn_mask : [batch_size x n_heads x len_q x len_k]

# context: [batch_size x n_heads x len_q x d_v], attn: [batch_size x n_heads x len_q(=len_k) x len_k(=len_q)]
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 * d_v]
output = self.linear(context)
return self.layer_norm(output + residual), attn # output: [batch_size x len_q x d_model]

这个模型在初始化的时候的参数,对应了GPT-3的Query,Key,Value,Output:

image-20260127142911815

变量 对应的层 GPT-3 的参数计算公式
self.W_Q Query $d_query \times d_embed \times n_heads \times n_layers$
self.W_K Key $d_query \times d_embed \times n_heads \times n_layers$
self.W_V Value $d_value \times d_embed \times n_heads \times n_layers$
self.linear Output $d_embed \times d_value \times n_heads \times n_layers$

而代码中的self.layer_norm = nn.LayerNorm(d_model)则是层归一化层,当然他的维度是和词向量大小d_model相关。代码里 n_layers = 6,所以总参数量就是这些线性层参数之和再乘以 6。

q_s、k_s、v_s是对 $Q, K, V$ 做了完全相同的变换,以$Q$为例,

  • 第一步是线性投影,对应代码中的self.W_Q(Q),表示将原始词向量通过矩阵乘法,投影到一个新的特征空间,即$Q_{proj} = Q \cdot W^Q$。
  • 第二步是维度拆分,对应代码中.view(batch_size, -1, n_heads, d_k)表示将原本 $512$ 维的向量,“切”成了 $8$ 个 $64$ 维的小向量。这是多头的物理实现。
  • 第三步是对维度的转置,对应代码中.transpose(1, 2)表示从从 [batch, len, 8, 64] 变成 [batch, 8, len, 64]。这是为了后续的矩阵乘法运算,在刚才的ScaledDotProductAttention中也可以看到是对最后两个维度做运算。

attn_mask.unsqueeze(1).repeat(1, n_heads, 1, 1)表示把之前做好的单层掩码,克隆成一套多头的掩码矩阵,以便它能准确覆盖到每一个注意力头上。

  • unsqueeze(1)表示在第 1 维(索引从 0 开始)插入一个长度为 1 的新维度。形状变化:[batch_size, len_q, len_k] $\rightarrow$ [batch_size, 1, len_q, len_k]。目的是为注意力头的腾出位置,即在 batch 和序列长度之间插了一个空位。
  • repeat(1, n_heads, 1, 1)表示在第 1 维拷贝 n_heads 次,其余维不变。形状变化:[batch_size, 1, len_q, len_k] $\rightarrow$ [batch_size, 8, 5, 5](n_head设置为 8 个头)

然后在context = context.transpose(1, 2).contiguous().view(batch_size, -1, n_heads * d_v)将 8 个头独立算出的增量重新缝合成一个完整的、与输入维度一致的长向量。

  • transpose(1, 2)是在把注意力头换回来,因为计算完注意力后,context 的形状是 [batch_size, n_heads, len_q, d_v]([1, 8, 5, 64])。这相当于交换第 1 维(n_heads)和第 2 维(len_q),最后会变成[1, 5, 8, 64]。表示重新以单词序列为主序。现在每个单词后面紧跟着 8 个头为它捕捉到的特征信息。
  • contiguous()是在整理内存,在内存中开辟一块新空间,按照现在的逻辑顺序把数据重新排好。如果不加这一步,下一步的view操作会因为内存不连续而报错。
  • view(batch_size, -1, n_heads * d_v)是在缝合维度,把最后两个维度(8 个头 和 每个头的 64 维)合并成一个维度($8 \times 64 = 512$)。最终形状:[batch_size, len_q, d_model] (即 [1, 5, 512])。

前馈神经网络

Feed Forward部分

1
2
3
4
5
6
7
8
9
10
11
12
class PoswiseFeedForwardNet(nn.Module):
def __init__(self):
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)
self.layer_norm = nn.LayerNorm(d_model)

def forward(self, inputs):
residual = inputs # inputs : [batch_size, len_q, d_model]
output = nn.ReLU()(self.conv1(inputs.transpose(1, 2)))
output = self.conv2(output).transpose(1, 2)
return self.layer_norm(output + residual)

这一部分也就是多层感知机。

在init中:

  • self.conv1 表示将 d_model (512) 映射到 d_ff (2048)。即特征放大,寻找更细微的模式。对应图中第一个线性层
  • self.conv2表示将 d_ff (2048) 压缩回 d_model (512)。用于还原原始的词向量
  • self.layer_norm层归一化,这图中没有标明,实际位置在$\oplus$ 之后,这个实际上是在将得到的新词向量做一个归一化,虽然在进入 PoswiseFeedForwardNet 时,多头的 context 早就已经被缝合成一个统一的向量了,主要是为了防止向量里的数值在经过 $512 \rightarrow 2048 \rightarrow 512$ 的剧烈变换后变得忽大忽小。确保下一层(比如另一个 Encoder 层)接收到的输入是标准的,不会导致梯度爆炸。

对应了GPT-3参数图中的up-projection和down-projection:

Encoder相关

1
2
3
4
5
6
7
8
9
def get_attn_pad_mask(seq_q, seq_k):
# 比如在Encoder的调用里面,都是 enc_inputs: [[1, 2, 3, 4, 0]]
batch_size, len_q = seq_q.size()
batch_size, len_k = seq_k.size()
# eq(0) 是判断哪些位置等于 0 (即 P)
# pad_attn_mask 变成: [[False, False, False, False, True]]
pad_attn_mask = seq_k.data.eq(0).unsqueeze(1) # batch_size x 1 x len_k(=len_q), one is masking
# expand 扩展成矩阵: [1, 5, 5]
return pad_attn_mask.expand(batch_size, len_q, len_k) # batch_size x len_q x len_k

函数返回的是一个 布尔矩阵(Mask 矩阵)。对于 ich mochte ein bier P 这句话,它生成的 Mask 矩阵看起来像这样(用 1 代表 True/屏蔽):

ich mochte ein bier P
ich 0 0 0 0 1
mochte 0 0 0 0 1
0 0 0 0 1
P 0 0 0 0 1

表示无论这个Token是谁(每一行代表一个词),当这个Token回头看全句时,最后一列的 P 都要被抹掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
def get_sinusoid_encoding_table(n_position, d_model):
# 算角度
def cal_angle(position, hid_idx):
return position / np.power(10000, 2 * (hid_idx // 2) / d_model)
# 算向量
def get_posi_angle_vec(position):
return [cal_angle(position, hid_j) for hid_j in range(d_model)]
# 先求出公式中括号里面的
sinusoid_table = np.array([get_posi_angle_vec(pos_i) for pos_i in range(n_position)])
# 分给sin和cos
sinusoid_table[:, 0::2] = np.sin(sinusoid_table[:, 0::2]) # dim 2i
sinusoid_table[:, 1::2] = np.cos(sinusoid_table[:, 1::2]) # dim 2i+1
return torch.FloatTensor(sinusoid_table)

代码中的逻辑其实是在实现论文里的这两个公式:

三个原因:

第一个是有界性的考虑。$\sin$ 和 $\cos$ 的值永远在 $[-1, 1]$ 之间。如果直接用 $1, 2, 3…$,当句子很长时,位置值会变得巨大,干扰词向量的特征。

第二个是相对位置关系方便表示,根据三角函数公式:

这说明对于任何固定的偏移 $k$,$PE_{pos+k}$ 都可以表示为 $PE_{pos}$ 的线性组合。这让模型能够非常容易地学习到词与词之间的相对距离。

第三就是唯一性。由于每一维的频率(分母里的 $10000^{…}$)都不同,从第 0 维到第 511 维,波长是逐渐拉长的。这保证了每一个位置生成的 512 维向量都是唯一的。

1
2
3
4
5
6
7
8
9
10
class EncoderLayer(nn.Module):
def __init__(self):
super(EncoderLayer, self).__init__() # 初始化父类,确保能够使用nn.Module中的自动追踪权重、支持移动到GPU等
self.enc_self_attn = MultiHeadAttention() # 多头注意力
self.pos_ffn = PoswiseFeedForwardNet() # 前馈神经层

def forward(self, enc_inputs, enc_self_attn_mask):
enc_outputs, attn = self.enc_self_attn(enc_inputs, enc_inputs, enc_inputs, enc_self_attn_mask) # 去做自注意力,Q,K,V都是enc_inputs
enc_outputs = self.pos_ffn(enc_outputs) # enc_outputs: [batch_size x len_q x d_model]
return enc_outputs, attn

enc_outputs是经过多头注意力(Multi-Head Attention)和前馈网络(FFN)处理后的新向量。

  • 进去前向量只代表词本身(比如 bier 只代表啤酒)。
  • 出来后向量融入了周围词的信息(比如 bier 的向量现在包含了“它是 ich 想要的那个 bier”这种语境信息)。

这个 enc_outputs 会作为下一层 Encoder Layer 的输入继续被精炼。层数给了 6 层,相当于被精炼 6 次。

attn是注意力权重矩阵(打分表)就是产生了注意力分数的矩阵。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Encoder(nn.Module):
def __init__(self):
super(Encoder, self).__init__()
self.src_emb = nn.Embedding(src_vocab_size, d_model) #Token的Embedding
self.pos_emb = nn.Embedding.from_pretrained(get_sinusoid_encoding_table(src_len+1, d_model),freeze=True) #位置编码
self.layers = nn.ModuleList([EncoderLayer() for _ in range(n_layers)]) #产生n_layers个层

def forward(self, enc_inputs): # enc_inputs : [batch_size x source_len]
enc_outputs = self.src_emb(enc_inputs) + self.pos_emb(torch.LongTensor([[1,2,3,4,0]])) #特征融合,整合词义信息和位置信息
enc_self_attn_mask = get_attn_pad_mask(enc_inputs, enc_inputs) # 在计算注意力时,忽略为了凑长度而补进去的 P(Padding)。
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
  • nn.Embedding:nn.Embedding就是一个模型能够理解的字典。第一维是词表大小,是整个字典里有多少个不重复的词,因为是Encoder层,所以词表大小是src_vocab_size。d_model是每一个 Token 被转化成的向量维度(这里是 512)。当然,在更高级的 NLP 任务中,经常会加载预训练词向量而不是让模型自己去学(如 Word2Vec 或 GloVe)。
  • Embedding.from_pretrained:from_pretrained 是一个特殊的构造方法。它改变了字典的生成方式。普通构造是需要去训练的,构造出来的全是全是随机生成的数字,但是这个方法会给一个已经算好的矩阵(Tensor)让它直接用这些数字作为初始内容。具体的数字源于get_sinusoid_encoding_table 函数,这个函数通过 $sin$ 和 $cos$ 的数学公式,预先计算出了一个形状为 [6, 512] 的矩阵。这里多加了一维,为了兼容Decoder中的‘S’。
  • 特征融合这里,self.pos_emb(torch.LongTensor([[1,2,3,4,0]]))这个顺序是和输入的句子意义对应的:’ich mochte ein bier P’。当然这是为了演示,真实情况肯定不是这样硬编码的
  • for循环是在精炼语义,可能得情况是第 1 层理解基础语法,比如主谓关系。到第 6 层就能够理解高级语义,即词语在句子里的最终含义。最后返回的 enc_outputs就是 Encoder 对这句德语的最终理解结果。它会被送往 Decoder,作为翻译的参考。

这样就完成了模型框架中的词嵌入以及编码层。

image-20260127143142655

Dncoder相关

get_attn_subsequent_mask是和Masked Multi-Head Attention相关,Encoder 的 Mask 只是为了挡住 P (Padding),而这个方法生成的是上三角矩阵。

1
2
3
4
5
def get_attn_subsequent_mask(seq):
attn_shape = [seq.size(0), seq.size(1), seq.size(1)]
subsequent_mask = np.triu(np.ones(attn_shape), k=1)
subsequent_mask = torch.from_numpy(subsequent_mask).byte()
return subsequent_mask
1
2
3
4
5
6
# 测试,模拟一个 Batch_size=1, 长度为 5 的序列
test_seq = torch.LongTensor([[1, 2, 3, 4, 5]])
mask = get_attn_subsequent_mask(test_seq)

print("Generated Subsequent Mask (1 means MASKED):")
print(mask[0].numpy())
Generated Subsequent Mask (1 means MASKED):
[[0 1 1 1 1]
 [0 0 1 1 1]
 [0 0 0 1 1]
 [0 0 0 0 1]
 [0 0 0 0 0]]

[[0 1 1 1 1] 第一行:只能看索引0,不能看1,2,3,4

[0 0 1 1 1] 第二行:只能看索引0,1,不能看2,3,4

[0 0 0 1 1] 第三行:只能看索引0,1,2,不能看3,4

[0 0 0 0 1] 第四行:只能看索引0,1,2,3,不能看4

[0 0 0 0 0]] 第五行:全都能看

1
2
3
4
5
6
7
8
9
10
11
12
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_outputs, dec_self_attn = self.dec_self_attn(dec_inputs, dec_inputs, dec_inputs, dec_self_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
  • self.dec_self_attn:这个是先做了一个自我回顾,$Q, K, V$ 全部来自 dec_inputs(即目标语言序列 “S I want a beer”)。并且使用的是dec_self_attn_mask这个上三角矩阵。这是在让模型在产生当前单词时,能够参考已经生成出来的单词。因为有了上三角掩码,模型在这一步只能看到历史信息,不能偷看后面的结果。
  • self.dec_enc_attn:这一步是在做交叉注意力,上一步产生的dec_outputs作为$Q$ (Query),enc_outputs作为$K$ (Key) 和 $V$ (Value),代表通过Encoder后原文(德语)的所有特征信息。注意这里的dec_enc_attn_mask掩码只负责遮住原文里的 P (Padding),不需要遮住未来,因为之后的句子本身就看不到。这里就是真的在做翻译了,模型拿着已经翻译出的英语单词作为搜索词,去德语原文里寻找最相关的词(比如翻译 “beer” 时,它会去原文里定位到 “bier”)。
  • self.pos_ffn:这个是做了一个全连接,将整合的结果进入到前馈神经网络(MLP)来对融合后的特征进行非线性映射和进一步升维提取

Encoder层和Decoder层对比:

特性 EncoderLayer DecoderLayer
层数 2 层 (Self-Attn + FFN) 3 层 (Self-Attn + Cross-Attn + FFN)
掩码 只遮 Padding Self-Attn 遮未来的词汇;Cross-Attn 遮原文的 Padding
数据源 纯粹的源语言 混合源语言(K, V)和目标语言(Q)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Decoder(nn.Module):
def __init__(self):
super(Decoder, self).__init__()
self.tgt_emb = nn.Embedding(tgt_vocab_size, d_model)
self.pos_emb = nn.Embedding.from_pretrained(get_sinusoid_encoding_table(tgt_len+1, d_model),freeze=True)
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]
dec_outputs = self.tgt_emb(dec_inputs) + self.pos_emb(torch.LongTensor([[5,1,2,3,4]]))
dec_self_attn_pad_mask = get_attn_pad_mask(dec_inputs, dec_inputs)
dec_self_attn_subsequent_mask = get_attn_subsequent_mask(dec_inputs)
dec_self_attn_mask = torch.gt((dec_self_attn_pad_mask + dec_self_attn_subsequent_mask), 0)

dec_enc_attn_mask = get_attn_pad_mask(dec_inputs, enc_inputs)

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

这个和Encoder类很像,它把多个 DecoderLayer 堆叠起来,并初始化目标词向量。

  • torch.gt:gt 是 “Greater Than” 的缩写,先将两张矩阵相加,相加后,只要某个位置原本在任意一张矩阵里是 1,加完后的值就会是 1 或 2,也就大于0,并将其转换为布尔值。大于0的是True,小于0是False。这样就能整合pad的掩码以及遮掉上三角矩阵的掩码,这就保证了模型在自注意力阶段既不会看 P,也不会看未来的词。
  • dec_enc_attn_mask:遮住的是 enc_inputs 里的 P,为了 DecoderLayer 里的第二层注意力准备的。
  • for循环同样是在精炼语义,最后整合Dcoder 对这句德语的最终理解结果。

Transformer主类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Transformer(nn.Module):
def __init__(self):
super(Transformer, self).__init__()
self.encoder = Encoder()
self.decoder = Decoder()
# 最后的线性投影层:将向量打回词表大小
self.projection = nn.Linear(d_model, tgt_vocab_size, bias=False)

def forward(self, enc_inputs, dec_inputs):
# 第一步:原文进,输出特征
enc_outputs, enc_self_attns = self.encoder(enc_inputs)
# 第二步:译文前缀+原文特征进入,生成特征
dec_outputs, dec_self_attns, dec_enc_attns = self.decoder(dec_inputs, enc_inputs, enc_outputs)
# 第三步:将 [batch, len, 512] 映射为 [batch, len, 7]
dec_logits = self.projection(dec_outputs) # dec_logits : [batch_size x src_vocab_size x tgt_vocab_size]
# 第四步:变形为 [batch*len, 7] 方便计算交叉熵损失,view是在缝合前两个维度
return dec_logits.view(-1, dec_logits.size(-1)), enc_self_attns, dec_self_attns, dec_enc_attns

画图函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def showgraph(attn,name):
# 传入一个注意力矩阵,当然一个三个维度,第一个维度表示是哪一个注意力头,这里选取 [-1] 代表取最后一层的输出,因为最后一层的理解通常最深刻。
attn = attn[-1].squeeze(0)[0] # squeeze(0):去掉 Batch 维度(即从 [1, 8, 5, 5] 变成 [8, 5, 5])。并且取了第一个头的注意力分数
attn = attn.data.numpy()

fig = plt.figure(figsize=(n_heads, n_heads))
ax = fig.add_subplot(1, 1, 1)
im = ax.matshow(attn, cmap='viridis') # 把矩阵里的权重数值(0 到 1 之间)映射成颜色(紫色到黄色)。颜色越亮(趋向黄色),代表注意力权重越高。

ax.set_xticks(range(len(sentences[0].split())))
ax.set_yticks(range(len(sentences[2].split())))
# 坐标轴的数字索引换成真正的单词
ax.set_xticklabels(sentences[0].split(), rotation=90)
ax.set_yticklabels(sentences[2].split())

plt.colorbar(im)
plt.savefig(name+'attention.png')
plt.show()

模型训练与结果可视化

随机种子函数

1
2
3
4
5
6
7
8
9
10
11
12
def set_seed(seed=2026):
import random
random.seed(seed) # 1. 锁定 Python 原生随机数
np.random.seed(seed) # 2. 锁定 NumPy 随机数
torch.manual_seed(seed) # 3. 锁定 CPU 上的 PyTorch
torch.cuda.manual_seed(seed) # 4. 锁定 GPU 上的 PyTorch
torch.cuda.manual_seed_all(seed) # 5. 如果使用多块 GPU,全锁定
# 6. 确定性运算(会稍微降低一点点训练速度,但能保证结果一模一样)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False

set_seed(2026) # 调用函数,设置一个数字

初始化模型

1
model = Transformer()

在这之前要先讲sentences进行数值化

1
2
3
4
5
def make_batch(sentences):
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)

交叉熵作为损失函数,Adam作为优化器

1
2
3
4
criterion = nn.CrossEntropyLoss(ignore_index=0)
optimizer = optim.Adam(model.parameters(), lr=0.0001) # 调小一点
enc_inputs, dec_inputs, target_batch = make_batch(sentences) # 数值化原始句子
enc_inputs, dec_inputs, target_batch

输出结果:

(tensor([[1, 2, 3, 4, 0]]),
 tensor([[5, 1, 2, 3, 4]]),
 tensor([[1, 2, 3, 4, 6]]))

模型的训练部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for epoch in range(100):  # 把上限设高一点,交给早停来控制
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))

# 打印进度
if (epoch + 1) % 10 == 0:
print('Epoch:', '%04d' % (epoch + 1), 'cost =', '{:.6f}'.format(loss))

# --- 早停 ---
if loss < 0.0001:
print('已达到预期精度,在第 %d 轮提前停止训练' % (epoch + 1),'cost =', '{:.6f}'.format(loss))
break

loss.backward()
optimizer.step()

多训练几轮,反正就一句话,设置loss为0.0001就停,结果:

Epoch: 0010 cost = 0.001163
Epoch: 0020 cost = 0.000583
Epoch: 0030 cost = 0.000113
已达到预期精度,在第 31 轮提前停止训练 cost = 0.000100

测试部分

1
2
3
4
5
6
7
8
9
10
11
12
13
# Test
predict, _, _, _ = model(enc_inputs, dec_inputs)
predict = predict.data.max(1, keepdim=True)[1]
print(sentences[0], '->', [number_dict[n.item()] for n in predict.squeeze()])

print('first head of last state enc_self_attns')
showgraph(enc_self_attns,'enc_self_attns')

print('first head of last state dec_self_attns')
showgraph(dec_self_attns,'dec_self_attns')

print('first head of last state dec_enc_attns')
showgraph(dec_enc_attns,'dec_enc_attns')

结果

ich mochte ein bier P -> ['i', 'want', 'a', 'beer', 'E']
first head of last state enc_self_attns

这会跑出来3个图。

第一张图:

image-20260127143601965

Encoder 自注意力图。展示的是德语单词之间的自我关联(自注意)情况。每个词在关注自己的同时,也对整句的其他词有均匀的亮度分布。最后的 P(Padding)列是黑色的,说明 get_attn_pad_mask 成功工作了,模型忽略了无意义的填充符。

第二张图:

image-20260127143622426

Decoder 自注意力图。右上角完全黑暗的部分说明了代码中get_attn_subsequent_mask得到的上三角矩阵起作用了。

第三张图:

image-20260127143633214

Decoder-Encoder 跨注意力图。有对角线对齐趋势,这说明成功地在德语原文中定位到了对应的词汇。

关于TIme-Series-Library库的启动脚本的超参数解读

thuml/Time-Series-Library: A Library for Advanced Deep Time Series Models for General Time Series Analysis.

视频中涉及到的概念($Q, K, V$, 多头注意、Embedding、参数量),在run.py里都有对应的超参数。

  • --d_model :
    • 这就是一直在说的词向量维度 $d$。
    • 它决定了每一个 Token 向量有多长。 $W_Q, W_K, W_V$ 矩阵的形状,就是由这个数字决定的。
  • --e_layers / --d_layers :

    • Encoder(编码器)和 Decoder(解码器)的层数。即信息交换与模型推理的地方。
    • GPT-3 有 96 层,且这96层全是解码器的层数。
  • --n_heads :

    • 多头注意力(Multi-head Attention)。
    • 这代表模型会同时开启 4 个头并行计算。每个头都有自己独立的 $W_Q, W_K, W_V$,分别去寻找不同的规律(比如头1找周期性,头2找趋势)。
  • --d_ff:

    • 前馈神经网络(Feed Forward)的维度。
    • 这就是Up-projection。在注意力交换完情报后,向量会进入这个2048维的空间进行非线性变换。
  • --enc_in :

    • 输入特征数。
    • 这代表你的初始元素,对应了嵌入前的初始文本。比如有 7 种时间序列数据,Embedding 层会负责把这 7 个维度变成 d_model
  • --embed :

    • 对应Embedding 的方式。
    • 这决定了模型如何把“时间”这个抽象概念编码进向量里。
  • --dropout (0.1): 在加权求和时,随机丢弃 10% 的神经元,防止模型过拟合。

  • --activation (‘gelu’): 这是激活函数。在 $V$ 向量搬运完信息后,给“思考”过程增加一点非线性的逻辑,GPT 系列通常都是GELU。

时间序列和文本任务在 Transformer 的应用上,存在几个核心的策略差异,文本任务(如 GPT)是目的是压缩,他文本原始维度是词表大小(5万+),d_model给的是1.2万,做特征降维和语义聚合。

但是时间序列,原始维度一般很小,达不到$10^4$级别,我做的数据集中最多也才26维。直接对 26 个维度做注意力计算,信息量太薄,模型很难学到复杂的非线性关系。因此一般会通过 Embedding 层将 26 维投影到 64 维。在时间序列论文(如 TimesNet, Informer, Autoformer)中,通常 d_model 设置为原始特征数的 2 到 4 倍 是比较稳妥的起点。所以在试的时候 d_model 从默认的 512 改成 64,48,32比较合适。当然这样做模型中所有 $W_Q, W_K, W_V$ 矩阵的面积缩小不少,跑的还是挺快的。

公式中$\sqrt{d_k}$ 的计算:在 d_model=64, n_heads=4 的情况下,每一个“头”分到的向量维度 $d_k$ 是16个,因为$d_k$ 代表Key向量的维度。那么由d_model=64得每一个头分到的是64/4=16个。