news 2026/9/30 4:29:24

Transformer论文精读:注意力机制与代码复现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Transformer论文精读:注意力机制与代码复现

2017年那篇《Attention Is All You Need》我前后完整读过四遍,第一遍是2019年刚接触NLP时囫囵吞枣,只记住了“Transformer”这个名词;第二遍是动手复现时逐公式抠细节;第三遍是给别人做分享,被迫把每个“为什么”都讲清楚;第四遍是最近重读,发现当年跳过的翻译措辞和实验设置里,藏着不少被忽略的判断依据。这篇博文就是这四遍留下的笔记整理,属于Transformer系列的第3篇,重点放在论文本身的翻译与精读上。它适合三类人:刚入门想搞懂Transformer原理的新手、准备手写Transformer的工程同学、以及读过论文但卡在某几个公式上的人。我会把摘要、模型结构、注意力公式、训练配置、实验结果按原文章节顺序拆开讲,该给数值的地方给数值,该补代码的地方补代码,也会把我自己踩过的坑标出来。

1. 读这篇论文前需要先打好的底子

1.1 为什么这篇论文值得逐句读而不是只背结论

很多人对这篇论文的印象停留在两个标签:Transformer的起点、抛弃RNN。但如果只记住这两条,后面看BERT、GPT、ViT、Swin Transformer时会一直处于“知道结构但不知道为什么要这么设计”的状态。这篇论文真正的价值在于它把“序列建模到底受限于什么”这个问题讲透了,并且给出的解法是整套配套的:注意力替代循环、多头并行、位置编码补序、残差加归一化保训练、学习率预热保收敛。每一块都不是孤立的,任何一个环节删掉,模型都跑不出论文里的成绩。

我自己的体会是,论文里有几处地方如果你只是浏览摘要,会完全错过。比如缩放点积注意力里那个除以根号d_k的操作,作者在脚注里给了解释,很多人第一次读根本不会注意到脚注;再比如多头注意力为什么要降维到64维,论文3.2.2节明确说了是“由于减少了每个头的维度,总计算成本与全维度的单头注意力相似”,这句话是理解多头设计的关键。还有学习率公式,它写在3.2节正文里,但很多人写代码时随手用了个固定学习率,训练不收敛还以为是模型问题。

逐句读还有一层好处:论文的英文表述本身非常克制,几乎没有废话,每一句话都对应一个设计决定。翻译的时候如果只按字面来,很容易丢掉这层信息。比如“scaled”译成“缩放”是对的,但如果不停下来想一下“缩放谁、缩放多少、为什么要缩放”,这个词就只是一个名词。所以我在下面每一节里都会先给一段我自己的翻译,再讲这句话背后的判断。

另外要提醒的是,不要一上来就啃模型图。正确的顺序是:先读3.1节编码器解码器堆叠结构,再读3.2节注意力,然后回头看图1,最后读3.3到3.5的位置编码、前馈网络和嵌入层。图1是结论的浓缩,不是入口。我第一次读时死盯图1,结果卡了整整一个下午,因为它把六层堆叠、三个子层、两种注意力全画在一起,信息密度太高。

1.2 必须提前补上的三个概念底子

在正式进论文之前,有三个概念如果不清楚,后面会处处卡壳。

第一个是序列到序列任务的典型结构。在这篇论文之前,主流的机器翻译模型是编码器-解码器架构,编码器把源语言句子压成一个或多个向量,解码器再逐词生成目标语言。编码器和解码器内部通常是RNN或LSTM,按时间步展开。理解这个背景,才能明白论文说的“recurrent”具体指什么,也才能明白为什么作者要强调“编码器和解码器都堆叠了6层相同的层”。

第二个是注意力机制的来龙去脉。注意力不是这篇论文发明的,它最早是作为RNN编码器解码器之间的对齐工具出现的,后来有了Bahdanau注意力和Luong注意力。论文的贡献不是提出注意力,而是提出只用注意力、不用循环和卷积。这个区别很关键,因为很多人误以为论文发明了注意力机制。读2节背景和相关工作时,作者自己写得很清楚:注意力机制已经是各类序列模型里的标配,他们的工作是第一个完全不依赖循环结构的序列转换模型。

