做NLP项目这些年,我绕不开一个话题——Transformer。从文本分类到序列标注,从短文本到长文档,架构选择的天平几乎都指向它。这篇东西算是我从RNN时代一路用过来的记录,会聊清楚为什么Transformer能成为NLP领域的事实标准,也会拆解自注意力、多头机制、位置编码这些核心模块,再给出一段可以照着跑的代码和实际任务中的经验教训。不管你是刚入门还是已经写过不少模型,应该都能从这里找到一些有用的东西。
1. 从RNN到Transformer:NLP架构切换背后的真实理由
1.1 序列建模的老问题:长程依赖与并行计算的矛盾
在Transformer成为默认选择之前,NLP领域的主力是RNN、LSTM、GRU这些循环架构。它们按时间步逐个读取输入,天然适合序列数据。但它们有两个难以调和的矛盾:第一是长程依赖问题,信息需要经过很多步才能传递,中间会有梯度衰减或爆炸,所以LSTM引入了门控机制,GRU做了精简,但这只是缓解,不是根治;第二是训练速度问题,因为是逐步计算,每个时间步都要等前一步完成,GPU并行能力发挥不出来,序列一长训练时间就让人崩溃。
举个例子,一句话有20个词,LSTM要串行处理20步。每一步的隐状态依赖前一步的输出,无法把句子拆成小块同时计算。这在短句上还能忍,但到了段落、文档级别,训练成本会指数级上涨。我当时用LSTM做一个长文本匹配项目,单条样本超过500字,一个epoch就要跑很久,调整超参数的成本特别高。而Transformer直接摒弃了循环结构,用注意力机制一次性看到整个序列,所有位置的表示可以同时计算。这意味着训练时间主要花在矩阵运算上,而这恰恰是GPU最擅长的。所以说Transformer的流行不只是效果更好,更是一整套工程效率上的胜利。
1.2 注意力机制如何绕过“逐步传递”的限制
注意力机制的出现不是Transformer发明的,更早的seq2seq模型里也用了attention,目的是让解码器在生成每个词时,动态地看编码器的哪些位置更有用。Transformer把这种思想推到极致:不再是解码器只看编码器,而是编码器内部每个位置都与其他所有位置做注意力计算,这就是自注意力。
自注意力的核心是建立一个“全连接”的依赖图。对任意两个词,不管它们在句子中相隔多远,都能直接建立关联。比如“A把书放在桌上,然后走出了房间”,这里的“A”需要作为“走出”的主语,传统RNN需要隔着十几个词慢慢传递,而自注意力一步到位。这在实际项目里带来的最大变化是:长距离语法关系、指代消解、共指问题都能更容易学到。代价是计算量变为序列长度的平方,这在后面会提。
1.3 什么时候你会真正需要Transformer
有人会问,是不是所有NLP任务都应该用Transformer?我的经验是不能一概而论。对于真正需要捕捉全局语义、长距离依赖的任务——比如文本分类、情感分析、阅读理解、关系抽取——Transformer几乎是无法绕过的选项。但对于一些极短的输入,比如关键词抽取、布尔查询匹配,传统方法依然有优势。速度快、可解释性强、不需要大规模GPU。
另外一个容易被忽略的点:Transformer需要更多数据来训练。从零训练一个Transformer比训练RNN更困难,因为它非常依赖大语料和较长的训练步数。所以我更推荐大多数场景直接使用基于Transformer架构的预训练语言模型做微调,而不是从零开始。这个选择会直接影响项目周期。我会在后面第4节详细展开。
2. Transformer核心组件拆解:自注意力、多头与位置编码
2.1 自注意力计算的完整流程与维度推导
先从最简单的单头自注意力说起。输入是序列X,形状为(seq_len, d_model),d_model是每个词向量的维度。通过三个可学习的权重矩阵W_Q、W_K、W_V,分别把X映射为Q、K、V,形状都是(seq_len, d_k),这里d_k通常等于d_model/h。注意力分数矩阵等于Q乘以K的转置,再除以根号d_k,得到(seq_len, seq_len)的分数矩阵。最后对每一行做softmax,乘以V,得到新的表示。
为什么要除以根号d_k?因为Q和K中的元素通常是独立同分布的,点积后数值的方差会随着d_k变大而增大。如果d_k是100,点积结果可能很大,softmax的输入一旦进入饱和区,梯度会非常小,模型很难训练。除以根号d_k相当于把点积的方差恢复到1附近。这一点是Transformer论文里比较容易被忽略的细节,但如果你自己实现过,就会明白它有多重要。
维度推导也有一个小坑。假设输入batch是16条文本,每条文本128个token,d_model=512,n_heads=8。那么Q、K、V在拆头之后应该是(16, 8, 128, 64),而不是把512直接分给头维度。很多新手在这里搞混,导致转置和矩阵乘法维度对不上。写代码时务必用view把最后一个维度拆开,再用transpose把头和序列位置调换,否则出来的形状完全不是预期。
2.2 多头注意力为什么不是“多个注意力”那么简单
多头注意力就是把d_model分成h个子空间,每个子空间单独做自注意力,然后把h个头的结果拼接,再过一层输出投影。直观理解是:每个头可以关注不同的依赖模式。有的头可能更关注相邻词的语法关系,有的头则关注较远距离的指代关系。concat之后,模型能看到多种视角的组合。
这里容易踩的坑是,很多人以为多头就是简单复制多个注意力层。其实关键在于把维度切分。比如d_model=512,h=8,每个头的d_k=64。如果你直接把QKV复制8份,每份维度也是512,那么计算量会膨胀8倍,模型参数量也变大,但效果并不等价。正确做法是先通过一个线性层把维度压缩到64,再做注意力。后面代码里我会给出具体写法。
另外,多头注意力的头数选择也有讲究。头数太少,比如1个,模型退化成单头注意力,可能学不到多角度的依赖;头数太多,比如超过64,每个头的维度太少,反而容易丢失信息。常见的经验值在8到16之间。如果你用的是预训练模型,头数通常已经定好,不用自己折腾。
2.3 位置编码的工程选型与相对位置方案的取舍
Transformer没有循环结构,它无法天然知道词的顺序。所以需要给每个位置注入位置信息。最基本的位置编码是使用不同频率的正弦和余弦函数,每个位置得到一组固定向量,加到词嵌入上。优势是能泛化到训练时没见过的长度。另一种做法是把位置编码当作可学习参数,随机初始化后在训练中更新。可学习编码在固定长度任务上通常更灵活,但遇到超长输入时没有见过就难处理。
相对位置编码则更近一步,在计算注意力分数时直接使用两个位置之间的相对偏移量偏置。比如对于序列中相距k的两个词,在QK^T上加上一个可学习的偏置。这种方式在长文本任务中更稳定,因为相对距离比绝对位置更有语义意义,比如“第5个词”这种绝对信息远不如“当前词前面第三个词”来得自然。如果你处理的是长文档,我强烈建议优先考虑相对位置编码的实现。
从我自己的项目经验看,如果只是做标准长度(512 token以内)的分类任务,正弦位置编码完全够用;但如果要处理长段落、多句拼接,或者对长度外推有要求,相对位置编码会更稳。选位置编码时不要只看短期训练集表现,还要想想线上会不会出现比训练集更长的输入。
3. 从零搭建一个Transformer编码器:关键代码与踩坑记录
3.1 网络结构定义与超参数选择
这部分给出实际可跑的代码。我用PyTorch实现一个标准的Transformer编码器层,然后叠加几层组成分类模型。这里的关键超参数是d_model、n_heads、d_ff、num_layers。还有一个容易被忽略的是dropout率,推荐0.1左右。下面是一个简化的编码器层实现:
import torch import torch.nn as nn import torch.nn.functional as F class MultiHeadSelfAttention(nn.Module): def __init__(self, d_model, n_heads, dropout=0.1): super().__init__() assert d_model % n_heads == 0 self.d_k = d_model // n_heads self.n_heads = 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.out = nn.Linear(d_model, d_model) self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): batch, seq_len, _ = x.size() Q = self.w_q(x).view(batch, seq_len, self.n_heads, self.d_k).transpose(1, 2) K = self.w_k(x).view(batch, seq_len, self.n_heads, self.d_k).transpose(1, 2) V = self.w_v(x).view(batch, seq_len, self.n_heads, self.d_k).transpose(1, 2) scores = Q @ K.transpose(-2, -1) / (self.d_k ** 0.5) if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) attn = F.softmax(scores, dim=-1) attn = self.dropout(attn) ctx = attn @ V ctx = ctx.transpose(1, 2).contiguous().view(batch, seq_len, -1) return self.out(ctx)然后是前馈网络和残差连接:
class TransformerEncoderBlock(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout=0.1): super().__init__() self.attn = MultiHeadSelfAttention(d_model, n_heads, dropout) self.norm1 = nn.LayerNorm(d_model) self.ff = nn.Sequential( nn.Linear(d_model, d_ff), nn.ReLU(), nn.Dropout(dropout), nn.Linear(d_ff, 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这里把LayerNorm放在残差分支之前,也就是Pre-LN结构。相比原始论文的Post-LN,Pre-LN在深网络中更稳定,训练初期不容易出现梯度爆炸。我自己搭模型时一开始用的Post-LN,结果20层就翻车了,学习率怎么调都不收敛;换成Pre-LN之后立刻稳定下来。所以别小看这几行代码的顺序。
3.2 训练中的不稳定因素:学习率、梯度累积与 Warmup
从零训练Transformer最常遇到的问题就是损失不降或直接产出NaN。第一个要检查的是学习率。Transformer对学习率非常敏感,原论文用的是带Warmup的自适应学习率:先让学习率从很小的值线性上升到峰值,再按步数的倒数衰减。这个策略我一直在用。
warmup步数通常设置为总训练步数的5%到10%。如果batch太大,warmup可以短一点;batch小,warmup要长一些。学习率峰值一般设在1e-4到5e-4之间,使用Adam优化器时要配上betas=(0.9, 0.98)和epsilon=1e-9。这个设置来自Transformer论文,实际用下来很稳。学习率调度器可以写成下面这样:
def get_lr(step, d_model, warmup_steps): return d_model ** -0.5 * min(step ** -0.5, step * warmup_steps ** -1.5)如果你喜欢框架自带的学习率调度器,也可以实现同样的策略。关键是step从1开始算,前warmup_steps步内学习率线性上升,之后按step的平方根倒数衰减。
另外,如果训练数据长度差异很大,容易造成一个batch内padding过多,浪费算力还影响收敛。可以按长度相似度进行桶排序,把长度接近的样本放一个batch。这能显著减少padding比例。我试过同样的数据,只改batching逻辑,训练速度就快了一半还多。
3.3 损失下降异常的排查链路
我统计了一下,A同学刚开始复现Transformer时,遇到最多的问题就是训练一段时间后loss变成NaN或者一直卡在很高的位置不降。其实排查链路是固定的。
第一步看输入:是否有NaN、分词结果是否正常、padding mask是否正确。第二步看学习率:把学习率降到原来的十分之一,看第一个量级的loss是否下降。第三步看梯度范数:在训练循环里打印一下梯度范数,如果超过一定阈值就做梯度裁剪。第四步看LayerNorm位置和残差连接顺序。第五步检查注意力mask和softmax维度是否对齐。把这条链路走一遍,基本能排除90%的问题。
我自己遇到过一次非常离谱的问题——损失一直在0.7左右不降,像是有个小错误但又不崩溃。最后发现是位置编码我直接加在了embedding后面,但embedding没有乘以系数sqrt(d_model)。这在原论文里是有的一个小细节:embedding层输出要放大sqrt(d_model),再和位置编码相加。不乘这个系数,相当于位置信息相对词嵌入占比被削弱了,模型不容易区分不同位置的语义。改完之后loss顺利降下去了。
4. 把Transformer用到实际NLP任务:文本分类与序列标注的经验
4.1 预训练+微调的关键操作细节
如果是实际项目,我不会从头训练Transformer。更常见的做法是加载一个基于Transformer架构的开源预训练语言模型,然后在自己的下游任务上微调。这里要处理好几个关键点。
第一,输入格式。预训练模型有自己固定的分词方式和特殊标记,比如开头的[CLS]、句间的[SEP],这些不是随意加的。如果你的文本包含多个句子,要正确拼接并生成对应的token type ids,否则模型会把句子之间的边界搞混。
第二,学习率。微调时的学习率要比预训练低很多,常见是1e-5到5e-5。很多人直接沿用从零训练时的1e-4,结果模型很容易灾难性遗忘,在预训练阶段学到的通用语言知识被冲掉,下游任务表现反而变差。我习惯先从2e-5开始,观察验证集loss,如果出现过拟合就降到1e-5,如果欠拟合就提到5e-5。
第三,max length。预训练模型对输入长度有上限,常见的是512个token。如果你的任务需要处理更长文本,不要简单地把超过长度的内容截断,那样会丢失关键信息。可以先做切分,把文本分成多个片段分别编码,再用池化或注意力池化汇总特征。
4.2 数据增强与样本不平衡的处理
NLP任务中数据不平衡非常常见。比如垃圾评论分类,负面样本可能只占5%。直接用原始比例训练,模型会倾向把所有样本都预测为多数类。我常用的处理方式包括:对少数类样本做同义词替换、回译增强、基于简单规则的文本扰动;设置Class Weight,在损失函数中给少数类更高权重;或者使用Focal Loss,让模型更关注难分类样本。
但注意,数据增强不是越多越好。在序列标注任务中,如果对实体词做同义词替换,很可能会破坏实体边界和标签一致性。比如“北京”改成“首都”,实体类型还是地点,这个可以;但如果你把一个多字词随机删除或插入,标签序列就错位了。所以做增强时一定要考虑任务本身的标签约束。我倾向于先做基于预训练模型的掩码替换,生成语义通顺的新样本,再重新跑一遍分词和标签对齐,这样安全很多。
对于文本分类,回译增强是最稳妥的方案之一。把原始中文句子翻译成英文,再翻译回中文,得到的句子语义基本不变,但表达方式会变。这个操作对情感分析、主题分类这类任务很有效。不过回译比较耗时,通常只对少数类样本做,把数量补到和多数类大致平衡。
4.3 推理阶段的速度优化与内存控制
训练完成后,部署时的推理优化也是大头。Transformer模型参数量大,推理时显存占用高、延迟也高。我常用的优化手段有几项。一是把模型转成半精度FP16,在支持Tensor Core的GPU上速度提升明显。不过要注意某些算子在FP16下不稳定,必要时使用混合精度。二是做模型蒸馏,用一个大的教师模型指导学生模型,把知识迁移到更小的模型上,可以大幅降低推理成本。三是使用批处理时动态padding,一个batch内按最长样本做padding,但样本长度差异大会浪费算力,所以最好是按长度排序再组batch。
如果对延迟要求极高,还可以考虑量化到INT8。量化后的模型体积缩小到原来的四分之一,推理速度也提升明显,但效果可能会掉几个点。我一般会先跑一遍验证集,确认性能降幅可接受再上线。
这里还要提一个容易被忽略的点:Transformer的tokenization在推理阶段也会占时间。尤其是用正则分词器逐步切词时,CPU单线程处理几百条文本可能成为瓶颈。实际部署时可以把分词器也放到GPU侧,或者提前做好离线分词缓存,能省很多时间。
5. Transformer的边界与下一步:长文本、可解释性与稀疏注意力
5.1 计算复杂度与长文本处理的瓶颈
前面提过,自注意力的计算复杂度是O(n^2),这里n是序列长度。当文本从几百字变成几千字时,显存占用和推理延迟会急剧上涨。我处理过一个长文档分类项目,单条文档超过4000个token,直接塞进Transformer,显存直接爆掉。后来不得不做滑窗切分,把长文档切成多个重叠窗口,分别编码后池化。虽然效果还行,但显然不是高效方案。这也是近几年出现各种稀疏注意力、滑动窗口注意力、线性注意力变体的原因。它们的核心思路都是想办法降低自注意力的计算量,同时尽量保留长距离依赖的捕捉能力。
对于普通项目需求,我的建议是:如果文本超过2000个token,不要直接硬塞,先做文档结构切分,或者用检索的方式先定位关键段落,再用Transformer编码。工程上更省心。
稀疏注意力的一种常见实现是局部窗口加全局感知。每个token只和固定窗口内的邻居做全量注意力,另外每隔若干个token设一个全局token,负责和整个序列交互。这样计算量从n^2降成n乘以窗口大小,长文档处理成本大幅降低。如果你的业务有强长文本需求,可以研究一下这一类改造。不要一上来就自己写新结构,优先看成熟的开源实现。
5.2 注意力分布的可解释性问题
很多人觉得Transformer的注意力权重可以直接用来解释模型决策,比如看哪个词和哪个词关联度高。但实际用下来,注意力权重并不等于重要性。我在一个文本分类项目里做过对比实验,把注意力权重高的词替换成无关词,预测结果有时候变化不大,有时候却剧烈变化。这说明注意力权重只是模型内部计算的一个中间量,它受到多种因素影响,不能简单当作归因结果。
如果你需要解释模型,建议用更稳定的方法,比如积分梯度、LIME这类归因算法,而不是直接看注意力权重的热力图。这一点在落地给非技术同事汇报时特别重要,否则很容易被质疑模型的可信度。
另外,多头注意力中不同头关注的内容差异很大,没有一个统一的“正确”注意力图。有些头学到的模式可能和语法很接近,但有些头完全学的是与任务相关的、人类难以理解的模式。所以别把注意力可视化当作逻辑链条,它更多是辅助调试工具。
5.3 对后续发展的观察与个人实践方向
从近几年的工作看,Transformer本身还在进化。比如把注意力计算变成分块或稀疏模式,在保证全局依赖的前提下降低复杂度;再比如加入检索模块,让模型能在推理时临时访问外部知识库,减少参数记忆负担。我个人的看法是,短期内我们还会继续用Transformer做主力架构,但会在工程上加入更多针对性优化,比如用更聪明的batching策略、混合精度、蒸馏、量化,以及针对长文本的切分和召回方案。
另一个我比较关注的方向是轻量化部署。NLP项目从实验到上线,差距最大的往往不是模型效果,而是模型能不能在有限算力下稳定跑起来。所以我会在项目早期就想清楚推理需求,而不是等模型训练完再去压缩。如果你也在做实际NLP项目,建议从第一天就把推理效率和长文本处理纳入架构选型。