BERT微调实战:从数据准备到文本相似度计算全流程
简介面向自然语言处理初学者与工程师的Bert预训练模型微调实践项目聚焦文本相似度/匹配任务。资源基于蚂蚁金服文本匹配数据集提供完整的fine-tune训练与测试脚本可帮助读者快速掌握基于Bert的句子对相似度计算流程并可直接替换数据迁移到自己的业务场景。压缩包共50个文件以30个Python脚本和13个pyc编译文件为主辅以txt说明、csv训练数据、json及index/meta等模型文件整体约2.77MB结构清晰便于按模块查阅。包含项目配置、依赖声明、测试样例及基于pytorch_pretrained_bert的模型封装适合刚接触预训练模型的读者边跑边学。目前已有480人学习/下载作为入门级微调案例兼顾完整性与易用性通过实际操作能理解数据预处理、模型加载、微调训练、评估测试等关键环节并借鉴其代码组织方式用于自身实验。1. 为什么直接拿BERT向量算相似度会翻车这个实践的真正难点很多人第一次拿BERT预训练模型做文本相似度都会以为把两句话分别丢进模型、取个句向量、再算余弦就能出结果。我也是这么踩过来的但真实情况是不做fine-tune池化出来的语义向量做匹配效果未必比TF-IDF好。预训练语言模型学会的是通用表达不是专门为“比对两句话”设计的。标题里的重头戏也就是这里用标注好的相似/不相似句子对对BERT做几轮有监督微调把模型能力对齐到文本相似度任务上。这个流程适合已经会写PyTorch、但没完整跑过一次预训练模型微调的工程师。跟着做能跑通数据准备、微调、打分和排查并搞清楚哪些参数值得动、哪些坑会反复出现。2. 先立住理论两个任务范式以及为什么BERT是相似度的默认起点2.1 相似度不是“像不像”两个任务模板决定模型输出文本相似度落到模型上有两种完全不同的做法。第一种叫cross-encoder把两句话按“[CLS] 句子A [SEP] 句子B [SEP]”拼成一个序列喂给BERT输出层接一个标量分数。第二种叫bi-encoder两句话分别过BERT取每条句子的向量表示再算余弦相似度。这两种写法在标题里都归在“fine-tune计算文本相似度”的大类下但任务模板完全不同。cross-encoder因为两个句子在自注意力里能互相看到语义交互最充分准确率通常更高代价是推理时要两两组合一次只能算一对。bi-encoder把句子预先编码成向量线上只需要存向量、做向量检索吞吐量要高一个数量级适合大规模召回。初学建议从cross-encoder开始先把流程跑通后面再优化成bi-encoder。对应到代码选择就写在模型类上。cross-encoder用AutoModelForSequenceClassificationnum_labels设为1或2bi-encoder用AutoModel自己接池化层。很多教程把这两类混在一起讲导致新手拿分类模型的输出去算余弦分数怎么都不对。先确认你要的是“句子对打分”还是“句子向量匹配”后面的loss、评估指标和部署方式全部会跟着变。2.2 为什么BERT是默认起点模型选型清单BERT的核心设计其实是一句话用双向Transformer做预训练语言模型出自J. Devlin、M.-W. Chang、K. Lee等人发表的论文“BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding”。双向意味着每个token在编码时能看到上下文两侧的信息这个特性天然适合相似度比对——一个词的意思必须看周围词才能定。对比几类方案。Word2Vec这类静态词向量是上下文无关的同一个“苹果”在“苹果熟了”和“苹果手机”里向量相同做相似度必然出错。ELMo虽然是双向但只是浅层拼接交互深度不够。GPT系是单向语言模型适合生成对匹配类任务不占优势。RoBERTa在BERT基础上改动了训练策略和动态掩码效果通常更好中文场景里也有成熟的中文预训练模型可选但任务的fine-tune思路完全一致。选型时我的建议是先跑通bert-base-chinese再换chinese-roberta-wwm-ext做对比。一方面bert的权重和资料最多出了问题容易搜到答案另一方面换模型对代码的改动只是改一个model_name字符串越早建立“模型可以替换”的认知越好。别一上来就追超大模型文本相似度这种任务标注数据质量对结果的影响远大于模型尺寸从110M涨到340M带来的收益。2.3 输出层和loss的搭配回归、二分类、对比学习模型结构定了输出层怎么设计直接决定训练能否收敛。句子对相似度最常见的三类标注方式对应三种做法。第一类是连续分数比如0到5分或0到1分用单输出加MSELoss做回归。BERT输出层一个神经元过sigmoid压到0到1和人工分数算均方误差。第二类是二分类标“相似/不相似”用两个神经元加CrossEntropyLoss或者单神经元加BCEWithLogitsLoss。第三类是三元组或对比学习构造anchor、positive、negative三个句子用ContrastiveLoss拉近正例、推开负例适合无标签场景做向量对齐。我的经验是有标注数据优先回归和二分类评估直白、调试省心也比对比学习容易收敛。对比学习适合向量检索场景需要额外调温度参数新手容易在温度上翻车。标题里既然写的是fine-tune默认你有监督标签选回归或二分类就好。二分类在数据不好标连续分时更实用因为标注员对“像不像”的判断比“有多像、打几分”的判断可信得多。我在项目里一般先用二分类跑通再按业务需要改回归输出。3. 数据准备与最小可运行代码从分词到跑通一个epoch3.1 数据集怎么选STS-B、LCQMC还是自己标微调本质是“用任务数据把预训练模型再掰一下”数据决定了模型能力的上限。公开数据集里最常被提到的是STS-B英文语义文本相似度基准和LCQMC中文问句匹配二分类。STS-B标签是0到5的连续分数评估用Spearman相关系数LCQMC标签是0/1评估用准确率或F1。数据集语言标签评测指标适合验证STS-B英文0~5连续分Spearman回归输出LCQMC中文0/1Accuracy / F1二分类输出这两个集子用Hugging Face的datasets库可以直接加载但真实业务往往是领域内的句子对公开数据只能帮你验证流程不能代替领域数据。我一般会先挑500到1000条业务真实句子对让标注员打标标完做一轮一致性抽检把明显标反的样本清掉。这步做扎实了后面模型的提升比调参明显得多。3.2 用transformers加载预训练模型最小代码from transformers import AutoTokenizer, AutoModelForSequenceClassification model_name bert-base-chinese tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForSequenceClassification.from_pretrained( model_name, num_labels1 )这里的AutoTokenizer负责把中文句子切成token。BERT中文词表是字级别的“今天天气不错”会被切成“今”“天”“天”“气”“不”“错”这样的字token不需要提前分词。AutoModelForSequenceClassification在BERT顶部加了一个分类头num_labels1表示输出一个连续值对应回归任务。如果做二分类改成num_labels2即可。第一次运行时transformers会下载模型权重和词表到本地缓存目录网络正常的话会自动完成。如果你本地有多个环境注意确认transformers和torch的版本兼容版本差距过大时from_pretrained有时会报错最常见的是Some weights of the model checkpoint were not used。这个警告不致命真正要注意的是词表是否对齐如果把中文模型和英文tokenizer混用训练loss会一直在高位下不去。3.3 一个epoch的训练流程与参数说明数据封装成Dataset然后进DataLoaderimport torch from torch.utils.data import Dataset, DataLoader class SimDataset(Dataset): def __init__(self, pairs, labels, tokenizer, max_len128): self.pairs pairs self.labels labels self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.pairs) def __getitem__(self, idx): a, b self.pairs[idx] enc self.tokenizer( a, b, truncationTrue, paddingmax_length, max_lengthself.max_len, ) return { input_ids: torch.tensor(enc[input_ids], dtypetorch.long), attention_mask: torch.tensor(enc[attention_mask], dtypetorch.long), label: torch.tensor(self.labels[idx], dtypetorch.float), }注意tokenizer的第二个位置参数传的是b在transformers中两个文本会被按“[CLS] a [SEP] b [SEP]”的方式拼接token_type_ids用来区分句子A和句子B。paddingmax_length把batch内所有样本pad到同样长度方便PyTorch组batchtruncationTrue负责把超长句子截断。这里有个容易被忽略的点token_type_ids不是必须传给模型的因为AutoModelForSequenceClassification默认会从input_ids重新生成如果你在手工构造输入时传了不一致的token_type_ids反而可能出问题。优化器和调度器定义from transformers import AdamW, get_linear_schedule_with_warmup optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) total_steps len(dataloader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps, ) loss_fn torch.nn.MSELoss()训练循环device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) model.train() for epoch in range(3): for batch in dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[label].to(device) logits model(input_idsinput_ids, attention_maskattention_mask).logits.squeeze(-1) loss loss_fn(logits, labels) loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad()参数说明按经验给几个。lr2e-5是BERT微调的常规起点比普通网络小一两个数量级因为预训练权重已经接近最优学习率太大会把学到的知识直接冲掉。weight_decay0.01约束分类层参数防止过拟合。warmup先预热再衰减让前几步不把预训练参数冲乱。max_len128对短句够了句子特别长时再调到256显存紧张就64起步。整个流程核心就这些跑通一个epoch后看loss有没有下降趋势再决定是动数据还是动参数。4. 计算相似度三种输出方式的边界、参数与打分含义4.1 句子对分类cross-encoder的打分代码微调完成后的打分函数长这样def predict_score(a, b): enc tokenizer(a, b, return_tensorspt, truncationTrue, max_length128) enc {k: v.to(device) for k, v in enc.items()} model.eval() with torch.no_grad(): logit model(**enc).logits.squeeze(-1) return torch.sigmoid(logit).item()这里return_tensorspt让tokenizer直接返回PyTorch张量少一步类型转换。model.eval()切换模型到推理模式关闭dropout这一点如果漏了你会发现同样的输入每次打分都有一点随机波动。torch.no_grad()告诉PyTorch不用为打分过程构建计算图省显存也提速。批量打分的写法不太一样因为batch内句子长度不同需要先pad到同一长度。transformers的tokenizer本身支持传入两个列表def predict_score_batch(pairs): text_as [p[0] for p in pairs] text_bs [p[1] for p in pairs] enc tokenizer( text_as, text_bs, return_tensorspt, truncationTrue, paddingTrue, max_length128, ) enc {k: v.to(device) for k, v in enc.items()} model.eval() with torch.no_grad(): logits model(**enc).logits.squeeze(-1) return torch.sigmoid(logits).cpu().tolist()paddingTrue会在batch内部按最长那条句子pad而不是固定pad到128。这个写法的好处是短句不浪费算力坏处是batch内部长度差异大时GPU效率反而下降因为所有样本都要算到最长那条的长度。显存充足时固定paddingmax_length在某些场景下速度更可控。4.2 句向量方案bi-encoderCLS还是mean pooling如果业务需要线上对百万级句子做召回cross-encoder逐个比对算不过来这时候要改成bi-encoder。用原始BERT把每个句子分别编码成向量常见做法是取[CLS]的输出或者对最后一层所有token做平均池化。CLS向量并不天然适合做相似度很多实践里mean pooling比CLS更可靠。def embed_text(text): enc tokenizer(text, return_tensorspt, truncationTrue, max_length128) enc {k: v.to(device) for k, v in enc.items()} with torch.no_grad(): outputs model(**enc) return outputs.last_hidden_state.mean(dim1).squeeze() def cosine_score(vec_a, vec_b): return torch.nn.functional.cosine_similarity(vec_a, vec_b, dim0).item()注意mean(dim1)是对batch内所有token取平均包括[CLS]和[SEP]以及pad位置的向量也一起算进去了。严格来说pad位置的向量是无效信息严谨的写法要先用attention_mask排除掉pad位再取平均。如果句子长度都不长、pad占比小这个误差可以接受一旦句子长短差异很大建议用masked mean即按attention_mask把pad位的隐藏状态置零后再做均值。如果你在训练时就确定用bi-encoder那fine-tune的loss要用对比学习而不是交叉熵否则向量空间没有被按相似度校准。训练时用cross-encoder、推理时用bi-encoder是我见过最多的误用会直接导致线上分数不可用。4.3 阈值怎么定别拍脑袋去验证集上找分类式相似度最终是一个0到1的分数判断“像不像”得有一个阈值。大多数教程默认0.5但正负样本比例和模型输出分布会把它顶偏。我会在验证集上扫描阈值from sklearn.metrics import f1_score scores [] # 模型在验证集上打出的分数 labels [] # 验证集真实标签0或1 best_th 0.5 best_f1 0.0 for th in torch.linspace(0.1, 0.95, 86): preds (torch.tensor(scores) th).long() f1 f1_score(labels, preds) if f1 best_f1: best_f1 f1 best_th th.item()扫描范围从0.1到0.95步长约0.01。为什么不用默认0.5因为相似度任务里正负样本比例经常严重失衡模型输出分数整体偏高或偏低是常态。阈值往高调可以降低误召回往低调可以提高召回率具体往哪边调取决于业务是宁可错报还是宁可漏报。把best_th和best_f1打印出来你会对模型能力有更具体的认知。4.4 分数分布不对劲先看直方图再做温度缩放如果所有分数都集中在0.5附近或者全堆在0.99附近先别急着改模型。把验证集输出画成直方图你会立刻发现问题集中在中间说明模型区分度不够集中在两端说明输出层过自信。标注偏中性时会出现前者正负样本简单到一眼可分的玩具数据集里会出现后者。对二分类模型可以给logit除以一个温度系数再算sigmoid温度系数让分数分布拉开或压拢。温度大于1时logit被压小sigmoid输出向0.5靠拢温度小于1时输出向两端推。温度系数的取值同样在验证集上按F1来找和阈值扫描一样属于上线前必做的校准步骤。温度缩放改变的是置信度分布不会改变排序关系所以对AUC没有影响但对F1和业务里的绝对阈值判断很关键。5. 微调翻车现场常见报错与排查清单5.1 显存不足CUDA out of memory现象batch size设为16一跑训练就报CUDA out of memory连模型加载都过不去。原因BERT的参数量在110M附近中间激活值占用远大于模型权重本身。max_length128和batch_size16组合起来激活值轻松吃掉十几G显存。解决先把batch size降到4确认能跑通再逐步调大。同时把max_length从256降到128短句任务完全够用。如果这两步做完还是不够用梯度累积accumulation_steps 4 optimizer.zero_grad() for step, batch in enumerate(dataloader): loss compute_loss(batch) loss loss / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: optimizer.step() scheduler.step() optimizer.zero_grad()注意梯度累积不会减少显存占用它只是延迟参数更新等效于把batch size从4放大到16。显存紧张时先降batch再考虑累积。5.2 训练loss完全不降或者直接变成NaN现象跑了三个epochloss在0.69附近纹丝不动或者loss在第一轮就变成NaN之后一直NaN。原因0.69这个数字很典型它是二分类交叉熵在随机猜测时的理论值出现这个值说明模型根本没在学。常见原因有三个用的是分类标签但loss写成了MSELoss学习率太大把参数冲飞数据里有NaN标签。解决先打印一个batch的loss和label确认标签范围和loss函数匹配。分类任务用CrossEntropyLoss回归任务用MSELoss两者互换是新手最常见的翻车点。然后把lr从5e-5降到2e-5再试。如果loss是NaN逐项检查标签有没有无穷大值input_ids有没有超出词表范围。5.3 相似度分数全在0.99或全在0.1附近现象验证集上所有句子对的预测分几乎一样要么全部接近满分要么全部接近零分。原因模型学到了“无脑打高分/打低分”这个捷径。最常见的原因是正负样本极端不均衡比如正样本占95%模型发现全预测正样本就能拿到很高准确率。另一个可能是标注本身有问题标注员把相似度理解成了“关键词重合率”导致模型只能看到字面重叠信息。解决先统计验证集人工分数分布。如果人工也是极端分布问题在标注规范重新讨论标准后补充标注。如果人工分布正常做负采样或换Focal Loss。我一般会先做最简单的随机欠采样把正负比拉到1比2以内如果换来的收益不明显再上Focal Loss。5.4 中文任务加载了英文BERT现象loss下降慢训练半天输出分数全在0.5附近。检查分词结果发现“今天天气不错”被切成了“今”“天”“天”“气”这样的独立汉字但后面却跟着一堆英文字母子词。原因model_name写成了bert-base-uncased加载的是英文模型。英文tokenizer按子词切分中文汉字在词表中不存在退化成一个汉字一个token语义信息大量丢失预训练学到的英文知识也完全用不上。解决把model_name换成bert-base-chinese或chinese-roberta-wwm-ext重新下载权重。加载后先打印两句话的分词结果确认一下print(tokenizer.tokenize(今天天气不错))输出应该是[今, 天, 天, 气, 不, 错]这样的中文字符列表而不是夹杂着##前缀的英文子词。这个检查只用十秒钟建议每次换模型都看一眼。5.5 训练时验证集指标不错推理时分数整体偏歪现象训练过程里验证集F1有0.85线下抽测也很正常但上线后线上分数分布和线下完全对不上。原因训练和推理的预处理流程不一致。常见的有三种训练时做了shuffle而推理没做这个不影响单条训练时按句子对拼接编码推理时不小心只传了一个句子训练时关闭了truncation推理时忘了开长句子被模型截断导致语义缺失。另外推理时漏写model.eval()会让dropout继续生效预测结果带随机性。解决把编码逻辑封装成同一个函数训练和推理都调用它。函数里固定好max_length、truncationTrue、padding策略杜绝两侧各自写一套。推理函数第一行永远是model.eval()与torch.no_grad()配套使用。6. 上线前的体检用十对句子看清模型用保存策略保住结果6.1 用十对句子给模型做个体检跑任何指标之前先手工看模型在精心挑选的句子对上的输出。我入职一个新项目或换一批数据时一定会做这件事十对句子五对容易直接看出相似与否三对是同义改写两对是专门用来踩坑的否定和数字变化。句子A句子B期望我喜欢这个方案我不喜欢这个方案不相似小明今天没来上班小明今天请假了相似今年营收3000万今年营收3000万元相似配置是8G内存配置是16G内存不相似系统重启之后正常了重启之后系统正常了相似这组句子不需要多跑一圈就能看出模型有哪些具体毛病。比如第二对能过、第四对也过了那说明模型理解了不少语义如果第一对和第二对打出的分数几乎一样说明模型没学会“否定”这种关键变化加数据时就该多补否定句式。6.2 保存模型时把阈值一起存下来微调结束后权重、词表、阈值、实验参数要放在一起避免三个月后回头找不到当初的判定标准model.save_pretrained(./sim_model) tokenizer.save_pretrained(./sim_model) torch.save( {threshold: best_th, seed: 42, val_f1: best_f1}, ./sim_model/meta.pt, )加载时先从meta.pt读出阈值再加载模型权重保证推理和验证时用的是同一个判定标准。我现在的习惯是每次训练前把随机种子写死在config里训练完把阈值、验证分数、数据版本都记在一个实验记录文件里。这个习惯帮我复盘过好几次“当时指标明明不错怎么现在复现不出来”的翻车现场也让我对模型这张黑匣子稍微多了点底。希望帮到你。本文还有配套的精品资源点击获取