第三个是自注意力的含义。自注意力是指查询、键、值三组向量都来自同一个序列,序列内部每个位置去和所有位置算相似度。这跟传统的编码器解码器注意力不同,后者的查询来自解码器、键值来自编码器。论文里两种都用了:编码器用自注意力,解码器先掩码自注意力再交叉注意力。分不清这三种用法,看代码时会一直被Q、K、V是三份还是两份困扰。

补完这三个底子,再进论文会顺很多。我自己第二次读的时候就是因为先手动实现了一个小号RNN编码器解码器,才真正理解“顺序计算”这个瓶颈到底卡在哪里。

2. 论文整体架构:把一句话拆成三块

2.1 摘要与引言:作者到底主张什么

摘要原文很短,核心就一句话的意思:当前主流的序列转换模型都基于复杂的循环或卷积网络,且包含编码器和解码器,而性能最好的模型还通过注意力机制把编码器和解码器连接起来。我们提出一种新的简单网络架构Transformer,完全基于注意力机制,彻底摒弃了循环和卷积。在两项机器翻译任务上,这些模型在质量上更优,同时更可并行化,训练时间显著减少。

这里有几个翻译细节值得停留。原文用的是“dispensing with recurrence and convolutions entirely”,dispensing with译成“摒弃”比“舍弃”更贴,因为它带有“主动不要”的意味,而不是“被动放弃”。另一个是“more parallelizable”,我译作“更可并行化”,因为这里的比较对象是循环网络,循环网络沿时间步串行,注意力则是整句一次性算完,这是并行度的质变而不是量变。

引言部分作者先交代背景:循环网络在序列建模中长期占据主导,LSTM和GRU在语言建模和机器翻译上取得了大量成果。接着指出循环网络的根本限制:隐藏状态h_t由h_{t-1}和当前输入决定,这个依赖关系天然排除了并行化,序列一长,显存和时间的瓶颈就出来了。虽然有不少工作尝试用分解技巧和条件计算来缓解,但串行的本质没变。

然后作者转向注意力:注意力机制已经成为序列建模和转换模型的重要组成部分,但几乎总是和循环网络搭配使用。于是他们提出Transformer,第一个完全依靠注意力来计算输入输出表示的转换模型,不依赖循环,也不依赖卷积。

引言最后一段给了两个具体成绩和一条自我评价:在WMT 2014英德翻译上取得28.4 BLEU,超过当时最好结果2个BLEU以上;在WMT 2014英法翻译上,在八块GPU上训练3.5天后取得41.8 BLEU,是当时该任务的最好成绩,而且训练成本只是文献中最好模型的一小部分。这段话要结合后面的表2一起看,才有直观感受。

2.2 模型总览图怎么读,从下往上分四层

图1是整个论文最容易被低估的部分。它画的不是细节,而是数据流。我的建议是把它拆成四层来读,从下往上依次是:输入嵌入与位置编码层、编码器堆叠层、解码器堆叠层、输出线性与softmax层。

最底层左侧是输入嵌入,把源语言词元映射成d_model维向量,论文里d_model取512。注意论文3.4节提到,嵌入层权重乘以了根号d_model,这个操作在图上没有画出来,但代码里必须实现,否则位置编码的数值尺度会把嵌入向量淹没。右侧同理,是目标语言的输出嵌入。

第二层是位置编码。因为模型里没有任何循环和卷积,如果不加位置信息,打乱词序得到的注意力结果是一样的。“我打你”和“你打我”在纯注意力下无法区分。所以作者把位置编码直接加到嵌入向量上,用正弦和余弦函数生成,波长从2π到10000·2π构成等比数列。这种设计的好处是可以外推到比训练时更长的序列。

第三层是编码器和解码器的堆叠。论文里编码器和解码器各堆叠6层,用N表示。编码器每层包含两个子层:多头自注意力和前馈网络。解码器每层包含三个子层:掩码多头自注意力、对编码器输出的多头注意力、以及前馈网络。每个子层都套了残差连接和层归一化,论文写作LayerNorm(x + Sublayer(x))。这个写法很关键,是后归一化而不是现在常见的预归一化,训练时对学习率预热比较敏感。

第四层是最上面的线性层加softmax。解码器输出经过一个线性变换映射到词表维度,再softmax得到下一个词的概率分布。论文3.4节提了一句,这个线性层和两个嵌入层共享同一份权重矩阵,这是减少参数量的经典做法,在GPT系列里也一直沿用。

