ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

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,每个头的维度是模型总维度除以头数。比如模型维度是 512,8 个头,每个头就是 64 维。

2.4 位置编码:没有顺序信息的补救方案

自注意力有一个特点:它对输入的顺序是不敏感的。你把句子里的词打乱,算出来的注意力分数是一样的。但语言是有顺序的,“我打你”和“你打我”意思完全相反。所以必须额外注入位置信息。

Transformer 用的是正弦和余弦函数生成的位置编码,直接加到词嵌入向量上。为什么用三角函数?因为三角函数有个好性质:位置 p+k 的编码可以表示为位置 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_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) 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), diagonal=1).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_len=128): 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,前馈维度 1024,dropout 0.1。这些参数是我在类似任务上试出来的,平衡了效果和速度。如果你的数据量更大,可以适当增加层数和维度。

class TransformerClassifier(nn.Module): def __init__(self, vocab_size, d_model=256, nhead=8, num_layers=4, dim_ff=1024, num_classes=4, dropout=0.1): super().__init__() self.embedding = nn.Embedding(vocab_size, d_model, padding_idx=0) self.pos_encoding = PositionalEncoding(d_model) encoder_layer = nn.TransformerEncoderLayer(d_model, nhead, dim_ff, dropout, batch_first=True) 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_mask=mask) x = x.mean(dim=1) x = self.dropout(x) return self.classifier(x)

这里用了 PyTorch 自带的 TransformerEncoderLayer,省去了手写注意力机制的麻烦。注意batch_first=True这个参数,默认是 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(), lr=1e-4, weight_decay=0.01) warmup = LinearLR(optimizer, start_factor=0.01, total_iters=400) cosine = CosineAnnealingLR(optimizer, T_max=20*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 mask

6. 从这篇出发,下一步可以做什么

第一篇我们主要把 Transformer 的基本结构和文本分类的完整流程跑通了。但 Transformer 的能力远不止于此。下一篇我会讲怎么用 Transformer 做序列标注任务,比如命名实体识别和词性标注,那里会涉及输出层对齐和 CRF 层的结合。再往后可以讲文本生成、机器翻译、以及怎么用预训练模型做微调。

如果你跟着这篇把代码跑通了,建议你试着改改参数,看看效果怎么变。比如把层数从 4 改成 6,或者把头数从 8 改成 16,观察训练速度和准确率的变化。这种动手实验比看十篇论文都管用。我自己就是通过反复调参,才慢慢理解了每个参数背后的意义。

还有一个建议是,不要只盯着准确率。在实际项目里,推理速度、显存占用、模型大小同样重要。一个准确率高 1% 但推理慢一倍的模型,在线上环境可能根本没法用。所以从开始就要养成综合评估的习惯。

最后分享一个小技巧:如果你在调试注意力机制,可以把注意力权重可视化出来。看看模型到底在关注哪些词,很多时候能发现数据里的问题。比如模型总是关注标点符号,那可能是你的数据里标点有特殊含义,或者分词有问题。这种直观的观察,比看损失曲线有用得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进