如果你曾试图用循环神经网络(RNN、LSTM)处理长文本,一定被那种“读到后面忘了前面”的无力感折磨过。序列太长时,隐状态塞不下那么多信息,训练还容易梯度消失,更别提并行化几乎为零。Transformer就是冲着这些问题去的——它用一套基于词嵌入和注意力机制的架构,把自然语言处理带进了新阶段。这篇博文面向刚接触Transformer的初学者,也适合想把基础补扎实的从业者。我会从词嵌入讲起,逐步拆解注意力机制的内部原理,再用PyTorch手写一个迷你版本,最后把常见坑都摆出来。不扯大而全的理论,只讲清楚“是什么、为什么、怎么用”。
1. 内容整体设计与思路拆解
1.1 为什么选择“词嵌入+注意力”这条主线
想理解Transformer,最忌讳一上来就背架构图。其核心创新只有两个:输入表示(词嵌入+位置编码)和特征提取(自注意力机制)。这两个点恰好对应了自然语言处理最底层的两个需求:让文字变成模型能算的数字,让模型能建模词与词之间的关系。
传统做法里,词向量负责第一个需求,RNN负责第二个需求。“词嵌入+注意力”则把两者统一到了一个框架中:词嵌入把离散符号映射到连续空间,注意力机制直接在符号之间建立动态权重关系,不再依赖顺序递归。因此,把这条主线拆透,Transformer后面的多头、前馈、残差等细节就都是自然延展。
1.2 从“一句话”理解Transformer的整体数据流
我给你一个最简单的直觉:Transformer就是一堆向量被反复“翻译”和“交换信息”。输入句子“我今天很开心”,先分词为“我”“今天”“很”“开心”,再查找词嵌入表,得到4个向量;为了保持顺序信息,每个向量再加上一个位置编码向量;随后这些向量进入多层注意力模块,每一层里,每个向量都和其他所有向量计算关联强度,再根据关联强度重新聚合彼此的信息;最后输出一个同样长度的向量序列,供下游任务(分类、翻译、生成)使用。
这个数据流没有循环,没有顺序依赖,本质上是一次次矩阵乘法。正因如此,它天然可以并行,也就能在GPU上高效率训练。整个架构看起来复杂,但把数据流理清后,每一块你都能独立看懂。
2. 核心细节解析与实操要点
2.1 词嵌入:从独热编码到可学习的映射矩阵
词嵌入的核心目标,是把一个词映射成一个稠密向量。为什么不用独热编码?因为独热编码的维度等于词表大小,假设词表有3万词,每个词就是3万维向量,且只有一个位置是1,其余全是0。这种表示有两个致命问题:维度爆炸,以及词与词之间完全孤立——两个词之间的余弦相似度永远是0,模型无法从表示层面看出“猫”和“狗”都是宠物。
Transformer反其道而行。它维护一个可训练的嵌入矩阵E ∈ R^{vocab_size × d_model},每个词对应其中一行。行向量初始是随机的,在训练中不断调整,最终让语义相近的词在向量空间里靠近。这就是“嵌入”的含义——把离散符号嵌入到连续向量空间中。实际操作中,输入一批词的索引即可,模型内部会自动查表:
import torch import torch.nn as nn vocab_size = 30000 d_model = 512 embedding = nn.Embedding(vocab_size, d_model) input_ids = torch.tensor([[1, 5, 89, 42, 328]]) x = embedding(input_ids) # shape: (1, seq_len, d_model)这里(1, seq_len, d_model)就是Transformer的输入张量格式。注意nn.Embedding本质上就是一个可学习的权重矩阵,前向计算就是F.embedding(input, weight)。如果你在Keras里用过Embedding层,原理完全一致,只是参数名称略有不同。
2.2 位置编码:为什么必须有,以及两种主流方式
上面的词嵌入没有位置信息。如果把“我打你”和“你打我”的词嵌入分别求和,得到的向量几乎一样,但语义相反。Transformer需要一种方式告诉模型每个词在句子中的位置。这里有两种主流方案。
第一种是固定三角函数位置编码,原始论文里用的。对位置pos、维度索引i,编码值如下:
PE(pos, 2i) = sin(pos / 10000^(2i/d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))这种编码的巧妙之处在于,任意位置的编码可以表示成其他位置编码的线性组合,相当于模型天生就带上了“相对位置”的归纳偏置。第二种是可学习位置编码,BERT、GPT等主流预训练模型常用,直接初始化一个[max_seq_len, d_model]参数矩阵参与训练。实践中后者往往更省心,因为不需要设计公式,让模型自己学就行。
无论哪种方案,最后都是把位置编码加到词嵌入上:x_final = x_embed + pos_encoding。加而不是拼接,是为了不额外增加维度,避免后续注意力计算的维度翻倍。
2.3 自注意力机制:从“关注相关词”说起
自注意力(Self-Attention)是Transformer的灵魂。它的目标是对序列中每个词,计算它与其他所有词的关联程度,并按关联程度加权汇总其他词的信息。举个例子:句子“它因为太重了,所以没被搬上楼”,这里的“它”指代什么?人脑会自然把“它”和“太重”关联起来。自注意力做的事情就是让每个词自己学会去找这种关联。
实现上需要引入三个向量:Query、Key、Value。可以这样理解:Query是“我要找什么”,Key是“我有什么标签”,Value是“我实际提供的信息”。计算过程分三步:
- 对输入向量
x,分别乘上三个可学习的矩阵W_Q、W_K、W_V,得到q、k、v。 - 用
q和所有k做点积,得到注意力分数,再除以sqrt(d_k)防止数值过大。 - 对分数做Softmax归一化,得到权重,再用权重对所有的
v做加权求和。
写成矩阵形式就是:
Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V这里的d_k是每个头的维度。除以sqrt(d_k)是关键的细节。假设d_k=64,点积的方差会随维度增大而增大,不缩放的话Softmax输入过大,梯度极小,训练很难推进。这个缩放算是一个经验性的必要操作。
2.4 多头注意力:为什么一个头不够用
单一注意力头相当于一个“关注模式”。但自然语言里的关系是复杂的:有的词依赖语法关系,有的词依赖语义相似,有的词依赖位置邻近。单个头很难同时捕捉多种模式,于是论文提出了多头注意力:把d_model维向量切分成h个头,每个头有自己的W_Q、W_K、W_V,独立计算注意力,最后把各头输出拼起来再经过一个线性层。
我常用的设置是d_model=512、h=8,每个头维度d_k=64。切分并不是直接view那么简单,需要注意维度顺序。PyTorch里常见写法是先把d_model拆成h和d_k,再交换维度:
def split_heads(x, h, d_k): # x: (batch, seq_len, d_model) batch_size, seq_len, _ = x.size() x = x.view(batch_size, seq_len, h, d_k) # 先切成h块 return x.transpose(1, 2) # (batch, h, seq_len, d_k)多头带来的好处是:每个头可以在不同的子空间里学习不同的关系。比如一个头可能学会关注“句法依存”,另一个头学会关注“指代消解”,最终拼接信息比单头丰富得多。实践告诉我,头数并非越多越好。头数太多会导致每个头维度太小,表达能力受限,还会加大显存消耗。我在实际项目中,小数据集上8个头足够,大模型才用16或32个。
2.5 残差连接与层归一化:让深度网络真的能训练
注意力层和前馈层的输出,都不是直接传给下一层的,而是先做残差连接,再做层归一化。残差连接就是output = layer_output + input,这能让梯度在反向传播时有一条“高速公路”,避免深层网络梯度消失。层归一化则是对每个样本的特征维度做归一化,让每一层输入保持稳定,加速收敛。
Transformer里的LayerNorm与BatchNorm不同。BatchNorm对一个batch内相同维度的特征做归一化,受batch大小影响;LayerNorm对每个样本自身做归一化,batch大小不影响结果,这在变长序列的NLP任务中更稳定。实际代码通常这样写:
class TransformerBlock(nn.Module): def __init__(self, d_model, h, d_ff, dropout=0.1): super().__init__() self.attn = MultiHeadAttention(d_model, h) self.ffn = 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): # Pre-LN结构:先归一化再进子层,训练更稳定 x = x + self.dropout(self.attn(self.norm1(x))) x = x + self.dropout(self.ffn(self.norm2(x))) return x这里我采用了Pre-LN变体(先归一化再做子层计算),而不是原始论文的Post-LN。原因是实践中Pre-LN训练更稳定,学习率可以开得更大,收敛更快。如果你在实现BERT类模型时遇到不收敛问题,把Post-LN改成Pre-LN往往立竿见影。
3. 实操过程与核心环节实现
3.1 定义模块:用PyTorch搭建迷你Transformer编码层
前面讲了那么多理论,现在动手。我不打算直接上完整模型,而是拆开揉碎,按编码器层、注意力层、位置编码三个模块依次实现。先看最核心的多头注意力模块:
import torch import torch.nn as nn import torch.nn.functional as F import math class MultiHeadAttention(nn.Module): def __init__(self, d_model, h, dropout=0.1): super().__init__() self.h = h self.d_k = d_model // h 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_proj = nn.Linear(d_model, d_model) self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): batch_size, seq_len, _ = x.size() Q = self.w_q(x).view(batch_size, seq_len, self.h, self.d_k).transpose(1, 2) K = self.w_k(x).view(batch_size, seq_len, self.h, self.d_k).transpose(1, 2) V = self.w_v(x).view(batch_size, seq_len, self.h, 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_weights = F.softmax(scores, dim=-1) attn_weights = self.dropout(attn_weights) context = attn_weights @ V context = context.transpose(1, 2).contiguous().view(batch_size, seq_len, -1) return self.out_proj(context)注意mask的处理。当mask中某位置为0时,我们将对应分数设为-1e9,这样Softmax之后权重趋近于0。用-1e9而不是直接填充float('-inf'),是因为float('-inf')在某些GPU运算下会产出NaN,而一个绝对值足够大的负数基本等效于零权重,但更安全。
3.2 实现位置编码与完整编码器层
接下来实现位置编码。使用可学习版本还是三角函数版本?这里我给出两者对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 三角函数固定编码 | 无需训练,可外推到更长序列 | 表达灵活度受限 |
| 可学习位置编码 | 训练中自适应调整,实现简单 | 超出最大长度时需要处理 |
| 旋转位置编码(RoPE) | 相对位置表达好,新一代模型常用 | 实现稍复杂 |
初学阶段直接用可学习位置编码就好:
class PositionalEncoding(nn.Module): def __init__(self, max_len, d_model): super().__init__() self.pe = nn.Embedding(max_len, d_model) def forward(self, x): positions = torch.arange(x.size(1), device=x.device) return x + self.pe(positions).unsqueeze(0)再把自注意力、前馈网络、残差和层归一化组合成一个编码器层。前馈网络就是两个线性层夹一个激活函数,中间维度d_ff通常设为4*d_model。这个比例不是拍脑袋定的:更大的中间维度能给每个位置提供独立的非线性变换空间,但也不宜过大,否则参数暴涨。实际项目中,我在一个小规模文本分类任务上测试过d_ff=2*d_model和4*d_model,后者效果更好但训练慢了约15%。如果资源紧张,可以先用2倍起步。
3.3 单层编码器完整运转流程演示
现在写一个使用单层编码器的文本分类demo,用来验证整个流程是否正确。任务很简单:判断一句话的情感是正向还是负向。词表用小规模的假想词表,不加载预训练权重,纯粹看模型能不能学会基本模式。
class SimpleTransformerEncoder(nn.Module): def __init__(self, vocab_size, d_model=128, h=4, d_ff=512, num_layers=2, num_classes=2, max_len=64): super().__init__() self.embedding = nn.Embedding(vocab_size, d_model) self.pos_enc = PositionalEncoding(max_len, d_model) self.layers = nn.ModuleList([ TransformerBlock(d_model, h, d_ff) for _ in range(num_layers) ]) self.norm = nn.LayerNorm(d_model) self.classifier = nn.Linear(d_model, num_classes) def forward(self, input_ids): x = self.embedding(input_ids) x = self.pos_enc(x) for layer in self.layers: x = layer(x) x = self.norm(x) cls_out = x.mean(dim=1) # 简单取平均,也可以取首token return self.classifier(cls_out)这里分类输出我用了全局平均池化(mean(dim=1))。初学时容易踩坑:有人会把整条序列的输出都送进分类器,结果维度对不上。实际上,文本分类通常只取一个汇总向量,常见做法有首token表示、平均池化、最大池化。Transformer不像BERT有专门的[CLS]token,对于基础模型,平均池化是一个不坏也不惊艳的起点。如果任务涉及抽取式问答或序列标注,就必须保留每个位置的输出,不能用池化。
3.4 训练循环中易被忽略的关键参数
训练Transformer模型,有几个关键参数直接影响成败。第一个是学习率。我见过太多初学者上来就用1e-3的Adam学习率,然后抱怨模型不收敛。Transformer对学习率很敏感,论文用的就是“先预热再衰减”的调度器:前warmup_steps步线性升高,之后按步数的平方根倒数衰减。在小型demo里可以直接用一个较小的固定学习率,比如2e-4,配合Adam的eps=1e-9也能训练起来。
第二个是dropout。注意力权重和子层输出都建议加dropout,小模型设为0.1,数据量越小,dropout应越大,防止过拟合。第三个是batch size。不要以为batch越大越好。Transformer里,大batch会让BN类方法失效,但LayerNorm不受影响,大batch主要问题是显存。实际训练时我会先从batch_size=32起步,看显存占用再调整。
optimizer = torch.optim.Adam(model.parameters(), lr=2e-4) loss_fn = nn.CrossEntropyLoss() for epoch in range(10): for batch_x, batch_y in dataloader: optimizer.zero_grad() logits = model(batch_x) loss = loss_fn(logits, batch_y) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()那段clip_grad_norm_几乎是必加项。尤其对Transformer,梯度范数偶尔会突然爆炸,不限制一下很容易让训练发散。max_norm=1.0是我常用的默认值,如果你的模型较浅,可以放宽到5.0。
4. 常见问题与排查技巧实录
4.1 维度不匹配:初学者最频繁的报错
几乎所有第一次实现Transformer的人都会遇到维度问题。最典型的报错是shape mismatch发生在Q @ K.transpose(-2, -1)。这时不要慌,先打印每一步张量形状。以下是我定位维度问题时的检查顺序:
- 检查嵌入输出是否为
(batch, seq_len, d_model) - 检查位置编码加法后维度是否保持一致
- 检查
view切分多头时,d_model是否能被h整除 - 检查
transpose(1, 2)后的维度是否为(batch, h, seq_len, d_k) - 检查注意力输出拼接后是否为
(batch, seq_len, d_model)
如果使用view报错“shape is invalid for input of size”,绝大多数原因是d_model // h非整数。比如d_model=128、h=5就不行,必须保证整除。
4.2 训练时Loss不下降或下降极慢
新手用Transformer训练小数据集,loss死活不降或下降极慢,我总结过几个最常见原因:
- Learning rate过高或过低。过高会震荡,过低半天不动。先用
3e-4试一次,如果发散,降到1e-4;如果不动,升到5e-4。 - 没有做梯度裁剪。长序列下梯度爆炸导致有效更新被破坏,loss表现为突然跳到
NaN。 - LayerNorm位置不对。Post-LN的原始实现不容易收敛,如果代码是在子层之后才加LayerNorm,试试改成Pre-LN结构。
- 没有掩盖padding位置。如果序列经过padding,参与平均池化时,大量
0向量会把特征稀释。训练前一定要构造attention_mask,并在注意力分数里把padding位置设为-1e9。
4.3 注意力矩阵全是均匀分布:模型没学到关系
偶尔可视化注意力权重时,发现所有注意力权重都接近1/seq_len,没有任何聚焦。通常原因是训练不充分,或者模型太小学不动。另一个原因是softmax的输入太小,所有分数都非常接近,Softmax输出自然均匀分布。可以检查QK^T的数值方差,如果显著小于1,说明缩放比例可能过大了;如果显著大于1,说明没缩放。正常情况,注意力分数方差应该接近1.0左右,过高容易梯度消失,过低则缺乏区分度。
4.4 显存溢出(OOM)的常用对策
训练一个Transformer经常遇到CUDA out of memory。不要在OOM时盲目调小batch,先做这三件事:
- 把模型中的中间变量改用
torch.no_grad的地方尽量少——实际上要减少显存占用,优先减少batch size。 - 使用混合精度训练。PyTorch自带
torch.cuda.amp,自动将部分运算转为半精度,显存几乎减半,速度还能提升。我用的模板是这样:
scaler = torch.cuda.amp.GradScaler() with torch.autocast(device_type='cuda', dtype=torch.float16): logits = model(batch_x) loss = loss_fn(logits, batch_y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()- 如果是序列特别长导致显存炸,可以检查是否真的需要那么长。很多模型的第一个词和最后一个词对分类结果影响不大,截断到256或512能极大缓解显存压力。
4.5 参数冻结与迁移学习的取舍
很多人喜欢直接加载预训练权重,然后在自己的小数据集上微调。这样效果通常远好于从零训练。但有一个常见误区:微调时把所有层都解冻,容易在小数据集上过拟合。我在实际项目里的做法是:
- 先用预训练模型提取特征(冻结所有层,只训练分类头),看baseline效果。
- 然后解冻最后几层Transformer层,用较小的学习率(比如
5e-5)微调。 - 如果效果还在涨,再逐步解冻更多层。
这套渐进式解冻策略虽然简单,但真的能避免很多“微调后效果反而变差”的尴尬。如果从头训练一个小Transformer做学习实验,数据量至少要有几万条,否则很难看到明显收敛——这也是我建议初学者先熟悉模块计算逻辑,再用预训练模型干正事的原因。
5. 进阶方向与扩展建议
5.1 从编码器到编码器-解码器
这里讲的基础Transformer是编码器结构,适合分类、语义相似度等理解任务。如果要处理翻译、摘要等生成任务,需要在编码器之后再接一个解码器,并在解码器里引入因果掩码(causal mask),防止模型看到未来信息。因果掩码的本质就是把注意力分数矩阵的上三角部分全部设为-1e9,这样当前位置只能attend到之前的位置。我在代码里通常这样处理:
def build_causal_mask(seq_len): mask = torch.tril(torch.ones(seq_len, seq_len, dtype=torch.bool)) return mask再配合masked_fill使用即可。初学时容易把编码器的padding mask和解码器的causal mask混为一谈,其实两者可以结合:先构造causal_mask,再与padding mask做“与”运算,或者分别传入注意力函数。
5.2 从标准注意力到高效注意力
标准注意力的时间复杂度是O(n^2),序列一长就吃不消。后来的模型引入了各种近似方法,比如稀疏注意力、分块注意力、线性注意力等。不过这些对初学者而言属于进阶内容。我建议你先把标准实现写熟,理解QKV的意义,再研究优化方案。否则直接上手稀疏注意力,会很容易被各种mask和索引规则绕晕。
5.3 把Transformer应用到非NLP领域
注意力机制并不局限于文本。现在图像分类、时间序列预测、推荐系统都在用Transformer结构。核心思路都是:把输入切成一个个“token”,加上位置信息,然后送入Transformer。图像里叫Patch Embedding,时间序列里叫窗口切片,本质上都是词嵌入的变体。理解了这一层,你会发现Transformer就是一套通用的信息处理框架,换个输入形态就能迁移到别的领域。
我的建议是,学到这里回过去总结一个自己的“Transformer最小清单”:输入表示、位置编码、自注意力、前馈网络、残差与层归一化。能把这五件事用一句话说清楚,基础就过关了。
最后再分享一个我在调试时的小技巧:单独实现一个注意力模块后,别急着训练完整模型,先输入一个只有两三个词的序列,手算一遍前向传播,核对输出维度。我的习惯是写一个打印形状的辅助函数,在每个关键层都输出形状,这样能节省大量排查时间。把基础代码写稳、吃透,再往上叠加模型结构,你会走得比预期快得多。