读图时还有一个容易忽略的细节:所有子层以及嵌入层输出的维度都是d_model等于512,这是为了让残差相加时维度对齐。前馈网络内层是2048维,属于先升维再降维,但输入输出仍是512。这一点如果没注意到,写代码时残差连接会直接报维度错误。

2.3 砍掉循环和卷积之后,代价和收益各是什么

论文3.1节最后用一段和表1给出了定量比较,这是整篇论文最有说服力的部分之一,但很多人读的时候直接跳过了。表1比较了四种层类型:自注意力、循环层、卷积层、受限自注意力,比较维度是每层计算复杂度、最少顺序操作数、以及任意两个位置之间的最大路径长度。

自注意力的每层复杂度是O(n²·d),n是序列长度,d是表示维度。循环层是O(n·d²)。卷积层是O(k·n·d²),k是卷积核大小。受限自注意力是O(r·n·d),r是邻域大小。顺序操作数方面,自注意力和卷积层都是O(1),循环层是O(n)。最大路径长度方面,自注意力是O(1),循环层是O(n),卷积层是O(log_k n)。

这三个指标合起来说明了三件事。第一,自注意力的并行度最高,因为整句一次性算完,不存在时间步依赖,这是它能用8块P100在12小时内训完base模型的直接原因。第二,自注意力的路径长度是常数级,意味着任意两个词之间的信息传递只需要一步,长距离依赖不会像RNN那样被稀释,这对翻译这种经常需要跨句子成分对齐的任务非常关键。第三,自注意力的代价是O(n²)复杂度,序列变长时显存吃紧,这也是后来各种稀疏注意力、线性注意力、滑动窗口注意力出现的原因。

卷积层的顺序操作数也是O(1),但最大路径长度是O(log_k n),需要堆叠多层才能覆盖全序列。这解释了为什么后来很多工作又回过头把卷积加回Transformer,比如在视觉任务里,Swin Transformer用窗口注意力加移位来换取局部性和效率,本质上是在O(n²)和O(n)之间找平衡点。

我在实际项目里的感受是,短序列场景下自注意力的O(n²)完全不是问题,512长度下显存和速度都很舒服;一旦序列上到几千,就必须做取舍了。所以理解表1不是为了考试,而是为了在选型时有判断依据。

3. 注意力机制逐层精读

3.1 缩放点积注意力里那个根号d_k不是随便加的

论文3.2.1节把注意力描述成一个映射:输入是查询Q、键K、值V,输出是值的加权和,权重由查询和键的相似度决定。公式写作:Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V。

先看QK^T这一步。Q的每一行是一个查询向量,K的每一行是一个键向量,两者做点积得到一个n×n的分数矩阵,第i行第j列表示第i个查询对第j个键的匹配程度。这一步是整篇论文的核心,也是“注意力”三个字的落点:用一个向量去找和它最相关的其他向量。

再看除以根号d_k。论文在脚注4里解释了原因:假设q和k是独立随机变量,均值为0、方差为1,那么它们的点积q·k的均值为0、方差为d_k。d_k越大,点积结果的方差越大,数值分布越分散。softmax在输入数值很大或很小的时候,梯度会趋近于0,也就是常说的饱和区。除以根号d_k正是把方差拉回1,让softmax处在梯度健康的工作区间。

我实测过一个对照:把d_k设为64,不加缩放时,softmax输出的最大概率经常在0.99以上,梯度几乎为零,训练前几千步基本不动;加上缩放后,概率分布明显更平滑,loss下降稳定。这就是为什么这个看似不起眼的除法必须写进代码。

最后是softmax的结果乘以V。这一步是加权求和,权重之和为1,所以输出向量的尺度不会被序列长度放大。要注意掩码的介入位置:论文里解码器的自注意力在softmax之前把未来位置的分数设为负无穷,这样softmax之后那些位置的权重就是0。如果用代码实现,通常是把分数加上一个形状相同的掩码矩阵,允许位置加0,禁止位置加负1e9。

论文还提了一句两种注意力的对比:加性注意力和点积注意力在复杂度上接近,但点积注意力可以用高度优化的矩阵乘法实现,速度快、显存效率高,这也是作者选择点积的原因。加性注意力用单层隐层网络计算分数,理论上在d_k很大时表现更好,但实测中缩放点积已经够用。

