做NLP项目做久了,你会发现一个特别现实的事情:很多人调了好几个月的分类模型,准确率卡在85%死活上不去,换成Bert之后一天就涨到93%。这种差距不是调参能补回来的,而是模型本身理解文本的方式变了。Bert这套深度学习方法,从2018年发布到现在,几乎把NLP领域的各种榜单重新洗了一遍牌。这篇文章我不会去复述论文里的公式,只想从一个做项目的人角度,把Bert的原理、微调流程、踩坑经验一次讲清楚。不管是刚入门深度学习没多久的新手,还是已经跑过不少模型但没系统梳理过Bert的人,应该都能从这里拿走一些能直接用的东西。
1. Bert到底解决了NLP的什么问题
1.1 静态词向量为什么不够用
在Bert出现之前,NLP领域最主流的做法是用Word2Vec这类工具,把每个词训练成一个固定向量。比如"苹果"这个词,无论出现在"苹果很好吃"还是"苹果发布了新手机"里,它的向量都是一模一样的。这个设计有个天然缺陷,同一个词在不同语境下的含义完全不同,但静态词向量根本区分不了。
我举个例子,你在训练一个情感分析系统,样本里出现"这个手机真的很苹果"这样网络用语式的表达,静态词向量会把"苹果"映射到水果那个语义空间,整个句子的向量表示就偏掉了。你当然可以针对业务数据重新训练词向量,但训练语料不够大的时候,得到的向量质量反而更差。而且Word2Vec是窗口式的局部训练,它看不到整个句子的全局结构。
静态词向量的另一个问题是它没有办法处理一词多义和句法结构。一个句子里的否定、转折、指代,这些信息在词向量里都是缺失的。所以当时很多项目组做文本分类,都会在词向量后面接一个双向LSTM,试图用序列模型把上下文信息揉进去。这个思路有效,但LSTM的序列建模能力有限,长距离依赖依然很难捕捉。说到底,问题不在下游任务设计得不好,而是上游的文本表示太弱了。
1.2 双向上下文才是关键
Bert的核心贡献之一,是把"双向"这个概念真正落到了预训练里。早期有一个比较有代表性的预训练模型叫ELMo,它虽然也号称使用了上下文,但实际上是分别训练了一个从左到右的模型和一个从右到左的模型,然后把两个方向的特征拼接起来。拼接本质上是一种"伪双向",它没有在同一个语义空间里同时看到左右两侧的信息。
Bert是怎么做的?它直接用了Transformer里的Encoder结构,Encoder内部的多头注意力机制在计算每一个词的时候,可以同时看到句子里的所有词。也就是说"苹果"这个词的表示,是由它左边所有词和右边所有词的注意力加权共同决定的。这才是真正的双向上下文表示。
你可以把注意力机制理解成一场会议:每个人发言的时候,都会参考会议室里所有人的话,然后决定自己这轮发言里哪些信息更重要。开会的时候你不会只听左边人的意见,也不会只听右边人的意见,而是全部听完再形成自己的判断。Bert里面的每个词就是这么工作的。这个设计带来的直接好处是,模型在预训练阶段学习到的词表示,已经天然携带了整句的语义关系,下游任务只需要在这个基础上做很小的调整。
1.3 预训练加微调这个范式改变了一切
Bert带来的另一个更大的变化,是确立了"预训练加微调"这套工作范式。所谓预训练,就是先用海量无标注文本训练一个通用语言模型,让模型学会词汇、语法、常识这些基础能力。所谓微调,就是拿到这个已经训练好的模型,在你自己标注好的小数据集上再训练一小段时间,让它适配具体任务。
以前做文本分类,你要从零开始训练一个模型,数据和算力都得自己扛。标注数据不够,模型就学不好。现在有了Bert这种预训练模型,你相当于雇了一个已经读过几亿篇文章的实习生,他底子很好,只需要你告诉他你们公司的业务规则,带他练几天他就上手了。哪怕你手里只有几千条标注数据,也能fine-tune出一个效果不错的模型。
这也是为什么Bert发布之后,NLP项目的门槛大幅度降低。原来需要算法工程师花几周时间调参训练,现在一个有一定基础的开发者在开源工具链的帮助下,一天就能把效果不错的分类模型跑起来。后续的RoBERTa、ALBERT、蒸馏模型,本质上都是在预训练加微调这个范式下做优化,没有跳出这套框架。
2. 拆开Bert的模型结构:三个核心模块
2.1 输入侧的三层嵌入:Token + Segment + Position
Bert的输入不是简单地把词丢进去,而是由三部分嵌入向量相加得来的。第一个是Token嵌入,也就是把文本切分成词片后,每个词片对应一个向量。这里要注意,Bert用的分词方式是WordPiece,它会把生僻词拆成更小的片段,比如"unhappiness"可能会被拆成"un"、"happiness"或者更细的单位。这么做的好处是词表不用做得特别大,而且训练时遇到没见过的词也能拆成已知片段来处理。
第二个是Segment嵌入,用来区分两个句子。Bert在做句子对任务(比如判断两个句子是否语义相似)的时候,需要让模型知道哪些token属于句子A,哪些属于句子B。Segment嵌入就是给句子A的所有token加一个向量,给句子B的token加另一个不同的向量。即使你只做单句分类,在输入的时候也要把Segment ID保持为0,不要随便乱传。
第三个是Position嵌入。Transformer本身没有循环结构,它不像LSTM那样天然知道单词的顺序,所以必须额外把位置信息编码进向量。Bert用的是可学习的位置嵌入,也就是说位置0、位置1、位置2这些位置向量是训练出来的,而不是用固定的正弦余弦函数算出来的。这个细节在处理长文本的时候会有影响,后面我会专门说。
这三层嵌入相加之后,会再经过Layer Normalization和Dropout,然后才进入Transformer编码层。标准Bert Base有12层Transformer,12个注意力头,隐藏层大小是768,总参数量大约1.1亿。还有一个Bert Large,24层、16个注意力头、隐藏层1024,参数量大约3.4亿。大部分实际工程项目使用的都是Base版本,因为Large版本显存占用高得离谱,效果提升却没那么明显。
2.2 两个预训练任务如何把语言知识灌进模型
Bert预训练阶段用了两个任务来教会模型理解语言。第一个是掩码语言模型(Masked Language Model,缩写MLM),做法很直接:随机把输入文本中15%的token遮住,然后用模型去预测这些被遮住的词。比如"我喜欢吃[ MASK ]",模型要根据上下文猜出[MASK]是"苹果"。
这个任务和传统的语言模型有很大区别。传统语言模型只能从左到右预测下一个词,而MLM允许模型同时利用左右两侧的信息,这就逼着模型把双向语义融合起来。不过这里有个细节,为了缓解训练和微调之间的差异,Bert并不是把所有被选中的词都替换成[MASK],而是有80%的概率换成[MASK],10%的概率换成随机词,10%的概率保持原样。这样做是为了让模型不能只依赖[MASK]这个特殊符号,还要真正理解上下文。
第二个任务是下一句预测(Next Sentence Prediction,缩写NSP)。模型会拿到两个句子,需要判断第二个句子是不是第一个句子在原文中的下一句。这个任务看似简单,但它让模型学会了句子之间的关系,对问答、推理这类需要跨句理解的任务很有帮助。
后来很多研究认为NSP任务太简单,对模型帮助不大,所以RoBERTa直接把它去掉了,只保留MLM。从实践效果看,去掉NSP确实让模型在多个任务上有所提升。但这些预训练细节对做微调的人来说不需要太纠结,你只要明白一件事:Bert的底层知识是从"猜词"这个游戏里学来的,它拥有非常强的语言能力,你微调的时候真正要做的是让它学会你任务里的输出形式。
2.3 模型的输出该在哪个位置取
很多初学者搞不清楚一件事:微调的时候,Bert输出到底怎么用。这其实取决于任务类型。Bert最典型的输出有两种,一种是每个token位置都有一个向量,也就是序列输出;另一种是整个序列只有一个向量,对应的是[CLS]位置的输出。
[CLS]是Bert在输入最前面加的一个特殊token,它在预训练阶段被训练成"汇总整个序列语义"的标识。做文本分类的时候,最标准的做法就是取[CLS]位置的输出向量,接一个全连接层,映射到类别数。做序列标注任务(比如命名实体识别)的时候,则要取每个token位置的输出向量,分别接上分类层。
我之前见过有人做文本分类,硬是把所有token的输出全部取出来拼在一起再接全连接层,结果模型又慢又难收敛。这个做法不是完全不行,但大多数场景下[CLS]向量已经够用,没有必要自己折腾特征工程。Bert的输出更像是给你提供了一个高质量的通用表示,下游怎么组合,是根据任务需求来定的,不是越复杂越好。
3. 从零微调Bert:一个文本分类项目的完整流程
3.1 环境选型和模型选择
先交代一下我自己的操作环境。我平时用的是PyTorch,配合主流的transformers开源库。这个库封装好了Bert的加载、分词、训练等绝大部分操作,你不需要自己写Transformer结构,也不需要手动下载权重,调用API就可以拿到预训练好的模型。
环境版本方面,Python建议3.8以上,PyTorch版本我觉得1.10以上就够用,transformers版本建议在4.x以上。深度学习框架版本太旧的话,很多预训练模型加载脚本会报错。我遇到过最典型的例子是,transformers 2.x时代加载新版模型权重时经常出现键名不匹配,升级到4.x之后很少再有这种问题。
选择模型时,如果机器显存有限,比如只有6G到8G的显卡,我会优先考虑Bert Base的中文版本。如果显存只有4G左右,建议直接用蒸馏版本,比如MiniLM之类的轻量模型,后面我会展开说。第一次跑通项目,不要贪心用Large版本,先用Base把整个流程走通,再根据效果决定要不要换更大的模型。
3.2 数据预处理:分词、截断与注意力掩码
Bert对输入文本有长度限制,一般是512个token。中文本的512个token大概对应三四百个汉字,因为中文分词后一个汉字通常是一个token。如果你的文本超过这个长度,有几个选择:直接截断、从开头和结尾各取一部分、或者用滑动窗口做片段处理。
文本分类任务里,直接截断前512个token是最常见的做法,虽然粗暴,但大部分关键信息都集中在开头,效果不会太差。我在做长文档分类的时候,先统计了数据长度分布,发现超过512 token的样本只占5%,于是直接截断,效果损失可以忽略。如果你的数据大量超过512,建议考虑滑窗或者先用摘要模型压缩文本,但这些都是后话。
分词之后要把文本转换成模型输入格式,包括input_ids、attention_mask和token_type_ids。input_ids是词在词表中的编号,attention_mask用来告诉模型哪些位置是真实文本,哪些是padding补零的,token_type_ids对应前面说的Segment嵌入。下面这段代码是一个典型的数据预处理流程:
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") def encode_text(text, max_len=128): encoded = tokenizer.encode_plus( text, max_length=max_len, padding="max_length", truncation=True, return_tensors="pt" ) return encoded["input_ids"], encoded["attention_mask"], encoded["token_type_ids"]这里我强烈建议所有样本统一max_length,不要一个批次里有的长有的短。虽然transformers库支持动态padding,但固定长度在数据加载和GPU计算上更省心,梯度更新也更稳定。选择max_length的时候,先看训练数据的长度分布,取能覆盖90%样本的阈值,不要一味选512,因为序列越长,注意力计算的时间复杂度是平方级增长。
3.3 训练循环里的参数细节
微调Bert的时候,有几个参数和训练普通网络很不一样。学习率是最关键的,Bert这种预训练模型已经学得足够好,微调阶段不能再用0.01这种大学习率,一般建议2e-5到5e-5之间。我之前用3e-5做中文分类任务,收敛速度和效果都比较稳定。如果学习率超过1e-4,很容易出现loss震荡,甚至整个模型直接训飞。
优化器建议用AdamW,它是对Adam做权重衰减修正的版本。普通Adam的L2正则和Adam本身的梯度动量的衰减耦合在一起,AdamW把权重衰减单独拿出来,对预训练模型微调更友好。transformers库的Trainer默认也使用AdamW,如果你自己写训练循环,不要漏掉这个细节。
warmup比例也是微调里一个很有效的技巧,通常把前10%的训练步数设置为warmup阶段,学习率从0逐渐升到设定值。这样做可以避免训练一开始就用较大的学习率冲击预训练好的权重,让模型平稳地进入适配过程。batch size建议从16或者32开始,如果显存不够,就降到8,但要注意batch size变了学习率最好也相应调整,一般batch size翻倍,学习率也可以适当调大一点。
一个简化版的训练循环长这样:
from transformers import BertForSequenceClassification, AdamW from torch.utils.data import DataLoader model = BertForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=2 ) optimizer = AdamW(model.parameters(), lr=3e-5, weight_decay=0.01) total_steps = len(train_loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(total_steps * 0.1), num_training_steps=total_steps ) for epoch in range(epochs): model.train() for batch in train_loader: input_ids, attention_mask, token_type_ids, labels = batch outputs = model( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids, labels=labels ) loss = outputs.loss loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad()注意,BertForSequenceClassification这个封装类里面已经替你接好了分类头,只需要指定num_labels。这是最省事的做法,不建议一上来就自己改Bert模型内部结构,先把流程跑通,再谈定制。
3.4 评估与推理
训练完成之后,评估的时候不要只看准确率。文本分类如果类别不平衡,准确率可能会骗人。我在一个二分类项目里,负样本占了85%,模型全部预测成负样本准确率也有85%,看起来不错,实际上正样本一个都没抓到。这种情况下必须看精确率、召回率和F1值,尤其是F1,它同时惩罚漏报和误报,更适合衡量分类效果。
推理阶段用模型的时候,同样要把文本转成tokenizer的输入格式,然后经过model.eval()关闭Dropout和梯度,得到logits之后取softmax或者直接取argmax。还有一个小细节,推理时的max_length必须和训练时保持一致,否则padding行为不一致会导致预测结果不稳定。
4. 微调Bert踩过的五个坑及补救方案
4.1 显存不够别硬刚序列长度
我在刚开始做Bert项目时,最常遇到的问题就是显卡显存爆炸。当时用一张8G显存的卡去训练bert-base,序列长度设成512,batch_size设成16,一跑就OOM。后来我把序列长度降到128,batch_size降到8,训练才跑起来。序列长度、batch_size、模型大小这三者的关系就是一场显存博弈。
如果任务本身不需要长上下文,比如短文本分类,别犹豫,直接设128。很多分类任务的有效信息都在前几十个token里,128完全够用。如果你确实需要长文本,可以考虑梯度累积,把batch_size设成4,每4步累积一次梯度再更新,效果相当于batch_size为16。这个做法不会增加显存占用,只是训练时间会长一点。
还有一个容易被忽略的坑:分词后明明文本只有100个token,但你还是把max_length设成了512,GPU一样会按512去分配显存。所以先统计一下你的数据长度分布,用覆盖大部分样本的阈值,别为了省事全设成512。
4.2 不冻结参数的微调容易灾难性遗忘
Bert预训练模型在通用语料上见过太多文本,如果参数全部放开微调,在很小的数据集上训练大量epoch,模型可能把通用语言能力忘掉,只记得你那几千条样本里的特征。这种现象叫灾难性遗忘。尤其是当你的标注数据只有几百条时,全参数微调很容易过拟合到训练集,验证集效果却很差。
解决办法有几个。一是减少训练轮数,一般情况下微调Bert跑2到3个epoch就够了,不需要像训练普通网络那样跑几十个epoch。二是加早停机制,当验证集F1连续两个epoch不涨甚至下降,就停掉训练。三是如果数据确实很少,可以冻结Bert大部分层,只训练最后两层和分类头。
我自己在某个文本分类场景里,数据只有800条,直接全参数微调跑了5个epoch,训练集F1是0.98,验证集F1只有0.82。后来我改成冻结前8层,只训练最后4层加分类头,训练3个epoch,验证集F1反而涨到了0.88。这个对比说明,参数不是训得越多越好,关键是要让模型在通用能力和任务适配之间找到平衡。
4.3 分类头不是越复杂越好
有些初学者觉得Bert的输出向量768维,后面应该接一个大一点的网络才配得上"深度学习"这三个字。于是搞了两层全连接,中间还加了一个Dropout和一个激活函数,结果训练时loss降不下去。这里的问题在于,Bert的[CLS]向量已经是一个高度抽象的特征,你只需要一个简单的线性层就能完成分类映射。
复杂分类头反而会引入额外的随机初始化和参数更新负担,让微调过程变得不稳定。我做过一组对比实验,同一个数据集上,单层全连接分类头的F1是0.90,两层全连接且中间加ReLU的分类头F1反而掉到0.87,训练时间还多了30%。所以不要画蛇添足,直接用BertForSequenceClassification自带的分类头,最多担心一下它默认的Dropout概率是否适合你的数据规模。
4.4 样本量小时要控制训练步数
样本量小是微调Bert最常见的难题。一个一千条数据的小数据集,模型可能只需要几百个训练步就收敛了。如果训练步数太多,模型会把训练集里的噪声也记住,导致泛化能力变差。
我的建议是,小数据集上先跑3个epoch,记录每个epoch结束后的验证集F1。如果第二个epoch明显好于第一个,第三个epoch持平或下降,那最终模型就用第二个epoch的checkpoint。如果第一个epoch就已经接近收敛,说明你的数据或者任务对于Bert来说太简单了,可以考虑减少学习率。
另外,小数据集上不要过度依赖验证集做超参数调优,因为验证集本身也小,波动会很大。有一种比较实操的做法是分出一部分训练数据做内部验证,先确定一个可靠的学习率和训练步数范围,再跑正式的微调。
4.5 随机种子不固定等于白调
微调Bert涉及很多随机因素:模型权重初始化、数据加载顺序、Dropout等。如果每次训练之前不设置随机种子,你会发现同一个配置跑两次,出来的F1可能差两三个百分点。这种波动在小数据集上尤其明显,甚至会掩盖你调参带来的真实收益。
我一开始没在意这件事,每次训练结果都不一样,还以为是代码写错了。后来在训练脚本开头固定了随机种子,结果稳定了很多。固定随机种子很简单,PyTorch里设置torch.manual_seed(42),如果用了NumPy和Python的random模块,也要一起设置。另外,还要把DataLoader的shuffle关闭或者固定其随机种子,否则数据顺序还是会变。
还有一点,PyTorch在GPU上跑的时候,某些操作不是完全确定的。如果对复现要求特别高,可以设置torch.backends.cudnn.deterministic = True,但会牺牲一点训练速度。做项目的时候,我不会对所有实验都强行复现,但每次对比实验前固定种子是必须的。
5. 从Bert出发:变体与轻量化方向怎么选
5.1 RoBERTa、ALBERT这些变体各自改了什么
Bert发布之后,社区出现了大量改进版本,最常见的几个是RoBERTa、ALBERT和蒸馏模型。RoBERTa的思路很简单,把Bert预训练阶段的很多细节改了:去掉NSP任务,用更大规模的数据,把训练步数加到几十万步,动态改变掩码模式。因为预训练更充分,RoBERTa在很多任务上的效果都优于Bert。但RoBERTa的参数量和Bert差不多,显存占用没有本质改善,我自己做中文场景时,反而没有优先选它,因为相关的中文版本选择比Bert少一些。
ALBERT则是往轻量化和省显存方向走,核心是参数共享。它让Transformer的12层网络共用同一套参数,参数量大幅下降。但ALBERT的推理时间并没有因为参数共享而变快很多,因为它依然要做12层的计算。所以ALBERT省的是存储和一部分显存,不是推理时延,对我的实际项目来说,收益没有想象中那么大。
还有一类变体是针对中文场景的特殊版本,它们主要在预训练语料和分词方式上做了调整。这类模型在中文任务上表现更好,但版本更新频繁,使用前要确认和你的transformers库版本兼容。我的经验是,不要盲目追新变体,效果提升往往来自预训练数据规模,而不是结构上的革命。
5.2 什么时候该放弃大模型
很多人做项目有一个误区,就是默认模型越大效果越好。但实际上,如果你的任务比较简单,比如短文本情感分类、垃圾消息识别,用一个几百兆的Bert Base可能严重浪费资源。工程上不仅考虑效果,还要考虑响应时间、显存和部署成本。
我做过一个在线推理服务,最初用Bert Large,单条请求的推理延迟大约200毫秒,部署需要三张卡才能扛住流量。后来换成了蒸馏版本模型,延迟降到50毫秒,单卡就能部署,F1只掉了0.8个百分点。对业务来说,这个准确率损失完全可接受,但成本和响应速度的改善非常明显。
什么时候应该放弃大模型?我总结了几条:延迟要求在100毫秒以内;显存小于8G;并发请求量高;目标任务对精度提升不敏感。只要命中两条,我就建议转去尝试轻量化模型。最典型的做法是知识蒸馏,用一个训练好的大模型当老师,教一个小模型学它的输出。
5.3 蒸馏和量化的实践经验
知识蒸馏的做法并不复杂。先把大模型在目标任务上微调好,用这个模型的输出概率分布作为软标签,去训练一个小模型。相比于直接训练小模型,蒸馏后的模型通常能保留大模型大部分能力。我自己在一个多分类任务上,小模型的准确率比直接训练提高了将近4个百分点。
蒸馏层面还有一个更省事的替代方案,就是直接使用社区里预训练好的蒸馏模型。这类模型已经有人用通用的蒸馏方法压缩好了,你不需要自己训练老师模型,只要加载之后照常微调就行。这些蒸馏模型效果通常不如Bert Base,但对于很多场景已经够用。
量化则是另一个优化方向。把模型权重从32位浮点转成16位浮点,模型体积直接减半,推理速度也能提升一些。如果项目允许,甚至可以尝试8位整型量化,体积进一步缩小。但量化之后要注意验证效果是否回落,尤其是模型在小数据集上微调时,量化带来的精度损失可能比较明显。
最后再分享一个关于Bert的实用心得
我踩过这么多坑之后,最大的体会是:Bert是一个极其强大的工具箱,但它不是魔法,决定项目成败的往往不是模型结构,而是你有没有把数据处理好、有没有把微调流程中的细节控制住。拿到一个NLP任务,我现在的习惯是先拿一个小模型或蒸馏模型跑通基线,然后用Bert Base做对比实验,最后根据效果和成本决定线上用哪个。这个流程比一开始就上大模型要高效得多。
还有一个很多人没注意的小技巧,微调之前先检查一下标签分布和数据长度分布,这两个东西决定了max_length、batch size和评估指标的选择。花十分钟做这个统计,能省后面两天的折腾。希望这篇文章能帮你在用Bert做深度学习的路上少走弯路,如果你在实践里也遇到过其他有意思的问题,不妨按自己的思路再做一轮实验,很多坑只有自己踩过才能记得住。