资讯动态

Transformer从零到实战:自注意力机制与中文文本分类

发布时间:2026/10/13 18:08:17 来源:尧图企业网站定制
1. 从零理解 Transformer为什么它成了 NLP 领域的分水岭如果你最近两年接触过自然语言处理大概率会被一个词反复轰炸——Transformer。不管是做文本分类、机器翻译、情感分析还是现在火得一塌糊涂的大语言模型底层架构几乎都绕不开它。但很多刚入门的朋友跟我一样第一次翻开那篇经典论文的时候满脑子都是“自注意力”“多头”“位置编码”这些词在飞看完之后好像懂了又好像什么都没懂。我自己最开始做 NLP 项目的时候用的还是循环神经网络那一套后来切换到 Transformer踩了不少坑也走了不少弯路。这个系列我打算把 Transformer 在自然语言处理里的应用拆开揉碎从最基础的结构讲起一直到能跑起来一个完整的文本处理任务。第一篇我们先解决三个问题Transformer 到底解决了什么痛点、它的核心组件各自在干什么、以及怎么用代码把它搭出来跑通一个简单的中文文本分类任务。这篇文章适合谁看如果你有基本的 Python 和深度学习概念知道什么是张量、什么是梯度下降但被 Transformer 的各种变体搞得头晕那这篇就是写给你的。我会尽量用生活化的类比来解释那些抽象概念同时给出可以直接复现的代码和参数配置。整个系列不会只讲理论每一篇都会落到实操上让你看完就能动手改自己的项目。2. Transformer 的核心设计思路拆解2.1 循环神经网络到底卡在哪里要理解 Transformer 为什么重要得先知道它替代的东西有什么问题。在 Transformer 出现之前处理序列数据的主流方案是循环神经网络RNN及其变体 LSTM、GRU。这类网络的工作方式很像人读句子从左到右一个字一个字地读每读一个字就把信息存到记忆里然后传给下一个时间步。这个方式听起来很自然但实际用起来有几个硬伤。第一个是串行计算的问题。因为每一步都依赖上一步的输出所以没法并行。你有一块很好的显卡但 RNN 就是快不起来因为计算必须按顺序来。第二个是长距离依赖的衰减。句子一长比如超过三四十个词前面信息传到后面就剩不下多少了。虽然 LSTM 用门控机制缓解了这个问题但并没有根治。第三个是固定长度的上下文表示。不管句子多长最后都要压缩成一个固定维度的向量信息密度太高细节容易丢。我做过一个实验用 LSTM 做长文本情感分类当评论超过 200 个字的时候准确率下降得非常明显。后来换成 Transformer同样的数据准确率直接涨了将近 8 个百分点。这不是说 LSTM 一无是处而是在处理长序列和需要全局信息的任务上Transformer 的结构优势太明显了。2.2 自注意力机制让每个词都能看到所有词Transformer 最核心的创新就是自注意力机制。用大白话讲就是句子里的每个词都可以直接和句子里的其他所有词“对话”然后根据对话的结果来决定自己应该关注哪些词、忽略哪些词。举个例子“小明把书放在了桌子上因为它太重了。”这里的“它”指代的是“书”还是“桌子”人类一眼就能看出来是书因为书重。但模型怎么知道自注意力机制会让“它”这个位置去计算和前面每个词的关联程度发现和“书”的关联分数更高于是就把更多的注意力放在“书”上。这个关联分数就是通过 Query、Key、Value 三个矩阵算出来的。具体计算过程是这样的每个词的向量分别乘以三个权重矩阵得到 Q、K、V 三个向量。然后用当前词的 Q 去和所有词的 K 做点积得到注意力分数再除以一个缩放因子通常是维度的平方根最后经过 Softmax 归一化得到权重。用这些权重对所有的 V 加权求和就得到了当前词的新表示。注意这里的缩放因子很关键。如果维度很大点积结果会很大Softmax 之后会变得非常尖锐梯度会很小训练不动。除以根号 d_k 就是为了让分数分布更平滑梯度更稳定。2.3 多头注意力从不同角度理解同一句话单个自注意力头只能学到一种关联模式。但语言是多义的一个词可能同时和多个词有关系。比如“苹果”可能和“吃”有关也可能和“手机”有关。多头注意力就是并行地跑多个自注意力每个头有自己的 Q、K、V 权重矩阵最后把多个头的输出拼接起来再线性变换。你可以把它想象成多个专家同时看同一句话每个专家关注不同的方面。有的专家关注语法结构有的关注语义关联有的关注指代关系。最后把大家的意见综合起来得到更全面的理解。实践中头的数量通常设为 8 或 16每个头的维度是模型总维度除以头数。比如模型维度是 5128 个头每个头就是 64 维。2.4 位置编码没有顺序信息的补救方案自注意力有一个特点它对输入的顺序是不敏感的。你把句子里的词打乱算出来的注意力分数是一样的。但语言是有顺序的“我打你”和“你打我”意思完全相反。所以必须额外注入位置信息。Transformer 用的是正弦和余弦函数生成的位置编码直接加到词嵌入向量上。为什么用三角函数因为三角函数有个好性质位置 pk 的编码可以表示为位置 p 编码的线性函数。这让模型能够容易地学到相对位置关系。后来也有用可学习的位置嵌入的比如 BERT 就是这么做的。两种方式各有优劣正弦编码在推理时可以外推到训练时没见过的长度可学习嵌入则更灵活。3. 核心组件细节与实操要点3.1 输入嵌入层把文字变成向量任何 NLP 模型的第一步都是把文字转成数字向量。Transformer 的输入嵌入层包括两部分词嵌入和位置编码。词嵌入可以用预训练的词向量初始化也可以随机初始化然后训练。实践中如果数据量不大用预训练词向量效果会好很多。中文和英文的处理不太一样。英文通常用子词级别的分词比如 BPE 或 WordPiece这样能处理未登录词。中文可以用字级别也可以用词级别。我个人的经验是对于大部分中文任务字级别加上位置编码效果已经不错了而且词表小训练快。如果任务对词边界敏感比如命名实体识别可以考虑用分词工具先切词但要注意分词错误会传播。import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len5000): super().__init__() pe torch.zeros(max_len, d_model) position torch.arange(0, max_len, dtypetorch.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) pe pe.unsqueeze(0) self.register_buffer(pe, pe) def forward(self, x): return x self.pe[:, :x.size(1), :]这段代码就是标准的位置编码实现。注意register_buffer的用法它会把 pe 注册为模型的一部分但不参与梯度更新保存模型时也会一起存下来。max_len 设成 5000 对大部分任务够用了如果你的文本特别长可以调大。3.2 编码器层堆叠带来的表达能力一个编码器层包含两个子层多头自注意力和前馈神经网络。每个子层后面都有残差连接和层归一化。残差连接解决的是深层网络的梯度消失问题层归一化则是让每层的输入分布稳定加速训练。前馈网络其实就是一个两层的全连接网络中间有 ReLU 激活。第一层把维度从 d_model 扩展到 d_ff通常是 4 倍第二层再压回 d_model。这个设计是为了增加非线性表达能力。虽然自注意力已经很强了但它本质上是线性的加权求和前馈网络补上了非线性变换。实操心得层归一化的位置有讲究。原始论文是“后归一化”也就是残差相加之后再归一化。后来很多工作发现“前归一化”更稳定训练更容易收敛。如果你自己搭模型建议先用前归一化也就是在子层之前做归一化。3.3 解码器层生成任务的关键解码器比编码器多了一个交叉注意力子层用来关注编码器的输出。另外解码器的自注意力是带掩码的确保每个位置只能看到它前面的位置不能看到后面的。这是为了在生成任务中保持自回归特性预测下一个词的时候不能偷看答案。掩码的实现很简单就是一个上三角矩阵把未来位置的注意力分数设成负无穷Softmax 之后这些位置的权重就变成 0 了。def subsequent_mask(size): attn_shape (1, size, size) mask torch.triu(torch.ones(attn_shape), diagonal1).bool() return ~mask这个函数返回的是布尔掩码True 表示可以关注False 表示屏蔽。实际用的时候要注意和注意力分数矩阵的维度对齐。3.4 残差连接与层归一化深层网络的稳定器残差连接的思想很简单把输入直接加到输出上。这样梯度可以沿着捷径回传不会因为层数太深而消失。层归一化则是对每个样本的特征维度做归一化不依赖批次大小适合变长序列和小批量训练。这两个组件看起来不起眼但缺了它们Transformer 根本训不起来。我试过去掉残差连接训练损失直接爆炸。也试过把层归一化换成批归一化效果差很多因为批归一化对批次大小敏感而 NLP 任务里批次内序列长度往往不一致。4. 完整实操搭建一个中文文本分类模型4.1 任务定义与数据准备我们用一个中文新闻分类任务来演示。数据是模拟生成的包含体育、科技、财经、娱乐四个类别每个类别 500 条共 2000 条。每条文本长度在 20 到 100 个字之间。import random categories [体育, 科技, 财经, 娱乐] templates { 体育: [球队在比赛中表现出色, 运动员打破了纪录, 教练调整了战术, 球迷们非常兴奋], 科技: [公司发布了新产品, 人工智能技术取得突破, 研究人员发表了论文, 软件开发工具更新了], 财经: [股市今天上涨了, 公司公布了财报, 投资者关注政策变化, 汇率出现了波动], 娱乐: [电影票房很高, 明星参加了活动, 新歌发布了, 综艺节目很受欢迎] } data [] for cat in categories: for _ in range(500): text .join(random.choice(templates[cat]) for _ in range(random.randint(2, 5))) data.append((text, cat)) random.shuffle(data)这个数据是虚构的只是为了演示流程。实际项目中你需要用真实数据并且做好训练集、验证集、测试集的划分。通常按 8:1:1 的比例分。4.2 词表构建与数据加载中文按字切分构建字表。把出现频率低于 2 的字去掉剩下的按频率排序加上特殊标记。from collections import Counter all_text .join([text for text, _ in data]) char_counter Counter(all_text) vocab [pad, unk] [char for char, count in char_counter.items() if count 2] char2idx {char: idx for idx, char in enumerate(vocab)} idx2char {idx: char for char, idx in char2idx.items()} print(f词表大小: {len(vocab)})数据加载用 Dataset 和 DataLoader注意 padding 要统一长度。可以按批次内最大长度 padding也可以固定一个最大长度比如 128。固定长度简单一些但短文本会浪费计算。按批次 padding 效率更高但实现稍复杂。from torch.utils.data import Dataset, DataLoader class TextDataset(Dataset): def __init__(self, data, char2idx, max_len128): self.data data self.char2idx char2idx self.max_len max_len def __len__(self): return len(self.data) def __getitem__(self, idx): text, label self.data[idx] ids [self.char2idx.get(c, 1) for c in text[:self.max_len]] ids [0] * (self.max_len - len(ids)) return torch.tensor(ids), torch.tensor(categories.index(label))4.3 模型定义与参数配置模型维度设 256头数 8编码器层数 4前馈维度 1024dropout 0.1。这些参数是我在类似任务上试出来的平衡了效果和速度。如果你的数据量更大可以适当增加层数和维度。class TransformerClassifier(nn.Module): def __init__(self, vocab_size, d_model256, nhead8, num_layers4, dim_ff1024, num_classes4, dropout0.1): super().__init__() self.embedding nn.Embedding(vocab_size, d_model, padding_idx0) self.pos_encoding PositionalEncoding(d_model) encoder_layer nn.TransformerEncoderLayer(d_model, nhead, dim_ff, dropout, batch_firstTrue) self.encoder nn.TransformerEncoder(encoder_layer, num_layers) self.classifier nn.Linear(d_model, num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): mask (x 0) x self.embedding(x) x self.pos_encoding(x) x self.encoder(x, src_key_padding_maskmask) x x.mean(dim1) x self.dropout(x) return self.classifier(x)这里用了 PyTorch 自带的 TransformerEncoderLayer省去了手写注意力机制的麻烦。注意batch_firstTrue这个参数默认是 False输入维度是 (seq_len, batch, d_model)设成 True 之后变成 (batch, seq_len, d_model)更符合直觉。4.4 训练循环与关键参数优化器用 AdamW学习率 1e-4权重衰减 0.01。学习率预热 400 步然后余弦退火。批次大小 32训练 20 个 epoch。from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR model TransformerClassifier(len(vocab)) optimizer AdamW(model.parameters(), lr1e-4, weight_decay0.01) warmup LinearLR(optimizer, start_factor0.01, total_iters400) cosine CosineAnnealingLR(optimizer, T_max20*len(train_loader)) scheduler SequentialLR(optimizer, [warmup, cosine], milestones[400]) criterion nn.CrossEntropyLoss()训练过程中要监控验证集准确率保存最好的模型。如果验证集损失连续 3 个 epoch 不下降就提前停止。实操心得学习率预热对 Transformer 特别重要。因为刚开始训练时模型参数是随机的注意力分数分布很乱直接上大学习率容易发散。预热让学习率从小慢慢变大给模型一个适应过程。我试过不预热训练损失经常在前几百步就变成 NaN。4.5 训练结果与效果分析在模拟数据上这个模型大概 5 个 epoch 就能到 95% 以上的验证准确率。真实数据上会低一些但通常也能到 85% 到 90%。如果效果不理想可以从这几个方面排查学习率是不是太大或太小、层数是不是不够、词表是不是太小导致太多未登录词、最大长度是不是截断了关键信息。我做过对比实验同样的数据LSTM 需要 15 个 epoch 才能到 85%而 Transformer 5 个 epoch 就到 90% 了。训练速度上Transformer 因为可以并行每个 epoch 的时间只有 LSTM 的三分之一左右。当然Transformer 的参数量更大显存占用也更高这是需要权衡的。5. 常见问题与排查技巧实录5.1 训练损失不下降怎么办这是最常见的问题。先检查学习率太大容易震荡太小收敛慢。可以画一下损失曲线如果损失上下跳动说明学习率太大如果损失几乎不变说明学习率太小。然后检查数据有没有问题比如标签是不是对的、输入是不是全零。最后检查模型初始化PyTorch 默认初始化一般没问题但如果你自己改了初始化可能会出问题。还有一个容易忽略的点是 padding 的处理。如果 padding 的 mask 没设对模型会把 padding 当成正常词来处理注意力会跑到 padding 上影响效果。检查src_key_padding_mask是不是正确传入了。5.2 过拟合了怎么处理Transformer 参数量大在小数据集上很容易过拟合。表现是训练集准确率很高验证集准确率低很多。解决办法有几个增加 dropout、减小模型维度或层数、加权重衰减、用数据增强。数据增强在 NLP 里可以做同义词替换、随机删除、随机交换位置等。还有一个办法是先用大规模语料预训练再在小数据集上微调这就是 BERT 的思路。我自己的经验是如果数据少于 1 万条直接用预训练模型微调效果最好从头训练很难超过预训练模型。如果数据有几十万条从头训练也可以但需要更仔细地调参。5.3 显存不够怎么办Transformer 的显存占用和序列长度的平方成正比因为注意力矩阵是 seq_len × seq_len 的。如果序列很长显存会爆。解决办法有减小批次大小、缩短最大长度、用梯度累积模拟大批次、用混合精度训练。混合精度训练可以把显存占用降低差不多一半速度也能提升推荐优先尝试。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for batch in train_loader: optimizer.zero_grad() with autocast(): output model(batch[input_ids]) loss criterion(output, batch[label]) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()5.4 推理速度慢怎么优化推理时可以用 torch.no_grad() 关闭梯度计算还可以把模型设成 eval 模式。如果对延迟要求高可以考虑模型量化、剪枝、蒸馏。量化是把浮点参数转成低精度比如 int8速度能提升两三倍精度损失通常不大。蒸馏是让小模型学大模型的行为推理时用小模型。还有一个技巧是缓存。如果同一个输入反复出现可以把结果缓存起来。在对话系统里系统提示词部分的注意力键值可以缓存避免重复计算。这个技术在生成式模型里很常用。5.5 常见问题速查表问题现象可能原因排查方法解决方案损失变成 NaN学习率太大打印每步损失降低学习率加预热损失不下降学习率太小或数据有问题检查数据标签和输入调大学习率检查数据验证集效果差过拟合对比训练和验证曲线加 dropout减小模型显存溢出序列太长或批次太大打印显存占用混合精度梯度累积推理慢没关梯度或模型太大测推理时间no_grad量化蒸馏注意力全在 padding 上mask 没设对可视化注意力权重检查 padding mask6. 从这篇出发下一步可以做什么第一篇我们主要把 Transformer 的基本结构和文本分类的完整流程跑通了。但 Transformer 的能力远不止于此。下一篇我会讲怎么用 Transformer 做序列标注任务比如命名实体识别和词性标注那里会涉及输出层对齐和 CRF 层的结合。再往后可以讲文本生成、机器翻译、以及怎么用预训练模型做微调。如果你跟着这篇把代码跑通了建议你试着改改参数看看效果怎么变。比如把层数从 4 改成 6或者把头数从 8 改成 16观察训练速度和准确率的变化。这种动手实验比看十篇论文都管用。我自己就是通过反复调参才慢慢理解了每个参数背后的意义。还有一个建议是不要只盯着准确率。在实际项目里推理速度、显存占用、模型大小同样重要。一个准确率高 1% 但推理慢一倍的模型在线上环境可能根本没法用。所以从开始就要养成综合评估的习惯。最后分享一个小技巧如果你在调试注意力机制可以把注意力权重可视化出来。看看模型到底在关注哪些词很多时候能发现数据里的问题。比如模型总是关注标点符号那可能是你的数据里标点有特殊含义或者分词有问题。这种直观的观察比看损失曲线有用得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