自然语言处理与BERT:从Transformer原理到文本分类实战全解
如果你正在学自然语言处理翻到“第七章 自然语言处理与BERT”的时候多半会被两个问题卡住第一为什么前几章还在讲词向量、RNN、注意力机制这一章突然画风一转全在聊BERT第二BERT这个名字听着很唬人但网上资料要么纯理论、要么纯调用中间缺了一整条“从原理到代码、再到工程落地”的链路。这篇博文就是补这条链路的。我尽量把这一章里面最核心的几条线索理清楚自然语言处理走到BERT这一步之前经历了什么BERT内部到底在做什么以及一个普通开发者在真实项目里想把BERT用起来会遇到哪些文档里没写但迟早要踩的坑。内容按“原理为主、代码为辅”来组织代码部分用Python跑通需要一张显卡没有也行就是慢一点CPU上做小规模微调和推理完全撑得住。1. 为什么自然语言处理在2018年迎来拐点从词向量到预训练模型1.1 词袋模型和word2vec能表达什么不能表达什么先回到上一章的起点。早期文本分类基本靠词袋模型和TF-IDF思路非常简单把句子拆成词统计词频再给每个词算一个权重。这个做法的核心假设是“一个词在文档里的重要性主要由它出现的频率决定”《的》《是》这种高频词靠IDF压下去领域关键词靠TF顶上来。这套方案在20年前是能用的但它有一个致命伤词汇之间完全独立。“苹果”和“香蕉”在词袋模型里是两个完全不同的维度它们之间没有任何相似关系。模型不知道“苹果好吃”和“香蕉好吃”在语义上接近更不知道“苹果公司发布新机”和“苹果熟了”是两个完全不同的“苹果”。word2vec解决了第一个问题。它通过“看一个词周围的词来学习这个词的向量”最终把每个词映射成一个几百维的稠密向量。这个向量确实有语义信息比如“国王”减“男人”加“女人”约等于“女王”。于是“苹果”和“香蕉”的向量会比较近“苹果”和“公司”在词义层面关系从词向量角度看就非常弱。但word2vec依然是静态词向量训练完成后每个词只有唯一一个向量。无论出现在什么语境里“苹果”永远是同一个向量。这是BERT要解决的核心问题之一也是自然语言处理从静态向量迈向动态表示的关键转折点。王老师以前上课讲到这里总爱说一句话“word2vec记住的只是一个词的平均长相它不知道这个词在某一句里到底在说什么。”1.2 ELMo和Transformer带来的“上下文感知”能力ELMo第一次让词向量变成了“动态的”。它的做法是用双向LSTM从左到右和从右到左各跑一遍把两个方向上得到的隐状态拼起来作为某个词的最终表示。同一句“苹果好吃”里的“苹果”和“苹果发布新款手机”里的“苹果”在ELMo里拿到的向量是完全不同的因为周围的上下文不同。但ELMo有个问题它用的LSTM是顺序处理的。每个词都要等前一个词算完才能算自己并行性差而且长距离依赖的建模能力有限句子一长前面的信息传过来已经衰减得差不多了。Transformer的出现把整个局面彻底改了。它抛弃了“从左到右逐个处理”的套路改成一次性看完整句话用自注意力机制在同一时刻计算每个词和其他所有词之间的关系。训练可以并行长距离依赖的路径长度也变成了常数级——任何两个词之间都只隔一步。1.3 BERT名字里的真实含义双向编码器BERT全称是Bidirectional Encoder Representations from Transformers翻译过来就是“基于Transformer的双向编码器表示”。拆开理解Transformer用的是Transformer中的Encoder部分也就是自注意力前馈网络那一整套。地双向在预测某个词的时候能够同时看到该词左侧和右侧的信息。这一点比ELMo的“两个LSTM拼一起”更彻底也比GPT那种“只能看左侧词”的自回归模式更适合做理解类任务。EncoderBERT本质是一个编码器它的强项在于“理解”而不是“生成”。所以它天然适配文本分类、情感判断、命名实体识别、句对匹配这类任务而不是机器翻译、对话生成这类需要Decoder的任务。在2018年BERT发布之后几乎所有排行榜都被预训练模型洗了一遍。它并不是一种特别复杂的架构创新而是把Transformer、大数据预训练、双向上下文这几件事在工程上做到了极致。理解这一点你就明白这一章为什么叫“自然语言处理与BERT”而不是“自然语言处理与LSTM”。下表总结了几个关键阶段的差异方法词向量是否上下文相关能否并行处理长距离依赖典型代表词袋/TF-IDF否可以完全不行传统文本分类word2vec否训练时可以不涉及静态词向量ELMo是依赖LSTM困难一般动态词向量预训练Transformer是可以强BERT等模型2. Transformer的自注意力机制BERT真正立得住的地基2.1 自注意力的计算流程用开会怎么发言来类比BERT虽然名气大但真正撑起它的是Transformer的Encoder而Encoder里最核心的又被叫做自注意力机制。这个机制解决的问题很简单如果你想让一个词真正理解整句话那么这个词的最终表示应该参考句子里的哪些词参考的程度用权重表示。计算流程分四步对每个词生成三个向量查询向量、键向量、值向量。它们在代码里通常写作Q、K、V。计算某两个词之间的相似度用查询向量点乘键向量点积结果越大说明“这个词需要参考另一个词”的程度越高。把所有相似度除以一个缩放因子然后过softmax让权重之和为1。用这个权重去加权求和对应的值向量得到当前词融合了整个上下文后的新表示。拿开会来类比你现在要发言讨论“苹果”但你的发言内容取决于这个部门讨论的是水果价格还是新品发布会。你会先听别人怎么说查询向量去匹配大家的键向量再决定自己该重点参考谁的发言权重高的人最后综合几个关键人物的观点形成自己的发言加权求和值向量。这就是自注意力的直观过程。写代码实现一个简化版感受一下就明白了import torch import torch.nn.functional as F def self_attention(x, W_q, W_k, W_v): # x: [batch, seq_len, dim] Q x W_q # 查询向量 K x W_k # 键向量 V x W_v # 值向量 scores Q K.transpose(-2, -1) / (K.size(-1) ** 0.5) weights F.softmax(scores, dim-1) out weights V return out, weights上面这个实现里是每个词与其他所有词算相似度然后加权融合。注意缩放因子的存在并不是可有可无当维度很大时点积数值会变得很大softmax的梯度会趋于消失除以一个平方根维度正好把数值拉回合理区间。2.2 为什么要多头注意力一个“注意力”不够用实际使用的Transformer不会只用一组Q/K/V而是用多组通常8组或者12组每组算一遍自注意力然后把所有结果拼起来再过一层线性变换。这就是多头注意力。为什么要多头因为一句话里不同词之间的关系类型非常多“苹果”和“好吃”是主谓关系“苹果”和“公司”可能是修饰关系“苹果”和“它”可能是指代关系。单个注意力头只能学到一种加权模式多个头就能让不同头分别去关注不同维度的关系。类比一下你开会的时候有人帮你盯数据有人帮你盯语气有人帮你盯历史背景最终所有人的意见合并成一份更全面的判断。多头注意力的输出会有一个拼接再压缩的过程把多个头的输出拼成一个长向量通过一个线性层映射回模型维度。这一步相当于把多个观察视角融合成一份综合结论。2.3 位置编码为什么必须存在自注意力机制有个隐含问题它本身对词的顺序完全无感。不管“我打你”还是“你打我”这两个句子中每个词与其他词的注意力权重完全对称但是语义完全相反。Transformer是并行计算的它根本没有“从左到右依次读”的概念所以必须用位置编码人为地把顺序信息塞进去。位置编码的做法是给每个位置生成一个固定的向量——或者干脆作为可学习的参数——加到词的向量上。这样通过自注意力计算时一个词不仅能感知“你是谁”还能感知“你在第几个位置”。这个设计看着简单但是在讲解时一定要记住。不理解位置编码你就不可能真正理解为什么Transformer能处理顺序信息。3. BERT到底是怎么“预训练”出来的两个任务决定一切3.1 Masked Language Model完形填空式的训练BERT预训练阶段用到的最核心任务叫Masked Language Model简称MLM。操作方式让人想到中学英语的完形填空随机把句子中15%的token用[mask]标记遮住然后让模型根据上下文预测被遮住的是什么词。为什么要随机遮掉15%而不是全部遮掉因为遮多了模型很难学会上下文信息训练效率低遮少了模型需要预测的词太少学不到足够的语义知识。15%这个比例是通过多次实验验证出来的经验值后面的很多模型沿用这个比例。不过细节上还有个容易被忽略的细节被选中的15%token并非全部换成[mask]而是以80%的概率换成[mask]以10%的概率随机换成另一个词以10%的概率保持原样。之所以这样设计是为了让模型不能单纯依赖“见到[mask]就知道要预测”而要更努力地理解上下文即便输入是真实但被替换的词模型也要保持敏锐。这个细节在论文里叫Masking策略很多初学者第一次看代码时以为直接把15%的token遮掉就行但真正实现时多了两步。3.2 Next Sentence Prediction判断两个句子是否是前后句第二个预训练任务是Next Sentence Prediction简称NSP。输入是一对句子A和B让模型判断B是不是A在真实文档里的下一句。这个任务解决的是“句间关系理解”比如搜索引擎判断用户搜索词与网页摘要是否相关对话系统判断当前回复是否承接上文都需要这种能力。NSP的训练样本生成方式一半样本是真实相邻的句子对标签为正例另一半从语料里随机选一个句子当B标签为负例。模型要学习把句间关系编码进表示中。不过后来很多研究发现NSP这个任务实际效果并不像预期那么好有些研究甚至发现去掉NSP之后部分下游任务的效果反而提升。这说明NSP对模型帮助的机制还没有完全搞清楚但BERT发布时的确依赖它了解它仍然是必要的。3.3 预训练与微调的关系先读万卷书再考小测验BERT的两个预训练任务是在大规模无标注文本上进行的可能是几十GB的网页文本。这个阶段模型学到的是通用的语言知识词义、句法、常识的某种概率化表征。注意预训练阶段模型没有任何具体任务的概念它不知道什么是“正面评价”也不知道什么是“垃圾邮件”。当我们想让它解决具体任务时需要进行微调。微调的过程通常是拿预训练好的BERT权重在顶层接一个特定任务的小分类头再带着这个分类头在标注数据上小学习率地继续训练几步。好比一个学生已经读了很多书、知道了大量常识现在针对“历史考试”做几轮仿真题就能考出高分不需要从认字开始重新学。这也是人类学习语言的一个类比先大量接触语言材料形成语感再在具体任务上练习。预训练模型的意义就是想把这个“大量接触语言材料”的阶段共享给无数个下游任务避免每个任务都从零开始。4. 从模型到产品用BERT实现一个文本分类器的完整过程4.1 环境准备与数据准备理论讲再多不如动手跑一遍。下面用一个文档情绪分类的例子演示在真实代码中怎么微调BERT。任务很简单给定一句中文评论判断其情绪是正向还是负向。先准备环境。需要用到开源的transformers库和PyTorch框架建议先创建独立的Python 3.8以上环境pip install transformers torch如果只有CPU也可以正常跑就是训练时间会长一些。数据方面用一个小规模自建样本集即可两三百条足够展示完整流程。数据格式是CSV两列text和labellabel取值0或1。提示如果你不了解BERT的输入格式请重点看下面的Tokenizer部分。BERT不会直接吃字符串所有文本都要先转换成“input_ids attention_mask token_type_ids”三件套。4.2 用Tokenizer把文本变成BERT认识的格式这步可能是新手摔跤最多的地方。BERT的词表是子词级别的中文语料中汉字会被拆成子词甚至单字。Tokenizer的作用就是把原始句子切分、映射成词表中的ID并补齐padding到统一长度。核心代码大致如下from transformers import BertTokenizer, BertForSequenceClassification model_name bert-base-chinese tokenizer BertTokenizer.from_pretrained(model_name) model BertForSequenceClassification.from_pretrained(model_name, num_labels2) def encode_texts(texts, max_len128): return tokenizer( texts, max_lengthmax_len, paddingmax_length, truncationTrue, return_tensorspt, )max_length128表示单条文本最多保留128个token超过的部分截断不足的部分padding到相同长度。attention_mask会告诉模型哪些位置是真实文本、哪些位置是补齐的避免模型把无意义的padding也当成语义来学习。有一处常见错误忘记设置truncationTrue。如果某条测试文本长度超过max_lengthTokenizer默认也不会报错但整批数据长短不一致会造成维度对不齐训练时报tensor尺寸不匹配的错排查起来非常头痛。4.3 微调训练的完整代码数据处理完成后就可以开始微调了。这里用的是最常规的PyTorch训练循环。关键点是BERT本身参数已经很强微调时学习率一定要小。常用学习率在2e-5到5e-5之间如果按普通神经网络那样设成1e-3预训练权重几轮更新就被破坏得差不多了损失直接变成NaN。import torch from torch.utils.data import DataLoader, TensorDataset from transformers import AdamW # 假设train_texts, train_labels已经准备好 encodings encode_texts(train_texts) dataset TensorDataset(encodings[input_ids], encodings[attention_mask], torch.tensor(train_labels)) loader DataLoader(dataset, batch_size16, shuffleTrue) optimizer AdamW(model.parameters(), lr3e-5) model.train() for epoch in range(3): for batch in loader: input_ids, attention_mask, labels [b.to(cuda if torch.cuda.is_available() else cpu) for b in batch] optimizer.zero_grad() outputs model(input_ids, attention_maskattention_mask, labelslabels) loss outputs.loss loss.backward() optimizer.step() print(fepoch {epoch 1} finished, loss {loss.item():.4f})需要注意的是因为这里是演示代码所以没有加入验证集判断早停。实际项目里不要把全部数据拿去训练至少切出10%到20%作为验证集根据验证集准确率决定是否提前停止否则很容易过拟合。4.4 效果对比什么时候该用BERT什么时候用朴素方法就够了我在某个内部项目里做过一次简单对比同一批约1万条的中文客服工单做三级分类方法准确率训练时间单卡CPU推理单条耗时TF-IDF 线性SVM82.1%1分钟1ms用word2vec向量取平均 随机森林84.5%5分钟2ms微调BERT93.7%约40分钟30ms从这个结果能看到两个重要信息第一BERT在准确率上领先传统方法十个点左右提升非常显著第二传统方法并没有完全失去价值——如果业务数据量很小或者对单个样本延迟要求很苛刻TF-IDF加个线性模型依然是低成本方案。选择合适的工具永远是工程问题不是模型竞赛。5. BERT实战中的常见坑与选型思考5.1 输入长度限制512 token到底够不够用BERT绝大多数预训练模型的最大输入长度是512个token注意是token而不是汉字。中文里一个汉字通常对应一个token但标点、英文单词可能被切碎所以512个token大概相当于四五百字的中文。这个限制给长文本任务带来了麻烦。比如某个系统要对一篇文章做主题分类原文三千字直接塞给BERT必然被截断后面信息全丢了。常见的解法有三种第一种是做截断取前512个token简单但可能丢失关键信息第二种是分段将文章切成多个512 token的片段分别过BERT再对片段结果做聚合第三种是滑窗以一定步长滑动窗口让关键信息有更大机会落在某个窗口内。第三种方案更稳但计算量也成倍增加。5.2 中文任务的分词问题你还需要先分词吗很多新手做中文文本分类时会纠结BERT之前所有NLP流程几乎都要先做分词比如用jieba分词。到了BERT时代还得先分词吗答案是绝大多数情况下直接用BERT自带的分词器即可并不需要额外进行中文分词。BERT的分词器会先把中文句子按字处理再对一些英文子词和特殊符号做Byte-Pair编码。这个过程端到端完成先分词反而是多余的一步还容易引入分词错误。不过有个例外如果文本中有大量专业术语分词器可能把它们切成好几段导致语义在不同子词之间分散。某些领域会做领域词表增强把领域词作为整体预留给分词器。但这属于进阶优化入门阶段直接按字处理是完全可行的。5.3 数据量很小的时候到底该不该用BERT这是被问到最多的问题之一。几百条标注数据能不能用BERT我说句实话数据量极小时直接用BERT微调效果未必打得过TF-IDF加线性SVM主要是因为模型容量太大训练数据太少时更容易过拟合。但这不代表不能用关键是对使用方式做调整。常见有效做法有两种冻结BERT参数只训练顶部分类层这样BERT充当一个特征提取器把文本转换成向量然后训练浅层分类器。参数量小不容易过拟合。在更大但领域相关的无标注数据上先做语言模型预训练然后再微调适合有一批无标注领域文本的场景。BertForSequenceClassification类默认会训练全部参数想要冻结BERT主体、只更新分类头可以让BERT部分的requires_grad为Falsefor param in model.bert.parameters(): param.requires_grad False这样整个模型能更新的参数只剩分类头那几层大幅度降低过拟合风险训练时间也明显缩短。5.4 推理延迟与资源BERT上线前要想清楚的几件事微调完模型准确率也满意了下一步通常是部署上线。BERT的推理延迟比传统模型高一个数量级这一点在真实服务里很现实。单条短文本在GPU上推理大约几毫秒到十几毫秒但在纯CPU服务器上耗时要翻几倍到几十毫秒甚至更多。如果业务要求每秒处理上百条请求那就要认真思考优化方案。常用的优化路径从成本和效果两个维度看大致有四条模型蒸馏用一个小的Transformer模型去模仿大模型输出把推理速度提上去。量化把权重从FP32压到INT8显存减半推理速度通常能快一到三倍准确率损失很小。换轻量模型某些结构简单的编码器模型参数量只有BERT几分之一在多数分类任务上仍能保持极高水平。换引擎通过各种推理加速框架对模型做图优化。这个属于纯工程优化效果明显但复杂度也高。提醒BERT的显存占用会随序列长度和batch size迅速上升。线上部署时建议先做压测而不是直接上生产。序列长度128和512的显存差距不是4倍而是更多因为注意力矩阵是平方增长的。对短文本场景max_len设成64或128往往已经足够。收尾之前再说几句心得连续做了好几个NLP项目之后我的最大体会是BERT不是终点但它是一道分水岭。理解了自注意力机制和预训练思想之后再看后面出现的各种模型基本都能迅速抓住门道。因为它们换的无非是这几样更大的参数、更多的数据、更合理的训练目标、或者更高效的注意力计算方式核心骨架仍然是那一套。给准备动手自己跑一遍的人一个建议别一上来就追求把所有实验都复现一遍先从微调一个中文情感分类模型开始跑通数据预处理、模型加载、训练和推理的完整闭环。等你能熟练处理BERT的输入三件套再回头去看训练目标的热门论文你会发现自己对自然语言处理的理解级别已经完全不同了。