3.2 多头注意力:一次判断不如八次不同角度的判断

多头注意力的公式在3.2.2节:MultiHead(Q, K, V) = Concat(head_1, ..., head_h) W^O,其中head_i = Attention(Q W_i^Q, K W_i^K, V W_i^V)。

先解释为什么不是单头。单头注意力每个位置只能产生一组权重分布,注意力被“平均”掉了,多个语义关系会被压到同一个分布里,容易顾此失彼。多头相当于让模型在不同的表示子空间里各自做一次注意力,每个头可以关注不同的模式:有的头可能盯着主谓一致,有的头盯着指代,有的头盯着局部相邻词。最后把结果拼接再线性变换,让模型自己决定怎么整合。

论文里base模型用h=8个头,每个头的维度d_k=d_v=d_model/h=512/8=64。这个取值不是随意的。论文原话是“由于每个头的维度减少,总计算成本与全维度的单头注意力相似”。算一下就明白:单头情况下d_k=512,QK^T的计算量是n²·512;八头情况下每个头d_k=64,八次计算的总量是8·n²·64,正好等于n²·512。所以多头不是靠堆算力换性能,而是在同样算力预算下换取更丰富的表示。

大模型(big)配置则是h=16,d_model=1024,每个头64维,逻辑一致。

实现上有两个必须注意的点。第一,W_i^Q、W_i^K、W_i^V这三组投影矩阵在代码里通常合并成三个大矩阵,一次性做完投影再按头切分,这样比循环8次做小矩阵乘法快得多。第二,切分维度时要搞清楚是按哪个轴切:形状从(batch, seq, d_model)变成(batch, h, seq, d_k),这里涉及一次reshape加一次transpose,轴顺序写错是最常见的bug来源,输出不报错但结果全错。我在第一次写的时候就因为把seq和h两个维度搞反,训练loss能降但BLEU只有个位数,排查了很久才发现。

3.3 三种用法别混在一起:自注意力、交叉注意力、掩码注意力

论文里注意力出现了三次,用途完全不同,混起来看会很乱。

编码器自注意力:Q、K、V全部来自编码器上一层的输出,所有位置都能看到所有位置,没有任何掩码。作用是把源语言句子内部的依赖关系抽出来。

解码器掩码自注意力:Q、K、V来自解码器上一层的输出,但在softmax之前加了掩码,位置i只能看到位置i及之前的位置。这是自回归生成的硬性要求,生成第t个词时不能偷看第t+1个词。论文3.2.3节专门讲了为什么必须掩码,并且提了一句他们更倾向于用掩码而不是把整个序列拆成多个子序列分别算,因为后者在训练时会带来额外的开销。

解码器交叉注意力:Q来自解码器上一层的输出,K和V来自编码器的输出。这一步是把目标语言当前生成的位置和源语言句子对齐,也是传统编码器解码器注意力的位置。注意这里没有掩码,因为源语言整句都是可见的。

这三种写法在代码里区别只在传入的Q、K、V来源以及是否加掩码,模块本身是同一个类。我建议在实现时给这个类加一个mask参数,默认None,解码器自注意力时传入因果掩码,另外两处不传。这样结构清晰,也方便复用。

补充一个实践中容易忽略的点:论文的掩码是上三角为负无穷,对角线保留。如果写成上三角包含对角线,那当前位置自己都看不到,输出会整体崩塌且不易察觉,因为loss只是偏高而不是明显异常。

4. 从论文到代码:关键模块手写实现

4.1 先把张量形状约定死,能省掉一半的调试时间

写Transformer之前,第一件事是定好命名和形状约定,不然做到一半一定会乱。我习惯用这几个记号:B是批大小,S是序列长度,D是模型维度即d_model,H是头数,Dk是每个头的维度即D/H。

输入嵌入层输出形状(B, S, D)。位置编码形状(1, S, D),加在嵌入上广播。编码器每层输出保持(B, S, D)。解码器掩码自注意力输出(B, S, D)。交叉注意力的Q是(B, S_tgt, D),K和V是(B, S_src, D),输出是(B, S_tgt, D)。最后线性层把(B, S, D)映射到(B, S, V),V是词表大小。

