1. 为什么会有Transformer:从预测下一个词的困境说起
2017年那篇《Attention Is All You Need》出来的时候,我第一反应是“这标题真狂”,但读完才发现人家确实有狂的资本。在此之前,NLP领域基本是RNN、LSTM的天下,处理序列数据要么从左往右扫,要么双向扫,但本质上都逃不掉“逐时刻推进”这个逻辑。
RNN最大的问题在于:t时刻的隐状态要等t-1时刻算完才能算,训练没法并行,一个句子几百个token就慢得离谱。更麻烦的是,序列一长,信息经过逐步传递会衰减,即便LSTM、GRU加了门控机制,本质上也仍然是“一条流水线上一个一个往下传”,当某个重要信息出现在句首、而要在句尾被用到时,中间那些时刻的隐状态会把它稀释得不成样子。这就是长距离依赖问题的根源。
Transformer给出的解法完全不同——它压根不按时间顺序处理序列,而是把整个句子一次性扔进网络,让任意两个位置的词直接“对话”。这种对话机制就是自注意力。又因为每个位置的输出都是整个序列的加权求和,词与词之间的距离不再是问题,距离再远也是一步直达,同时所有位置的向量可以在同一时刻并行计算,训练效率瞬间拉满。
这篇博文想做的事,就是把这套机制从头到尾拆开揉碎,先讲清楚自注意力里的Q、K、V到底是什么,再解释编码器和解码器为什么长那个样子,然后给出一份可以动手搭起来的核心代码思路,最后把我自己实际训练中踩过的一些坑一并整理出来。不管你是刚入门深度学习、论文读到Attention看不懂推导,还是准备自己训一个翻译或摘要模型,这篇文章应该都能让你少走一些弯路。
2. 自注意力机制的核心:Q、K、V到底在干什么
2.1 从一个简单的检索场景理解Q/K/V
很多教程一上来就摆公式,看得人头大。我先用一个生活化的例子把直觉建立起来。
想象你在图书馆找书。你心里有个“我想找的内容”的概念,比如“怎么训练神经网络”,这就是Q(Query,查询向量)。每本书都有一个“主题标签”和“实际内容”,主题标签就是K(Key,键向量),实际内容就是V(Value,值向量)。查找的过程是:把你大脑里的Q和每本书的K比对一下相似度,相似度高的书,它的V(内容)就多“搬进”你的脑子里,相似度低的就少搬一点。
自注意力就是把这个过程放到一个句子里来做。假设输入句子是“The animal didn't cross the street because it was too tired”,模型在处理“it”这个词的时候,需要知道“it”指代的是“animal”还是“street”。此时“it”的向量就是Q,句子每个词的向量都作为K,将Q与所有K做相似度比较,发现“animal”的位置得分最高,于是把“animal”那一位置的V以最大权重融合进“it”的向量表示里。
这样“it”的最终向量就不只是它本身的词义了,而是把它和上下文里相关的词“粘”在了一起。这个过程对每个词重复一遍,就是自注意力,自注意力里的“自”是指Q、K、V全部来自同一个句子自身,不是外部引入的。
2.2 缩放点积注意力的计算过程
现在看正式定义。给定输入序列的向量表示X,形状是(序列长度, d_model),我们定义三个可学习的权重矩阵W_Q、W_K、W_V,形状都是(d_model, d_model),于是:
Q = X @ W_Q K = X @ W_K V = X @ W_V注意力分数的计算是:
Attention(Q, K, V) = softmax(Q @ K.T / sqrt(d_k)) @ VQ和K做点积,得到的是两两位置之间的原始相关度得分。点积越大,两个向量在几何意义上越“同向”,也就是相关度越高。除以sqrt(d_k)是缩放,d_k是每个注意力头的向量维度,这个缩放非常关键,后面我会专门说为什么。
接着对每一行做softmax,把分数变成和为1的概率分布,再用这个概率对V做加权求和。这个概率分布就是注意力权重,它刻画了“当前这个位置应该从其他位置抽取多少信息”。数值越大的位置,在加权输出里贡献越大。
注意:Q和K的点积矩阵在理论上是任意位置两两配对,所以不管两个词隔着多远,只要分数高,信息就能直接传递过去,这就是Transformer能天然建模长距离依赖的根本原因。
2.3 为什么非要除以根号d_k
这个细节很多人忽略,但它直接关系到模型训练是否稳定。
假设Q和K向量中每个元素的均值是0、方差是1,那么两个维度为d_k的向量做点积后,结果均值仍然是0,但方差变成了d_k。也就是说,当d_k比较大的时候,点积结果会分布在一个很宽的范围里,比如64维的时候,方差是64,标准差是8,大量点积值会落在[-20, 20]这个区间。
如果直接把这些值送进softmax,问题就来了:指数函数e的x次方在x的值很大时增长极其夸张,比如e的20次方约等于4.8亿,而e的0次方等于1,只差一个很小的数值,整个概率分布就会变得极端,几乎所有的注意力权重都会集中到最大值那一个或少数几个位置上,其余位置完全被忽视,而且反向传播时这些地方的梯度也非常小,很容易梯度消失。除以sqrt(d_k)相当于把点积结果的方差拉回1附近,softmax输入的数值范围变得温和,概率分布既不至于太尖锐,也不至于太平坦。
为什么不用d_k而用sqrt(d_k)?因为点积是逐元素相乘再求和,两个独立同分布向量的点积方差就是d_k,要把它归一化到方差为1,就要除以sqrt(d_k),这可以理解为一个简单的方差标准化操作。这个设计看着不起眼,却从数学上防止了训练早期梯度消失或者注意力崩塌的问题。
2.4 多头注意力:为什么要多个“脑袋”
单组的Q、K、V只能学到一种“相关性”定义,但实际语言中的关系非常丰富,可以是语法上的主谓关系、指代关系、语义上的对比关系,甚至只是一些固定的搭配习惯。一个注意力头很难同时追踪这么多种关系,于是多头注意力机制出马了。
多头注意力的做法是:把d_model维度的Q、K、V切成h份,每份维度是d_k = d_model / h,各自用独立的权重矩阵计算注意力。比如d_model=512,h=8,那就是8组独立的注意力头,每组在64维的子空间里计算。每个头计算完后得到维度为(序列长度, 64)的结果,把8个头的输出在特征维度上拼接起来,正好又拼回512维,最后再乘一个输出权重矩阵W_O。
通俗地说,单头注意力是一个资深的“单一视角评估师”,多头注意力像是8个从不同角度审视文本的专家。有的头特别擅长捕捉“当前位置的下一个词是什么”,有的头更关注“距离比较远的语法相关词”,还有的头会追踪“是不是在引语内部”。这些头各司其职,最后汇总出的特征自然比单头丰富得多。
我实际观察过一些小模型的注意力权重可视化,确实能看到某些头固定地attend到句子的前一个词,某些头则呈现对角线分布,还有一些头有明显地attend到特殊符号的倾向。这还没法解释得很完全,但多头带来的表达能力提升是实打实的。
2.5 两种掩码:Padding Mask和Look-ahead Mask
这是Transformer里最容易被忽略的细节,但它和自注意力机制直接相关。
Padding Mask解决的是变长序列的问题。一个batch里的句子长短不一,为了拼成统一形状的张量,我们一般把短句后面用特殊的PAD符号补齐到和最长句子相同的长度。但这些PAD位置是没有真实语义的,注意力计算不应该让它们影响其他位置,否则等于在空白符号上浪费注意力权重分数。解决办法是:在计算分数矩阵时,给PAD位置的分数加上一个极大的负数,比如-1e9,这样softmax之后这些位置的概率无限接近0。
Look-ahead Mask则用于解码器,也叫因果掩码。解码器的任务是逐词生成输出,在生成第t个词的时候,它只应该看到当前位置及之前的词,不能偷看到未来。实现方式是一个上三角矩阵,矩阵里当前位置之后的位置全部被掩码成极小的负值,softmax之后这些未来位置的权重就变为0。翻译任务中,解码器的自注意力就靠这个掩码把“未来”给堵死。
3. 编码器-解码器架构逐层拆解
3.1 模型骨架总览
Transformer整体是一个Encoder-Decoder结构。编码器负责把源语言句子读进来,转成一组富含上下文信息的向量表示。解码器则根据编码器的输出,以及已经生成的目标语言前缀,一步步预测下一个词。
标准Transformer的编码器由6层相同的EncoderBlock堆叠,解码器也是6层DecoderBlock。每层内部主要包含自注意力子层和前馈神经网络子层。编码器的自注意力没有掩码限制,能看到整个源句子,解码器自注意力则使用Look-ahead Mask,只允许看到已生成的部分,另外解码器中间还有一个额外的交叉注意力子层,负责从编码器输出中提取源语言的关键信息。
“6层”不是硬性规定,我见过3层的、12层的,层数越多模型容量越大,但训练难度和显存消耗也同步上升,实际项目更多是按任务复杂度和算力条件来折中。
3.2 输入嵌入与位置编码
词嵌入把离散的token映射成连续向量。拿机器翻译举例,“hello”会被查表得到一个512维的向量,这个表是随模型一起训练出来的。
但自注意力本身对位置是不敏感的,你把“我爱你”换成“你爱我”,自注意力计算出来的两两相关度完全不变,词序信息完全丢失。这就好比你给了一个可以把所有字洗乱的句子,模型根本不知道哪个词在前面哪个词在后面。
Transformer给每位置补充了位置编码:对位置pos,第i维的编码值如下。
偶数维度:
PE(pos, 2i) = sin(pos / 10000^(2i / d_model))奇数维度:
PE(pos, 2i+1) = cos(pos / 10000^(2i / d_model))这是一组不同频率的正弦和余弦波,之所以选三角函数,是因为位置p和位置p+k的编码之间存在一个线性变换关系,模型容易通过学习捕捉相对位置信息。使用正弦形式的另一个好处是它能外推到训练时没见过的更长序列——虽然现在很多模型改用可学习位置嵌入或者旋转位置编码,但核心思想一脉相承。
实际操作中,位置编码加在词嵌入之后,不是拼接。最终输入向量就是词嵌入加位置编码,把词义信息和位置信息结合在同一个空间里。还有一种常见做法是让位置编码参与训练,直接把位置作为可学习的参数表,从效果上看差别没有想象中那么大,但固定形式的正弦编码参数少、泛化性更好。
3.3 编码器层内部结构与前馈网络
每个编码器层,先过一层多头自注意力,再过一层前馈神经网络。这里的“前馈神经网络”不是卷积网络,而是两个全连接层夹一个激活函数:
FFN(x) = ReLU(x @ W1 + b1) @ W2 + b2Transformer论文里,内层维度d_ff一般设为2048,比d_model的512大4倍。这个设计很像先把特征空间放大到更丰富的维度做非线性变换,再压缩回原始维度,可以理解为在注意力汇总信息后,再做一次逐位置的“特征精加工”。
每个子层前后都有残差连接和LayerNorm。残差连接将子层输入和输出相加,梯度可以沿着捷径直接回传,缓解深层网络的梯度问题。LayerNorm则是把每个位置的向量在特征维度上归一化到均值0、方差1,使模型训练更稳定。
需要注意,这里的归一化方式不是BatchNorm那种按batch维度做,而是对每个样本的每个位置独立做,这保证了无论batch大小怎么变、序列长短怎么变,归一化行为都是稳定的。这也是Transformer在NLP任务上对BatchNorm不敏感的重要原因。
3.4 解码器层:掩码自注意力加交叉注意力
解码器每层比编码器多一个子层,结构是三级:掩码多头自注意力、交叉多头注意力和前馈网络。掩码自注意力负责聚合已经生成的词序列中的信息,前文说的Look-ahead Mask就在这里发挥作用。
交叉注意力是整个Encoder-Decoder信息交互的枢纽,它的Q来自解码器的掩码自注意力输出,K和V则来自编码器的最终输出。这相当于解码器在生成每一个词时,都会回头“查”一下源句子中哪些词最相关。比如在法译英任务中,解码器生成“the”的时候,交叉注意力的权重可能会很高地指向源法语句子里的“la”或“le”这样的词。
解码器的整体计算顺序是:掩码自注意力只看“已生成的词”,交叉注意力结合“源语言的上下文信息”,前馈网络做最后的特征变换。每层同样都要过残差连接和LayerNorm。这样设计出来的解码器,在每一步生成时都同时掌握“自己说了什么”和“原文讲了什么”两份信息。
4. 从零搭建一个Transformer的核心步骤
4.1 核心模块的代码思路
现在写代码。PyTorch是目前最容易上手的框架,我给出改造过的核心模块代码,不追求和论文一字不差,但保证可以跑通。
先定义缩放点积注意力:
import torch import torch.nn as nn import torch.nn.functional as F def scaled_dot_product_attention(Q, K, V, mask=None): d_k = Q.size(-1) scores = torch.matmul(Q, K.transpose(-2, -1)) / (d_k ** 0.5) if mask is not None: # 把mask中为True的位置填充为极小的负数 scores = scores.masked_fill(mask, -1e9) attn_weights = F.softmax(scores, dim=-1) output = torch.matmul(attn_weights, V) return output, attn_weights这里mask要提前构造好,形状是(batch, 1, seq_len, seq_len)。把不需要attend的位置烧成True,后面会直接用-1e9覆盖,安全系数高。
多头注意力封装:
class MultiHeadAttention(nn.Module): def __init__(self, d_model, n_heads): super().__init__() assert d_model % n_heads == 0, "d_model必须能被n_heads整除" self.d_model = d_model self.n_heads = n_heads self.d_k = d_model // n_heads 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) def forward(self, x, mask=None): batch_size, seq_len, _ = x.size() Q = self.W_Q(x).view(batch_size, seq_len, self.n_heads, self.d_k) K = self.W_K(x).view(batch_size, seq_len, self.n_heads, self.d_k) V = self.W_V(x).view(batch_size, seq_len, self.n_heads, self.d_k) # 把n_heads提前一维,方便做批量矩阵乘法 Q = Q.transpose(1, 2) K = K.transpose(1, 2) V = V.transpose(1, 2) outputs, attn = scaled_dot_product_attention(Q, K, V, mask) outputs = outputs.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model) return self.W_O(outputs)d_model能被n_heads整除是硬约束,我见过的报错里“shape mismatch”大概有三成出在这一步。
前馈网络的写法就简单了:
class PositionwiseFFN(nn.Module): def __init__(self, d_model, d_ff, dropout=0.1): super().__init__() self.fc1 = nn.Linear(d_model, d_ff) self.fc2 = nn.Linear(d_ff, d_model) self.dropout = nn.Dropout(dropout) def forward(self, x): return self.fc2(self.dropout(F.relu(self.fc1(x))))编码器层的完整实现:
class EncoderLayer(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout=0.1): super().__init__() self.self_attn = MultiHeadAttention(d_model, n_heads) self.ffn = PositionwiseFFN(d_model, d_ff, dropout) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): # 子层1:多头自注意力 + 残差 + LayerNorm attn_out, _ = self.self_attn(x, mask) x = self.norm1(x + self.dropout(attn_out)) # 子层2:前馈网络 + 残差 + LayerNorm ffn_out = self.ffn(x) x = self.norm2(x + self.dropout(ffn_out)) return x解码器层的差别在于多加一个交叉注意力子层,逻辑是一样的,Q来自解码器端,K和V来自编码器端,代码上只需把MultiHeadAttention的输入分开传:
class DecoderLayer(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout=0.1): super().__init__() self.masked_self_attn = MultiHeadAttention(d_model, n_heads) self.cross_attn = MultiHeadAttention(d_model, n_heads) self.ffn = PositionwiseFFN(d_model, d_ff, dropout) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.norm3 = nn.LayerNorm(d_model) self.dropout = nn.Dropout(dropout) def forward(self, x, encoder_output, look_ahead_mask=None, padding_mask=None): attn_out, _ = self.masked_self_attn(x, look_ahead_mask) x = self.norm1(x + self.dropout(attn_out)) attn_out, _ = self.cross_attn( Q=x, K=encoder_output, V=encoder_output, mask=padding_mask ) x = self.norm2(x + self.dropout(attn_out)) ffn_out = self.ffn(x) x = self.norm3(x + self.dropout(ffn_out)) return x注意交叉注意力里的mask传的是padding mask,不是look-ahead mask。因为K来自编码器的源句子,源句子里PAD位置的信息不应该被attend到,所以需要把源句子的PAD位置mask掉。而解码器自注意力需要同时处理两个mask:look-ahead mask负责挡住未来词,padding mask负责挡住目标序列里的PAD位置。实践中通常把这两个mask合并成一个大mask再做 masked_fill,效果一样,还没那么容易写错。
4.2 掩码矩阵的构造
构造掩码是实操里很容易出错的地方。
def create_padding_mask(seq, pad_token_id=0): # seq形状: (batch, seq_len),返回 (batch, 1, 1, seq_len) mask = (seq == pad_token_id).unsqueeze(1).unsqueeze(2) return mask # True的位置是PAD,需要被忽略 def create_look_ahead_mask(seq_len): # 生成上三角矩阵,对角线及以下为False,以上为True mask = torch.triu(torch.ones(seq_len, seq_len), diagonal=1).bool() return mask # True的位置是未来位置,需要被忽略当PAD的token id恰好是0时,要确认词表里0号确实是专门用来垫的PAD,如果0号是实际的“unk”之类的合法词,就会把正常内容给mask掉,这个bug极难发现,排查时先确认词表设计。
4.3 训练策略与自回归推理
Transformer训练用Teacher Forcing,也就是把目标句子的真实词序列和偏移一位的版本同时喂进解码器:训练时输入“ I love you”,期望输出“I love you ”。这样训练和推理时解码器看到的历史序列长度不一样,是Transformer训练中一个非常典型的“训练与推理不一致”问题。
训练时的损失函数用交叉熵,计算时要把PAD位置的loss屏蔽掉,不然大量PAD会稀释掉真实词的梯度。常见写法是:
loss = F.cross_entropy(logits.view(-1, vocab_size), targets.view(-1), ignore_index=pad_token_id)推理时多数采用自回归方式:先把“ ”喂给解码器,得到第一个词的分布,从这个分布里按概率采样,然后把“ ”和生成的词拼在一起,再来一轮,生成下一个词,直到生成“ ”或达到最大长度。这种方法每一步都依赖之前生成的词,如果前一个词错了,后面可能跟着一串错,这也是为什么很多系统对束搜索或采样温度调参那么敏感。
训练时的注意力掩码和推理时还不一样,训练时look-ahead mask是固定的,推理时因为序列不断变长,mask要跟着变,代码里不能写死。
我在实际项目中的做法是:训练阶段直接用一个固定的上三角mask,推理阶段每次循环都重新构建mask。这样逻辑清晰,不容易出错。
4.4 显存优化与效率问题
Transformer训练最头疼的问题就是显存。序列长度越长,注意力矩阵的形状是(seq_len, seq_len),序列长度增长时显存开销是平方级增长。512长度的句子还好,如果去处理4096长度的长文,注意力矩阵本身就让大多数消费级显卡直接崩掉。
几个我实测有效的优化手段:
- 梯度累积:模拟更大的batch,而不是一次性把大batch塞进显存。
- 混合精度训练:PyTorch里直接加torch.cuda.amp.autocast(),能在不损失精度的情况下显著减少显存占用,速度也更快。
- 激活函数重计算:训练时前向传播不保存所有中间激活值,反向传播时重新算一遍,显存节省巨大,代价是训练时间变长。
- 稀疏注意力或窗口注意力:让每个位置只attend到附近若干位置,而不是全部位置,长序列场景的标配方案。
训练速度上,我还会给多头注意力的每一步检查形状,避免无意间做了广播导致计算量爆炸。
5. 训练过程中最常遇到的坑与排查方法
先说结论:Transformer收敛慢,绝大多数时候不是模型结构的问题,而是训练配置的问题。下面列出的问题我几乎每个都踩过,有些甚至折腾了我好几天。
5.1 维度不匹配和形状报错
最常见的是d_model无法被n_heads整除,然后是mask维度不对。mask维度必须是(batch, 1, seq_len, seq_len),这样才能和注意力分数的形状(batch, n_heads, seq_len, seq_len)广播。如果你发现mask和scores形状对不上,多半是unsqueeze的维度放错了。
解码器交叉注意力的Q来自解码器,K和V来自编码器,它们的seq_len可以不一样。很多新手在这里直接把K和V都传成x,导致解码器根本没“看”源句子,模型就等于失忆了。在翻译任务里这种情况的典型表现是损失能降,但生成的译文和原文毫无关系。
5.2 损失出现NaN
Transformer训练早期非常容易出现NaN,我遇到的情况八成是学习率设置过大。Adam优化器默认learning rate是1e-3,但Transformer论文用的是Noam学习率调度:前warmup_steps步线性增加,之后按步数的倒数平方根衰减。如果没有warmup,训练初期参数还没稳定,过大的学习率很容易把loss冲到NaN。
我的建议是:直接用论文里的Adam + Noam调度器,warmup_steps设4000左右;如果你不想搞调度器,就把初始学习率压到5e-5到2e-4之间,加上梯度裁剪(clip_grad_norm_,最大值1.0左右)更稳。
还有一种隐藏的NaN来源是fp16混合精度下的下溢问题,如果你的序列里有大量非常长的句子,激活值太小变成0,反向传播后梯度可能是NaN。这时把loss scaling调高,或者干脆先纯fp32跑通再切混合精度,优先确认模型本身没问题。
5.3 损失不下降或下降极慢
损失持续很高,先检查是不是padding mask或look-ahead mask没生效。一个检测技巧是:在训练时把mask关掉,观察loss是不是突然能降了,能降就是mask逻辑有问题。
还有一种情况是位置编码没加。不加位置编码的Transformer,句子里所有词的顺序信息完全没有,模型能学会词义但不能学会语序,翻译质量会很怪,loss在降到一定程度后就不再降,因为它在硬记词的共现而没有真正的句法结构。
训练时显存不够又会反过来导致batch_size设得特别小,比如batch为1或2,梯度噪声太大,模型不断震荡,loss曲线像锯齿一样上下乱跳。这时候用梯度累积,把有效batch_size撑到32或64,往往收敛速度立刻有改善。
5.4 推理时效果差但训练时loss很低
这种“自嗨型”过拟合,通常是训练和推理行为不一致导致的。训练用的是Teacher Forcing,解码器输入的是真实的历史词,推理时输入的是自己生成的词,分布一旦有偏差,误差就一路累积,生成的句子越来越离谱。
常用的缓解办法是Scheduled Sampling,训练过程中随机、按一定概率把真实词替换成模型上一时刻自己生成的词,让解码器逐渐适应它自己的错误。另一种思路是在推理时做束搜索,保留top-k条候选序列而不是只走一条路,这样即使一个词选错,还有别的候选路径兜底。
我在文本摘要任务里发现,把解码器的输出加上温度系数,温度调到0.7到0.9之间,生成出的文本比贪心解码流畅自然得多,无脑贪心解码容易重复相同词汇。
5.5 训练时间太长的优化思路
如果模型训练速度实在慢,先分析瓶颈。序列长度在512以下时,多头注意力的矩阵乘不算太夸张,真正的计算热点经常在d_ff=2048的全连接层上。可以考虑把d_ff降到1024看看效果变化,性能能接受,训练速度却能涨不少。序列长度超过1024时,瓶颈就到了注意力本身,这时优选稀疏注意力或窗口注意力思路。
我见过的另一个非常隐蔽的坑是,embedding层在返回时忘了乘sqrt(d_model)。论文里Embedding之后要乘sqrt(d_model)来调整和位置编码的相对大小,如果忘了,词向量和位置编码的尺度不匹配,训练初期就不稳定。虽然现在很多开源库也省略这个操作,但对效果的影响是真实存在的。
6. 写在最后的个人经验
有几点是我折腾Transformer这么久以来最深的体会。第一,不要迷信论文里那双数,必须结合自己任务的算力和数据量做取舍。我做过一个小语种的翻译模型,d_model从512降到256,n_heads从8降到4,d_ff从2048降到1024,参数少了大半,推理速度快了不少,BLEU分数只掉了零点几,但训练时间缩短了一半以上。这个比例非常划算,你得先搞清楚自己的瓶颈在算力还是在数据量,再来决定模型容量怎么调。
第二,自注意力权重可视化真的值得做一次。把训练好的模型的注意力矩阵画出来,你会亲眼看到某些头在追踪指代、某些头在关注句子结构,这种直观理解比看再多公式都有用。可视化的代码很简单,pytorch里在forward的返回里把attn_weights拎出来画个热力图就行。
第三,mask的调试永远优先于模型结构的调优。这个问题我提了三次,是因为它真的太容易出错了。后置检查法很容易被漏掉,比如look-ahead mask写反了,模型训练时等于一直在偷看未来的答案,训练loss非常低,推理时彻底崩塌,这是最阴险的一种bug,因为表面上看一切正常。
第四,Transformer不是“堆层就变强”。很多新手以为无脑堆个24层就能超越12层,但实际上深层模型在数据量不足时会发生优化困难,训练起来变得极其脆弱。如果没有额外的预训练或大规模数据,建议从2到4层的小模型开始,先把数据pipeline和训练配置验证干净,再逐步加深。
这套架构能统治NLP这么久,靠的确实是那些精巧的设计:并行、全局依赖、多头视野、稳定归一化。但你真正跑起来后会发现,模型结构只是基础,把它调好、训稳、避免那些隐藏的坑,才是一个工程师真正的日常。希望这篇文章能帮你少踩几个我踩过的坑,早日在自己的任务上跑出漂亮的效果。