NLP圈子里,Transformer已经完全是默认底座,从BERT到GPT,再到各种多模态模型,核心架构都离不开它。这篇是这个系列第五篇,后台留言看下来,大家的问题大多不是“我知不知道它重要”,而是“看了几十张图解、几十篇模型详解,一到自己手写代码就卡住”。所以这一篇换个思路,把Transformer在自然语言处理里的核心机制拆到代码级别,再用两个小任务——正弦数据预测和文本分类——把从定义模型到训练调参的完整链路跑一遍。这篇更偏动手向,适合已经学过基础NLP、想在代码层面真正掌握Transformer的读者。
说句实在话,Transformer的数学门槛不高,难点在于各组件之间的配合。很多教程喜欢画线条巨多的大图,看的时候觉得逻辑很顺,关掉浏览器脑子就一片空白。我自己刚开始手写时也翻过车:位置编码忘了加、mask维度对不上、softmax里填了float('-inf')导致训练直接NaN。所以这篇文章会把“为什么这么做”和“怎么做”放在同等位置,尽量让你看完能自己把核心代码写出来。
1. 核心设计拆解:Transformer为什么长这样
1.1 序列建模拐点:从RNN到自注意力
在Transformer出现之前,RNN和LSTM是序列建模的主力。LSTM通过门控机制缓解了梯度消失问题,但本质上是串行结构,t时刻的隐状态必须等t-1时刻算完才能继续。这意味着长序列训练慢,而且随着序列变长,信息要经过很多步才能从起点传到终点,长距离依赖依然脆弱。
Transformer选择了一条完全不同的路:自注意力。所有位置的信息在每一层里直接两两交互,任意两个token之间的依赖路径长度都是1。怎么理解这件事?RNN像流水线上逐个处理工件的工人,每一步都依赖上一个动作;Transformer更像一群人围在会议室里,每个人把自己的想法写在白板上,所有人都能同时看到所有人的内容。于是位置与位置之间的“距离”不再是序列距离,而是直接连通。
这种设计带来的最大红利是并行。GPU天生适合矩阵运算,自注意力可以一次性对整个序列做矩阵乘法,训练效率远高于逐步传播的RNN。后来的长期依赖实验也表明,Transformer在需要跨长上下文建模的任务上优势明显。你现在看到的BERT、GPT,本质上都是自注意力堆叠出来的编码器或解码器结构。
1.2 Self-Attention公式的直觉逻辑
自注意力的核心公式长这样:
Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V
Q、K、V分别叫查询、键、值,它们来自输入X的三次线性变换。你可以把Q理解成“我想找什么信息”,K理解成“我携带什么标签”,V理解成“我真正要传递的内容”。两个向量的点积大小说明它们的匹配程度,匹配度高的位置会获得更大的注意力权重。
这里有一个容易被忽略的细节:为什么要除以sqrt(d_k)?如果不做缩放,当d_k很大时,点积结果的方差也会很大,softmax的输入会落到两侧梯度接近0的区域,模型很难训练。除以sqrt(d_k)相当于把点积结果的方差拉回1附近,让softmax的梯度保持在比较敏感的范围。我见过不少初学者手写时漏掉这个缩放,结果训练要么不收敛,要么loss下降慢得离谱。
之后整个流程就是:用softmax把相似度分数变成权重,再用权重对V做加权求和。每个位置都会拿到一个“根据当前上下文重新整合过”的表示。注意这些权重是完全动态的,同一个词在不同句子里会被不同的上下文重新解释,这正是Transformer表达力强的关键。
1.3 位置编码:让Transformer知道顺序
自注意力本身不关心token顺序。把“猫喜欢鱼”和“鱼喜欢猫”输入同一个没有位置信息的Transformer,模型看出的结果几乎一样。这显然不行,所以我们需要在输入里注入顺序信息。
经典Transformer用了一组正弦和余弦函数:
PE(pos, 2i) = sin(pos / 10000^(2i/d_model))PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
为什么要用三角函数,而不是直接给一个可学习的位置向量?其中一个重要原因是外推能力。sinusoidal位置编码的频率是固定的,模型没见过特别长的序列时,也能想办法根据已有频率模式推测更长位置的大致编码。可学习位置编码在训练长度内性能也不错,但一旦序列长度超过训练时最大长度,模型很容易失效。
另一个小细节是,不同维度上的三角函数频率不同:低位维度变化快,高位维度变化慢。这样不同位置会产生不同的编码模式,同时模型也有机会通过线性组合学到相对位置关系。我在实验里也验证过,固定sinusoidal编码在短序列任务上和可学习编码差距不大,但它不需要额外参数量,而且在序列外推场景下明显更稳。如果你使用的库已经内置了位置编码,优先选可配置的方式;如果自己手写,sinusoidal永远是入门最稳的选择。
2. 模块级拆解:多头注意力、FFN和Mask
2.1 多头注意力是“一人分饰多角”
多头注意力做的事,可以理解成把d_model维的表示切分成h个子空间,每个子空间独立做一次注意力计算。经典配置是d_model=512、h=8,每个头拿到64维。为什么要多个头?因为单个注意力只能学到一种“匹配模式”,而自然语言中有很多种关系需要建模:有的头可能倾向于关注句法依赖,有的头关注指代关系,有的头关注语义相关性。多头相当于让模型同时用多种视角看同一个序列。
代码实现时最关键的一步是维度重塑。输入X的形状是[batch, seq_len, d_model],我们通过线性层映射到Q、K、V,再view成[batch, seq_len, num_heads, head_dim],最后转置成[batch, num_heads, seq_len, head_dim]。这样矩阵乘法Q @ K.transpose(-2,-1)得到[batch, num_heads, seq_len, seq_len],每个头独立计算注意力权重,互不干扰。
我在调参时发现一个规律:头数增加不一定会变好。头数越多,每个头的维度越小,能表达的信息越受限;同时头数增加带来参数和计算量上升。对小数据集,8个头常常已经够用;如果需要压缩模型,4个头也不会差太多。重点是让d_model能被num_heads整除,否则代码会在view时直接报错,这也是手写Transformer最常见的低级bug之一。
2.2 FFN和残差:非线性靠FFN,收敛靠残差
尽管自注意力已经很强,但它本质上是加权求和,属于线性操作。如果只有注意力层堆叠,整个模型无论多少层都还是线性的,表达力不够。所以每个TransformerBlock后面都要接一个前馈网络:
FFN(x) = Linear(d_model -> d_ff) + 激活函数 + Linear(d_ff -> d_model)
一般d_ff设为d_model的4倍,也就是先放大再压缩。激活函数最常用的是ReLU或GELU,GELU在Transformer里表现更平滑,训练稳定性略好。靠这一层非线性变换,模型才能表达复杂函数。参数量上,FFN占了Transformer总参数的三分之二左右,反而是大头。
残差连接和LayerNorm在这里承担了稳定训练的任务。残差让梯度可以直接从深层流回浅层,解决了深层网络退化问题;LayerNorm对每个token的特征向量做标准化,避免了每一层的输出分布剧烈波动。更推荐使用Pre-LN结构,也就是先做LayerNorm,再做注意力/FFN,再加残差。我在实践里发现,Post-LN在层数较深时对学习率和warmup极其敏感,Pre-LN用起来省心很多,收敛更稳定,这也是现在开源实现的主流写法。
2.3 Mask机制:Padding mask与Causal mask
Mask是Transformer手写实现里最容易被忽略、也最容易出错的点。它分两类。
第一类是Padding mask。用于处理batch内变长序列。假设batch里有一条长度是10的句子,另一条长度是7,我们把短句补3个pad token到长度10。注意力计算时,如果不屏蔽pad位置,模型就会把这几个无意义的pad当成正常token,注意力权重被稀释。做法是让softmax之前对应位置的scores变成非常大的负数,比如-1e9,softmax后这些位置的权重就接近0。注意千万不要直接用float('-inf'),在fp16混合精度下-inf可能导致NaN,经验上-1e9更稳。
第二类是Causal mask,也叫自回归掩码。用于生成任务,比如GPT的训练。模型在预测第t个位置时,只能看到位置1到t-1的输入,不能看到未来。实现上是一个下三角矩阵:当前位置及之前为1,之后为0。在注意力score矩阵上,凡是mask为0的位置,全部填充为-1e9。
还要注意这两类mask经常会叠加使用。正确做法是分别对score矩阵做两次masked_fill,或者把两种mask相乘后一次性填充。常见错误是直接用mask.unsqueeze(1)就去填充,但score矩阵的维度是[batch, heads, seq_q, seq_k],mask至少需要[batch, 1, seq_q, seq_k],否则广播维度对不上,直接报错。
3. 最小实现:从正弦数据预测到文本分类
3.1 一个只有核心组件的Transformer骨架
下面这个实现去掉了很多工程细节,但保留了一个TransformerBlock最核心的部分:位置编码、多头注意力、FFN、残差和LayerNorm。代码可以直接在PyTorch里跑,适合理解原理。
import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len=5000): super().__init__() pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) self.register_buffer('pe', pe.unsqueeze(0)) def forward(self, x): return x + self.pe[:, :x.size(1)] class MultiHeadAttention(nn.Module): def __init__(self, d_model, num_heads): super().__init__() assert d_model % num_heads == 0 self.d_k = d_model // num_heads self.num_heads = 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.out = nn.Linear(d_model, d_model) def forward(self, x, mask=None): batch, seq, _ = x.size() Q = self.W_q(x).view(batch, seq, self.num_heads, self.d_k).transpose(1, 2) K = self.W_k(x).view(batch, seq, self.num_heads, self.d_k).transpose(1, 2) V = self.W_v(x).view(batch, seq, self.num_heads, self.d_k).transpose(1, 2) scores = Q @ K.transpose(-2, -1) / math.sqrt(self.d_k) if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) attn = torch.softmax(scores, dim=-1) context = attn @ V context = context.transpose(1, 2).contiguous().view(batch, seq, -1) return self.out(context) class TransformerBlock(nn.Module): def __init__(self, d_model, num_heads, d_ff, dropout=0.1): super().__init__() self.attn = MultiHeadAttention(d_model, num_heads) self.ff = nn.Sequential( nn.Linear(d_model, d_ff), nn.ReLU(), nn.Linear(d_ff, d_model) ) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): x = x + self.dropout(self.attn(self.norm1(x), mask)) x = x + self.dropout(self.ff(self.norm2(x))) return x用的时候,把输入先过PositionalEncoding,再串多个TransformerBlock,最后根据任务接输出头。注意这里做的是Pre-LN,也就是先归一化再进入子层,效果比Post-LN稳定不少。上面的注意力实现里,我们手动完成了Q@K^T和softmax,而不是调用nn.MultiheadAttention,目的是让你看清楚内部每一步在干什么。
3.2 任务一:让Transformer拟合正弦序列
有人可能会问,Transformer不是做NLP的吗,为什么要先去拟合正弦数据?因为正弦序列是一个“干净”的回归任务:数据生成完全可控,可视化简单,而且能快速验证你的Transformer实现是否正确。只要这个模型能拟合一个简单的非线性函数,你才有信心把它搬到文本数据上。
任务可以这样定义:给定过去seq_len个时间步的值,预测下一个时间步的值。为了造数据,我先生成一段连续的sin函数:
import numpy as np N = 10000 t = np.linspace(0, 20 * np.pi, N) y = np.sin(t)然后用一个固定窗口在y上滑动,生成[seq_len, 1]的输入样本和[1]的标签。把数据按7:1.5:1.5划分训练、验证和测试。模型方面,不需要embedding层,输入直接就是连续数值,所以用一个nn.Linear(1, d_model)把每个时间步的单维数值投影到d_model空间,再叠加位置编码。超参我习惯先给d_model=32、num_heads=4、layers=2、d_ff=64,batch_size=64。这个配置在几万条数据上非常快。
训练时使用MSE损失和Adam优化器。如果你用的还是上面那段代码,记得在训练前准备一个padding mask=None的调用,因为正弦数据没有变长问题。loss一般会在几十个epoch内降到比较低,预测曲线基本贴着真实sin曲线走。不过注意,序列端点位置经常会有轻微滞后,因为模型本质是在做一种“平滑外推”,它会倾向于往历史均值方向收缩。给输入加一点点噪声比如np.random.normal(0, 0.01, y.shape),反而能提升一定泛化能力,类似正则化。
3.3 任务二:改成文本分类任务
正弦预测验证了回归能力,现在把同一个Transformer改成文本分类。改动其实非常小。
第一,需要加一个Token Embedding层。因为文本输入是离散token id,我们要用一个nn.Embedding(vocab_size, d_model)把token id映射到连续向量,再叠加位置编码。
第二,需要构造Padding mask。batch内句子长度不同时,把短句pad到统一长度,然后记录哪些位置是pad,生成mask张量。
第三,输出头要换。正弦任务是输出一个连续值,分类任务则是输出类别分布。常见做法是对序列输出做平均池化(mean pooling),或者模仿BERT在句首加一个[CLS]token,把它最后一个TransformerBlock的输出接一个nn.Linear(d_model, num_classes)。如果没有做特殊处理的[CLS],mean pooling通常也够用。
代码改动大概是这样:
class TextClassificationTransformer(nn.Module): def __init__(self, vocab_size, d_model, num_heads, d_ff, num_layers, num_classes, max_len=128): super().__init__() self.embedding = nn.Embedding(vocab_size, d_model) self.pos = PositionalEncoding(d_model, max_len=max_len) self.blocks = nn.ModuleList([ TransformerBlock(d_model, num_heads, d_ff) for _ in range(num_layers) ]) self.classifier = nn.Linear(d_model, num_classes) def forward(self, x, mask=None): x = self.embedding(x) x = self.pos(x) for block in self.blocks: x = block(x, mask) x = x.mean(dim=1) return self.classifier(x)训练时损失函数换成CrossEntropyLoss,优化器同样用Adam。如果发现过拟合,可以在TransformerBlock里增加dropout,或者在Embedding后加一层dropout。这个结构在做情感分类这类任务时,两三层Transformer就能拿到不错的基线,虽然不如预训练大模型,但胜在可以完全掌控内部行为,适合做实验和教学。
4. 实操复盘:训练不稳定、loss不降的问题排查
4.1 学习率与优化器:Transformer的命门
Transformer对学习率极其敏感。很多人把CNN那套训练经验直接搬过来,比如固定学习率1e-3,结果loss不是发散就是原地踏步。这是因为Transformer的深层结构加上自注意力,梯度的方差比普通CNN大得多。
我推荐的做法是使用Adam优化器,并设置betas=(0.9, 0.98)、eps=1e-9。同时配合学习率warmup策略:前几轮用很小的学习率让参数在初期稳定,之后再线性上升到峰值,之后按步数的倒数衰减。公式可以写成lr = d_model^-0.5 * min(step_num^-0.5, step_num * warmup_steps^-1.5)。
在PyTorch里可以用LambdaLR实现,不过要注意step计数从0开始还是从1开始,不同版本有细微差异。我的习惯是自己写一个简单调度器,保证可复现:
class WarmupScheduler: def __init__(self, optimizer, d_model, warmup_steps): self.optimizer = optimizer self.d_model = d_model self.warmup_steps = warmup_steps self.step_num = 0 def step(self): self.step_num += 1 lr = self.d_model ** -0.5 * min(self.step_num ** -0.5, self.step_num * self.warmup_steps ** -1.5) for param_group in self.optimizer.param_groups: param_group['lr'] = lr self.optimizer.step() def zero_grad(self): self.optimizer.zero_grad()实际经验是warmup_steps在4000左右通常够用,小数据集可以适当减少,比如1000。如果你的loss在训练早期就冲到几百甚至NaN,先不要怀疑模型结构,优先检查学习率是不是太大,以及有没有忘掉除sqrt(d_k)。
4.2 位置编码和Mask导致的隐蔽Bug
这里分享两个我在踩坑之后总结出来的典型问题。
第一个是训练时忘记加位置编码。如果你只拿TransformerBlock去拟合正弦序列,你会发现它其实也能拟合,因为注意力可以学出一些固定模式,但泛化能力很差。一旦序列长度变长,预测就明显跑偏。这个bug在代码里很容易看不出来,因为loss还是会在下降,只是测试集表现异常。检查方法也简单:把位置编码单独输出出来看一眼,确认输入到第一层Block的序列确实带上了位置信息。
第二个是Mask维度错误。常见报错是类似The expanded size of the tensor (X) must match the existing size (Y),这通常是因为mask没有扩展到[batch, 1, seq_q, seq_k]。还有人会在softmax之后做mask,这等于把已经归一化的权重直接清零,会导致注意力行和不等于1,语义完全错误。正确的做法永远是:先构造mask,在softmax计算前对scores执行masked_fill。
另外要提醒一点,不要用mask * scores这种方式来做padding mask。乘0看起来把score清零了,但softmax的归一化分母只针对未被mask的位置,所以乘0并不会让这个位置的注意权重变成0,只会让其他位置的权重被均匀压缩。用-1e9填充才能真正屏蔽掉相应位置。
4.3 性能调优与常见问题速查表
最后给你一个我自己整理的问题速查表,都是实际项目中高频碰到的情况。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| loss发散或NaN | 学习率太大,或mask填了-inf | 调低学习率,使用warmup,fill值改成-1e9 |
| loss不降 / 收敛极慢 | 忘记除以sqrt(d_k),或层数过深使用Post-LN | 加缩放,改用Pre-LN结构 |
| 验证集过拟合 | 模型容量太大 / 数据量太少 | 增大dropout,减少层数或头数,加数据增强 |
| 序列变长后效果崩 | 位置编码外推能力不足 | 使用sinusoidal编码或RoPE,不要超max_len训练 |
| GPU显存OOM | batch太大或序列太长 | 减小batch,打开梯度累积,使用混合精度 |
| 训练慢 | 注意力复杂度高 | 使用Flash Attention,开启torch.compile |
| 分类任务准确率低 | 输出头只用最后一层,信息丢失 | 尝试mean pooling + 多层MLP分类头 |
其中梯度累积是减小batch之后很实用的补偿手段。比如原本batch=64,显存不够改成batch=16,那就累积4个step再更新一次参数,效果和batch=64接近。混合精度在Transformer上收益明显,PyTorch自带torch.cuda.amp,对NLP任务来说几乎是无痛提速。Flash Attention我建议直接试试,对长序列尤其有奇效,能大幅降低显存占用。
这套最小实现我后来改过很多版本,已经变成我自己做NLP实验的通用底座。最后分享一个很实用的习惯:新模型先拿正弦数据或者随机噪声把前向和反向跑一遍,确认loss能稳定下降,再换到真实文本数据。很多人一上来就在情感分类上调试,模型不收敛时根本分不清是代码错、数据错还是参数错。用可控任务先把链路打稳,再上真实任务调试,往往能省下大半天时间。