把这张形状表贴在屏幕上,写代码时对照着看,基本不会出维度错误。有个细节是批大小和序列长度这两个维度名字都容易混,尤其在交叉注意力里B相同但S不同,广播的时候要特别小心。

注意:论文里没有出现batch这个维度,所有公式都是单样本描述的。落到代码上必须自己补上批量维,这是读论文和写代码之间最容易掉链子的地方。

4.2 多头注意力的PyTorch实现与逐行说明

下面这份实现我用了很多次,逻辑和论文完全对齐:

import math import torch import torch.nn as nn import torch.nn.functional as F class MultiHeadAttention(nn.Module): def __init__(self, d_model=512, num_heads=8, dropout=0.1): super().__init__() assert d_model % num_heads == 0, "d_model必须能被num_heads整除" self.d_model = d_model self.h = num_heads self.d_k = d_model // num_heads # 三组投影合并成大矩阵,一次算完再切头 self.W_q = nn.Linear(d_model, d_model) self.W_k = nn.Linear(d_model, d_model) self.W_v = nn.Linear(d_model, d_model) self.W_o = nn.Linear(d_model, d_model) self.dropout = nn.Dropout(dropout) def split_heads(self, x, b, s): # (B, S, D) -> (B, H, S, Dk) x = x.view(b, s, self.h, self.d_k) return x.transpose(1, 2) def forward(self, query, key, value, mask=None): b, s_q, _ = query.shape s_k = key.shape[1] q = self.split_heads(self.W_q(query), b, s_q) # (B,H,Sq,Dk) k = self.split_heads(self.W_k(key), b, s_k) # (B,H,Sk,Dk) v = self.split_heads(self.W_v(value), b, s_k) # (B,H,Sk,Dk) # 缩放点积注意力 scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) attn = F.softmax(scores, dim=-1) attn = self.dropout(attn) out = torch.matmul(attn, v) # (B,H,Sq,Dk) out = out.transpose(1, 2).contiguous().view(b, s_q, self.d_model) return self.W_o(out)

几个关键点展开说。第一,W_q、W_k、W_v都是nn.Linear(d_model, d_model),论文里的W_i^Q是(D, Dk)的小矩阵,八个拼起来正好是(D, D),所以这样实现是等价的,而且矩阵乘法效率更高。

第二,split_heads里的view和transpose顺序不能反。先view把D拆成H和Dk,再transpose把H提到前面。如果先transpose再view,切出来的头就是错的,模型仍然能跑,但每个头看到的是乱七八糟的混合维度。

第三,缩放用的是math.sqrt(self.d_k)而不是sqrt(d_model),因为论文的d_k指的是单个头的维度。这一点在多头场景下极容易写错,写错后训练也能收敛,只是效果差一截,属于隐蔽性很强的坑。

第四,masked_fill用的值我习惯用-1e9而不是负无穷。用float('-inf')在某些混合精度设置下会产生NaN,-1e9在fp16里也能安全表示,softmax后基本就是0。

第五,transpose之后必须加contiguous()再view,否则view会因为内存不连续报错。这个错误信息很直白,遇到了直接加就行。

4.3 位置编码、前馈网络和残差归一化的落地写法

位置编码直接用论文公式实现,偶数维用sin,奇数维用cos:

class PositionalEncoding(nn.Module): def __init__(self, d_model=512, max_len=5000, dropout=0.1): super().__init__() pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len).unsqueeze(1).float() div_term = torch.exp( torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model) ) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) self.register_buffer('pe', pe.unsqueeze(0)) # (1, max_len, D) self.dropout = nn.Dropout(dropout) def forward(self, x): return self.dropout(x + self.pe[:, :x.size(1)])

这里有两个工程细节。一是div_term用exp和log计算,等价于10000的负指数,但数值上更稳定,也比直接写pow快。二是pe注册成buffer,这样它会跟着模型一起搬到GPU,且不会被优化器更新,比写成普通张量安全得多。

前馈网络在3.3节,结构是全连接、ReLU、全连接,内层维度d_ff=2048,外层回到512:FFN(x) = max(0, xW_1 + b_1)W_2 + b_2。论文提了一句,这可以看作两个核大小为1的卷积。这个视角在理解了卷积网络的人看来会更直观。实现就是两个Linear夹一个ReLU,中间加dropout。

残差加层归一化按论文写是LayerNorm(x + Sublayer(x)),也就是后归一化。有个坑必须提前说:后归一化在训练初期不稳定,必须配合学习率预热,否则loss会震荡甚至发散。现在很多开源实现改成了预归一化LayerNorm(x + Sublayer(LayerNorm(x))),训练更稳、对预热不敏感,但和论文原版有差异。做复现实验时要明确自己用哪种,别一边用后归一化一边用恒定学习率。

编码器层和解码器层就是把上面几块拼起来。编码器层两个子层,解码器层三个子层,每个子层外面包残差和归一化。堆叠6层就是N=6。这里有一个小细节:论文说解码器三个子层里第一个是掩码自注意力,第二个是交叉注意力,第三个是前馈网络,顺序不能换,换成交叉注意力在前后,模型仍然能训,但和论文不是同一个东西,对比结果就没意义了。

5. 训练配置与复现细节

5.1 优化器、学习率预热和批量大小的取值依据

论文5.3节的训练配置写得很具体,这部分照着抄就行,但我想把每个数值的理由说清楚。

优化器用Adam,beta1=0.9,beta2=0.98,epsilon=1e-9。注意beta2是0.98不是默认的0.999。这个改动的原因是训练步数多、梯度噪声大,0.98让二阶矩估计跟得更快,实测在长训练里更稳。epsilon取1e-9比默认的1e-8更小,配合缩放后的注意力分数效果更好。

学习率不是固定的,按公式调整:lrate = d_model^(-0.5) · min(step_num^(-0.5), step_num · warmup_steps^(-1.5)),warmup_steps取4000。这个公式分两段,前4000步线性增长,4000步之后按步数的平方根倒数衰减。前一段叫预热,作用是在训练初期参数还很随机时避免大学习率把模型带偏,尤其是后归一化结构对初始阶段很敏感。后一段衰减是为了后期精细收敛。

按base模型d_model=512算一下峰值学习率:d_model^(-0.5)约等于0.0442,在step_num等于4000时,step_num^(-0.5)约等于0.0158,两者相乘约等于0.0007。这就是峰值学习率,训练过程中不会超过这个值。这个数字看起来很小,但配合25000个token的批大小正好合适。

批量大小按token数算,每批约25000个源token和25000个目标token,而不是按句子数。这样做的原因是句子长度差异大,按句数会导致每批实际token量波动剧烈。base模型在8块P100上训练约12小时,共10万步;big模型训练30万步,约3.5天。这两个数字在表2里也能对上训练成本那一列。

还有一个训练技巧是checkpoint平均:把最后20个检查点的权重平均起来用。这个方法在论文里一笔带过,但收益不小,通常能稳定提升零点几个BLEU,而且零成本。我在自己的项目里也保留了这个习惯。

5.2 正则化三件套:dropout、标签平滑和注意力dropout

论文5.4节列了三种正则化手段,值得单独拎出来讲。

残差dropout:每个子层的输出在加到残差之前做一次dropout,base模型dropout率0.1。注意位置是在Sublayer(x)之后、相加之前,不是加到之后再drop。

注意力权重dropout:softmax之后的注意力权重也做dropout,同样是0.1。这个操作比很多人想的更有用,它逼着模型不要把全部权重压在一个位置上,间接鼓励多头分工。

标签平滑:标签平滑值取0.1。这个技术的做法是把正确类别的目标概率从1降到0.9,剩下0.1均匀分给其他类别。它会让模型输出的概率分布不再那么极端,虽然会降低困惑度指标的好看程度,但能显著提升BLEU和准确率。论文特意提了一句,他们用了标签平滑之后模型学到的分布更不确定,但翻译质量更高。这个反直觉的现象值得记住:困惑度和生成质量不是一回事。

三种正则化叠加使用时要注意总强度。我试过把dropout全部提到0.3,结果训练前期loss下降很慢,最后效果反而变差。0.1这个值是作者调过的,base模型够用,小数据集上可以适当提高,大数据集上可以适当降低。

5.3 数据预处理与批处理策略的实际考虑

论文5.1节讲数据。英德用了WMT 2014,约450万句对;英法用了更大的WMT 2014,3600万句对,句子被切成约32000个词元的子词单元。这里“子词单元”就是后来的BPE那一类做法,目的是平衡词表大小和未登录词。

批处理按近似序列长度分组,论文说他们组内句子长度相近,一个批大约包含25000个源token和25000个目标token。这个策略的作用是减少padding浪费:如果一批里既有5个词的句子又有100个词的句子,短句要补到100,算力大量浪费在填充位上。按长度分桶之后,padding比例大幅下降,训练速度提升明显。

我自己的经验是,分桶的粒度不要太细,太细会导致每个批的样本量很小、梯度噪声大;也不要太粗,粗了padding又浪费。一般按长度分10到20个桶、桶内打乱,是个比较稳的折中方案。

另一个容易被忽略的是词表共享。论文3.4节说编码器嵌入、解码器嵌入和输出线性层的权重共享。这样做参数量直接减少两倍多,而且让输入输出在同一语义空间里对齐。但在源语言和目标语言不同的时候,共享嵌入需要先把它们映射到同一个子词词表,否则维度对不上。实现时如果用两套独立词表,就不要强行共享,改成只共享解码器嵌入和输出层。

6. 实验结果怎么读,以及常见疑问排查

6.1 表2和表3里的数字到底在比什么

论文表2是主结果表,在英德和英法两个方向上和当时的最好模型对比。看这张表要抓三个维度:BLEU分数、训练成本、模型参数量。Transformer big在英德上28.4 BLEU,比此前最好结果高2个BLEU以上;英法上41.8 BLEU,训练成本只有对比模型的四分之一左右。这个对比最有力的地方不是分数更高,而是“用更少的算力拿到更高的分数”,说明瓶颈确实被解开了。

表3是模型变体分析,作者在英德开发集上做了几组消融。有几个结论值得记住。第一,头数不是越多越好,单头比8头低约0.9 BLEU,但16头并没有明显超过8头,说明多头收益有上限。第二,key的维度d_k减小会掉分,这印证了相似度计算对表达能力的要求。第三,模型越大效果越好,big比base高1.4 BLEU左右,但仍然不如多头带来的收益稳定。第四,位置编码换成学习式嵌入和正弦式差距很小,说明正弦编码的价值主要在于可外推,而不在于性能本身。第五,dropout去掉后掉分明显,正则化不能省。

这几组消融是论文里信息密度最高的部分,我强烈建议做复现的人把这几个变体都跑一遍,比读十遍论文管用。

6.2 常见问题速查表:从loss不降到BLEU异常低

第一次复现Transformer的人,遇到的问题高度相似。下面这张表是我自己踩过和帮别人排查过的典型情况。

现象可能原因排查与解决
loss完全不降学习率固定、没有预热换成论文的预热加衰减公式,warmup设4000
loss震荡发散用了后归一化但学习率过高降低峰值学习率或改用预归一化
loss降但BLEU个位数多头切分维度顺序写错检查view与transpose的先后顺序
生成结果重复同一句缺少因果掩码或掩码位置错确认上三角为负无穷、对角线保留
训练几百步出现NaN掩码用负无穷加混合精度改成-1e9,并在softmax前统一数据类型
位置编码后loss变差嵌入未乘根号d_model嵌入输出乘以sqrt(d_model)或让编码缩放到相近量级
推理速度远慢于训练逐词解码未缓存实现增量解码或先用beam search小批量验证
显存爆掉序列过长导致注意力矩阵过大缩短序列长度、使用梯度累积替代大batch

这张表里最后一行值得多说两句。注意力矩阵是n×n的,序列长度翻倍,注意力相关显存翻四倍。显存不够时,正确做法是减小批大小同时用梯度累积凑出等效批大小,而不是简单砍序列长度,因为砍长度会直接损失长距离依赖建模能力。论文里的批大小25000 token也是在一批P100上折腾出来的结果,硬件不同不要照搬。

另外补充一个隐蔽问题:训练时如果用的是教师强制,推理时却是自回归解码,两者之间的分布差异会导致推理阶段表现差一截,这就是曝光偏差。缓解手段有调度采样、加噪声、以及标签平滑。论文用标签平滑的一部分动机也在这里。

6.3 从这篇论文延伸出去的分支,以及我建议的阅读顺序

把论文读完之后,往下走有几条主线。第一条是预训练语言模型线:BERT用编码器堆叠加双向注意力,GPT用解码器堆叠加因果注意力,两者都是Transformer的直接后代。理解这篇论文里编码器和解码器的分工差异,就能明白为什么BERT适合理解类任务、GPT适合生成类任务。

第二条是效率线:O(n²)的复杂度推动了稀疏注意力、线性注意力、滑动窗口注意力、内存压缩注意力等一系列工作。这些方法的不同点在于它们怎么近似注意力矩阵,有的按稀疏模式裁剪,有的用核函数重写,有的做低秩分解。想搞清楚这些,前提是对表1里的复杂度有概念。

第三条是跨模态线:Vision Transformer把图像切成patch当token,Swin Transformer引入窗口注意力和移位机制恢复局部归纳偏置,语音和视频领域也有各自的自注意力变体。共同点是保留了Transformer的核心骨架,只在输入表示和注意力模式上做适配。

第四条是工程线:混合精度、激活重计算、FlashAttention、张量并行和流水线并行。这些不是论文内容,但决定了你能不能把模型真正训起来。我个人建议的阅读顺序是先啃完这篇论文的3.1到3.4节,然后动手写一版小模型在玩具数据上跑通,再回头读5.3和5.4的训练配置,最后去看后续工作的改进点。顺序颠倒的话,很容易在还没理解注意力是怎么算的时候,就先去研究FlashAttention的显存布局,效率很低。

最后再分享一个小技巧。如果你想把这篇论文彻底吃透,最好的办法不是反复读,而是找一个现成的开源实现,把里面的每一行和论文逐句对照,凡是注释里没写清楚的,自己补上并推导一遍。我当时就是这么做的,一个六百行的实现对照了两天,之后再读任何Transformer变体,基本都能在半小时内看懂结构。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 4:29:23

Bash中let与普通赋值有何不同?详解算术求值、退出码与set -e陷阱

我之前调试一个批量重命名的脚本时,遇到一段看起来毫无问题的代码:let "n n 1"跟我想的一样吗?不,它彻底打乱了我的脚本。原因很简单:let不是“赋值语句”,它是一门独立的算术求值器。这个名字…

作者头像 李华
网站建设 2026/9/30 4:29:21

TensorFlow本质:端到端模型生命周期操作系统

1. 这不是“又一个深度学习框架”——TensorFlow 的真实定位与误用起点 很多人第一次听说 TensorFlow,是在某篇“2024年AI工程师必学工具”清单里,和 PyTorch 并列排在第二行;也有人是在公司内部培训PPT上看到它被标为“生产级首选”&#x…

作者头像 李华
网站建设 2026/9/30 4:29:18

Paperclip范式:AI Agent能力封装的工程实践

1. “Paperclip”不是回形针:一个被误读的AI工程隐喻与真实技术图谱最近在多个技术社区和面试复盘帖里反复看到“paperclip”这个词,尤其高频出现在React、Node.js和AI agents相关的讨论中。有人把它当成某个新出的前端UI库,有人以为是OpenCl…

作者头像 李华
网站建设 2026/9/30 4:29:16

AI工程骨架:从零构建可生产、可维护、可演进的AI系统

1. 这不是“搭积木”,而是重建AI系统的地基“AI Engineering from Scratch”——看到这个标题,很多人第一反应是:又要学Python、装PyTorch、跑个MNIST?不。这六个单词背后,是一整套被工业界反复验证却极少被系统拆解的…

作者头像 李华
网站建设 2026/9/30 4:28:41

C++ 编译期字符串哈希:用 constexpr 消除路由分发性能开销

最近在翻自己写的路由分发代码时,我看见一段扎眼的东西:十几个字符串命令,全靠一连串if (cmd "login")、if (cmd "logout")这样比较下去。每次请求进来,CPU 都要把这些字符串从头到尾比一遍。当时我就想&am…

作者头像 李华
网站建设 2026/9/30 4:28:38

DeepSeek视觉搜索API实战:300ms精准图像检索

简介:本资源是一份面向AI开发者与计算机视觉初学者的DeepSeek视觉搜索API实战指南,聚焦图像识别中的以图搜图、分类及多模态搜索等核心场景,解决实际项目中API调用、预处理适配与结果解析等关键问题。文档共23页PDF,内容完整、图文…

作者头像 李华