BERT微调实战:基于CMRC2018的中文抽取式问答实现指南

发布时间:2026/10/21 12:47:26
BERT微调实战:基于CMRC2018的中文抽取式问答实现指南
做深度学习相关的NLP项目很多人第一步会卡在“模型选什么、数据怎么做”上。这次的目标其实很具体把BERT语言模型放到CMRC2018中文阅读理解数据集上做一次微调让模型最后能回答简单的抽取式问答。所谓抽取式就是模型不做自由发挥而是从给定的文章片段里挑出连续一段文字作为答案有点像开卷考试里“从第几段抄出关键句”。经过微调后输入一篇短文加一个问题模型会输出一组起始位置和结束位置从而定位出答案片段。这个流程看起来不复杂但真正上手跑一遍之后你会发现里面不少细节都决定最终效果。如果你是第一次接触BERT微调或者想找一个能复现的中文问答基线项目这篇文章可以当作一份完整的工作笔记来用。我会把从数据集解析、tokenizer偏移对齐、训练参数设定到推理后处理的全部环节都拆开来讲也会把我在实际调试中踩过的坑写出来。不要小看这个“简单的抽取式阅读理解任务”——它背后的数据对齐逻辑、损失设计思路和评估指标几乎可以原封不动迁移到很多真实业务场景中。1. 项目整体拆解任务定义与技术选型思路带问答需求的业务场景越来越多客服机器人、文档助手、知识库问答底层都能归到“给定上下文找答案”这个范式。用BERT做抽取式问答最大的好处是训练目标明确模型只需要学会判断答案从哪里开始、到哪里结束。任何能改成这种“span提取”形式的问题都可以套这套方案。1.1 这是个什么任务难点在哪相比生成式问答抽取式问答更接近“定位”而不是“创作”。模型端的输出是两个概率分布一个是start_logits一个是end_logits分别表示每个token位置上作为答案起点和终点的置信度。训练时拿到答案的字符偏移映射成token下标构造出这两个位置的标签然后计算交叉熵损失。这里的难点主要有三个文本到token的映射关系容易错。BERT分词后会引入[CLS]、[SEP]和可能的[UNK]token和原始字符并不是一一对应。答案可能被截断。当问题加文章总长度超过max_length时如果答案落在截断区域这条样本要么丢弃要么截断处理。后处理限制多。不能简单地同时取start_logits和end_logits的最大值还需要保证起始位置在结束位置之前且跨度不能太大。我刚接触这个项目时犯的最大的错误就是直接用tokenizer之后的下标去匹配答案。后来才发现必须依赖tokenizer返回的offset_mapping才能回到原始字符空间里做对齐。1.2 为什么选BERT和CMRC2018选择BERT不是因为它最新而是因为它非常适合作为抽取式问答的基线。它有足够强的双向上下文表示能力并且开源权重和配套工具都成熟。对于中文数据标准中文BERT权重已经包含字粒度词表对中文文本处理得很稳不需要额外做分词。CMRC2018是中文阅读理解领域很经典的数据集由训练集、验证集和测试集构成训练集有上万道问题验证集有三千多道问题。每条数据是“一篇文章 若干问题 答案”。和英文的阅读理解数据集不同CMRC2018每个问题的参考答案往往不止一个因为不同标注者给出的答案长度和位置可能略有差异。评估EM的时候系统预测答案只要和任何一个参考答案匹配就算完全正确。预训练加微调这个范式可以类比成一个岗位培训过程BERT在通用语料上已经“读了很多书”对词义、句法、常识很有感觉微调阶段只是让它针对性地练一练“从文章中找答案”这项技能。相比从零开始训练预训练加微调不仅数据需求少训练时间也大大缩短效果还通常好得多。1.3 整体技术路线与验收标准我给自己划定的实施路线分五步每一步都有可验证的产出阶段输入产出验收要点数据准备CMRC2018原始JSON清洗后的标准化数据所有答案都有字符级偏移特征工程标准化数据token特征与起始结束标签单条样本可视化检查正确模型训练特征Dataset微调后的模型权重训练损失稳定下降评估与推理验证集样本预测答案片段F1、EM指标达到预期部署扩展微调模型问答Demo或简单接口单条推理延迟可接受有了这条路线后面每一步都不会盲目。下面我从环境准备开始逐步展开。2. 环境准备与数据集预处理环境问题说起来不复杂但经常浪费一下午时间。先把我用的依赖列出来避免版本上遇到无谓的干扰。2.1 开发环境与依赖版本建议使用Python 3.9以上版本深度学习框架选PyTorch模型和数据集处理直接用transformers和datasets这两个库。核心安装命令pip install torch transformers datasets如果你的机器只有CPU可以先跑通前向和单条样本的调试但完整训练还是建议有GPU显存12G以上会比较舒服。显存不够的话可以通过减小batch size、使用梯度累积来缓解但训练速度依然受限。需要注意transformers库更新比较频繁不同版本间API有些微差异。比如早一点的版本里AutoModelForQuestionAnswering的输入和返回值基本固定但新版本在padding、truncation等参数上的默认行为可能变化。固定版本是个好习惯pip install torch2.1.0 transformers4.40.0 datasets2.19.0这套版本组合我实际跑过没有遇到兼容性问题。2.2 原始数据长什么样CMRC2018的原始数据是JSON格式结构大致如下{ paragraphs: [ { context: 在机器学习中经验风险是指模型在训练样本上产生的平均损失。, qas: [ { question: 经验风险的定义是什么, id: train_001, answers: [ { text: 模型在训练样本上产生的平均损失, answer_start: 16 } ], answer_start: 16 } ] } ] }这里“answer_start”是答案片段在context字符串里的起始字符偏移量。计算答案终点只需要用起始偏移量加上答案文本长度。从这套数据结构能看到答案文本并不单独存储而是作为原始文章的一部分出现所以“抽取式”的本质非常明显。我用datasets库加载数据后很快发现一个问题数据里可能有空答案、长文本、以及奇怪的空白字符。这些都需要在进入模型前处理好。2.3 数据清洗与关键偏移映射很多人觉得数据清洗无足轻重但在抽取式问答任务里一个小疏忽就能让答案对不上。比如文章里有全角空格、换行符或连续标点虽然肉眼看不出来但字符偏移会偏移掉。我的做法是先不修改原文内容把“问答文本”原封不动保留只做下面几项规整统一文本里的换行符去掉不可见控制字符。把英文标点统一成中文标点避免tokenizer分词时出现意外token。答案文本用strip()清理首尾空白但必须确保清理后的文本仍然能在原上下文里通过find()找到否则需要重新计算偏移。真正要命的还是tokenizer的偏移映射。BERT内部的词表是字和词混合的比如“机器学习”可能会被切成多个token原先的字符偏移直接转换不成token下标。必须让tokenizer返回每个token对应原始文本中的字符区间也就是offset_mapping。我写了一个通用对齐函数逻辑是这样的def find_answer_tokens(offset_mapping, start_char, end_char): start_token, end_token None, None for idx, (token_start, token_end) in enumerate(offset_mapping): if token_start start_char token_end: start_token idx if token_start end_char token_end: end_token idx return start_token, end_token注意判断条件里的开闭区间。答案的起始字符很可能落在某个token的中间一般规则是“答案起点所在token的起始位置不超过字符起点”这样才不容易把答案首字丢掉。还有一个容易忽略的点tokenizer会自动在序列开头加[CLS]、在中间加[SEP]这些special token在offset_mapping里对应的字符区间是(0, 0)。写对齐逻辑时要把这些token过滤掉否则可能错误地把答案起点定位到特殊token上。3. 核心实现模型加载、特征构建与训练流程预处理跑通之后核心环节是模型加载、特征构建和训练。这部分内容最多也是决定模型最终表现的关键区。3.1 加载模型和tokenizer的两种常见方式我习惯用AutoTokenizer和AutoModelForQuestionAnswering来加载因为同一套接口可以切换到不同的预训练模型后期实验替换模型会非常方便。from transformers import AutoTokenizer, AutoModelForQuestionAnswering MODEL_NAME bert-base-chinese tokenizer AutoTokenizer.from_pretrained(MODEL_NAME) model AutoModelForQuestionAnswering.from_pretrained(MODEL_NAME)AutoModelForQuestionAnswering会返回一个带qa_outputs层的模型结构该层输出两个张量shape是(batch_size, seq_len)分别对应start logits和end logits。这个结构不复杂但很多人刚开始会忘记把模型切到训练模式或评估模式导致Dropout层行为不对训练和推理结果出现偏差。另一个思路是直接用BertForQuestionAnswering好处是更明确坏处是换成其他结构时需要改代码。如果你只是做这个基线项目两种方式都行如果后面要尝试RoBERTa等变体建议用Auto系列。3.2 构造训练样本问题、上下文、答案标签一个都不能少在构造训练样本时我会把问题放在前面、上下文放在后面用tokenizer的truncationonly_second确保只截断上下文而不是问题。示例代码def tokenize_example(example, tokenizer, max_length384): qa_input tokenizer( example[question], example[context], max_lengthmax_length, truncationonly_second, paddingFalse, return_offsets_mappingTrue ) offset_mapping qa_input[offset_mapping] start_char example[answers][answer_start][0] end_char start_char len(example[answers][text][0]) start_token, end_token find_answer_tokens(offset_mapping, start_char, end_char) # 如果答案在截断区域之外丢弃样本或做截断处理 if start_token is None or end_token is None: return None qa_input[start_positions] start_token qa_input[end_positions] end_token return qa_input这里有几个细节需要解释return_offsets_mappingTrue是拿到字符映射的前提缺失这个参数就无法正确对齐。paddingFalse先不填充等批量组合时统一处理减少存储浪费。如果答案因为截断原因没有出现在token序列里我倾向于直接过滤这条样本而不是硬塞一个错误标签。虽然这会损失一点训练数据但避免给模型传递噪声。处理完整批样本后用DataLoader的collate_fn动态做padding。动态padding的关键是要把标签和输入一起转成相同长度的张量def collate_fn(batch): input_ids [torch.tensor(item[input_ids]) for item in batch] attention_mask [torch.tensor(item[attention_mask]) for item in batch] token_type_ids [torch.tensor(item[token_type_ids]) for item in batch] start_positions torch.tensor([item[start_positions] for item in batch]) end_positions torch.tensor([item[end_positions] for item in batch]) input_ids torch.nn.utils.rnn.pad_sequence( input_ids, batch_firstTrue, padding_valuetokenizer.pad_token_id ) attention_mask torch.nn.utils.rnn.pad_sequence( attention_mask, batch_firstTrue, padding_value0 ) ... return input_ids, attention_mask, token_type_ids, start_positions, end_positions这里有一个隐藏问题padding后start_positions和end_positions不能指向padding区域。好在我构造标签时只用了有效token的下标不会发生越界。若有人在构造标签时把标签设成max_length下标训练时就会报索引错误或计算错误。3.3 训练参数这样设不太容易翻车BERT微调的常见经验值是学习率从2e-5到5e-5之间选取。不要用默认的1e-4这类偏大的学习率微调时很容易破坏预训练权重。我这次用的是学习率2e-5batch size16最大序列长度384训练轮数3warmup比例0.1权重衰减0.01选择384而不是512是因为CMRC2018的很多文章其实不长384已经能覆盖绝大多数样本同时显存占用更小。如果上下文特别长、或者你处理的是长文档问答可以改成512但此时batch size可能要调小。训练循环可以用transformers自带代码也可以自己写。自己写的可控性更强核心部分如下from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup optimizer AdamW(model.parameters(), lr2e-5, weight_decay0.01) total_steps len(train_dataloader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps ) model.train() for epoch in range(epochs): for batch in train_dataloader: batch [x.to(device) for x in batch] input_ids, attention_mask, token_type_ids, start_positions, end_positions batch outputs model( input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids, start_positionsstart_positions, end_positionsend_positions ) loss outputs.loss loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad()这里模型前向时传入start_positions和end_positions后Hugging Face内部会自动计算交叉熵损失。如果你自己算损失不要忘记对start_logits和end_logits做view(-1, seq_len)否则会得到错误的loss。3.4 训练时该观察什么训练过程中我主要看两个信号训练loss是否稳步下降。如果loss在某个数值附近震荡不降很可能是标签对齐有问题先去检查数据预处理。验证集指标是否跟着训练集一起提升。如果训练集loss很低、验证集指标完全不动就要警惕过拟合和噪声标签。BERT base在CMRC2018上经过简单微调验证集F1在80上下波动是比较常见的情况。不同随机种子、不同学习率会带来几个点的浮动所以不必追求固定分数。关键是要保证训练曲线是健康的。4. 评估指标、推理后处理与效果观察训练完成后很多人直接拿模型跑一遍验证集发现有的回答正确有的不正确但缺少量化评价。这里需要把EM和F1指标讲清楚。4.1 EM和F1到底在算什么EM是Exact Match要求预测答案和某个标准答案在字符级别完全一致。这个指标很严多一个标点、多一个“的”字都算错误。F1则是字符级别的精确率和召回率的调和平均值计算公式是精确率 预测答案与标准答案的重合字符数 / 预测答案字符数 召回率 预测答案与标准答案的重合字符数 / 标准答案字符数 F1 2 * 精确率 * 召回率 / (精确率 召回率)由于CMRC2018每个问题有多个参考答案评估时取的是预测答案与所有参考答案之间F1的最大值。这也是为什么F1通常明显高于EM只要预测答案和标准答案有所重叠F1就能给出一个不低的分数。4.2 推理后处理直接取argmax是不够的推理时的“解码”很容易被新人忽略。如果直接把start_logits和end_logits各自取最大值可能出现起始位置在结束位置之后或者是答案长度长到离谱的情况。我的做法是遍历所有满足条件的起始、结束位置组合def decode_answer(start_logits, end_logits, max_answer_length30): best_score float(-inf) best_start, best_end 0, 0 for start_idx, start_score in enumerate(start_logits): for end_idx in range(start_idx, min(len(end_logits), start_idx max_answer_length)): score start_score end_logits[end_idx] if score best_score: best_score score best_start, best_end start_idx, end_idx return best_start, best_end这里使用了“起始分 结束分”作为span分数。限制end_idx不能低于start_idx同时限制最大跨度避免模型输出整篇文章。对于CMRC2018这种答案通常很短的场景max_answer_length设成30个token已经足够。还需要过滤掉[CLS]、[SEP]以及padding token。因为模型可能在特殊token上给出很高分数但把答案定位到特殊token显然不对。之后根据offset_mapping把span对应的原始文本切片出来这才是最终答案。4.3 一个直观的推理示例我拿了这样一条验证集样本来测试问题经验风险是什么意思上下文在统计学习理论中经验风险是指模型在训练样本上取得的平均损失它反映了模型对已知数据的拟合程度。标准答案模型在训练样本上取得的平均损失模型预测模型在训练样本上取得的平均损失这个结果看起来不复杂但只有当标签对齐、后处理逻辑正确时模型才能真正稳定产出这种高置信度答案。如果中间某一步出错比如offset_mapping对不上推理时很可能会抽出“经验风险是指模型”这种残缺片段。验证集整体跑完后可以统计出EM和F1的具体数值再分别抽取错误样本查看。看错误样本比看指标本身更有价值如果模型经常把答案首字或尾字多抽出来说明后处理里的边界判断有问题如果答案偏移明显说明预处理时有脏数据。5. 常见问题与避坑经验这一部分是我整理项目时最有价值的总结。很多问题并不是模型结构造成的而是在数据处理、版本兼容、训练策略等环节里挖出来的坑。5.1 高频错误与解决方案对照现象可能原因排查与解决方法训练loss不下降答案标签无法对齐构造出错误span打印训练集中前几条样本的token与偏移映射逐一对照验证EM非常低后处理没有限制start和end的顺序在decode中强制startend并限制max_answer_length模型回答出padding位置decode没有过滤特殊token推理前去掉offset_mapping为(0,0)的tokenGPU显存不够max_length太长或batch size过大降低batch size使用梯度累积或缩短max_length训练与验证分布差异大数据集划分随意有相同段落同时出现按文章ID去重划分避免数据泄露5.2 最容易翻车的数据对齐问题我第二次训练时把answer_start直接拿来当token下标用结果训练loss奇高模型无论怎么训都只会预测固定位置。排查了很久才醒悟BERT中文字token最少也是单个汉字但标点、空格、字母数字都可能合并成多个token字符偏移自然不等于token索引。一个强经验是在训练之前手动构造5条极端样本跑前向同时打印出每一条的input_ids、offset_mapping和标签。具体做法是把tokenizer.convert_ids_to_tokens(input_ids)打印出来再把每个token对应的字符区间打印出来然后和标准答案比对。只要这条手工检查通过后面批量训练才会稳。还有个坑是答案文本和上下文存在不可见字符差异。比如上下文里是中文全角空格答案里却是普通半角空格用find()会找不到偏移也会错。统一清洗后再去计算offset并且用答案文本在上下文里的实际起止位置重新生成标签。5.3 训练轮数和学习率的真实体感BERT微调一般2到3轮就有不错效果并不是越多越好。训练到第3轮时验证集指标可能已经趋于平稳训练集loss还在下降说明过拟合开始出现。这时继续训练只会让权威性往错误方向偏移。学习率方面5e-5收敛速度快但容易震荡1e-5更稳但需要更多轮次。如果你想减少实验次数用2e-5和3轮通常是安全组合。早期我试过用1e-4一键训练结果模型几乎崩溃输出全是重复字符从这个教训能看出微调任务里学习率是极其敏感的超参数。另外一个容易被忽视的是warmup。warmup比例太小训练初期优化器更新步长太大会让模型在开头就“走偏”。我习惯设置num_warmup_steps为总步数的10%效果比较稳定。5.4 评估结果过高时要警惕“作弊”如果写代码时不小心把训练集也放进验证集或者验证集和训练集有大量重叠EM和F1会被虚高。CMRC2018原始划分本身是安全的但如果你自定义数据就一定要按文本来源去重。我实际验证过当验证集里混入训练集同源数据时F1能虚高五六个点。这在真实场景里毫无意义因为线上问题几乎不会和训练数据完全重复。稳妥做法是按文章ID或者文章标题做分组把同一篇文章的所有问题放在同一个集合里避免跨集合泄漏。6. 效果提升与工程落地思路用BERT在CMRC2018上完成一轮微调只是拿到了一个基线。要在真实项目中落地后面要补的方向还不少。6.1 从单个模型走向“检索加深读”真实问答系统很少直接把整本知识库塞进模型更常见的做法是先检索再阅读先用关键词召回或者向量召回出可能包含答案的若干段落再由阅读理解模型抽取答案。比如用户问“显卡保修多久”先通过检索把相关商品说明文档拉出来再用类似刚才微调过的抽取式问答模型定位具体回答。这个组合比让模型直接生成答案可控得多也方便维护。我建议把CMRC2018微调出来的模型当成一个可复用的“阅读理解底座”后续换到其他领域时只需要准备这个领域的问答对再继续微调一遍即可。数据规模不一定很大几千条高质量问答对就能带来明显提升。6.2 模型压缩与加速BERT base在CPU上跑单条推理通常需要一两百毫秒放到高并发服务里就有些吃力。常用的加速手段包括蒸馏成小型模型比如从BERT base蒸馏到一个小参数量的模型效果会下降一些但延迟能降低一个数量级。用ONNX Runtime或推理框架做图优化减少算子开销。量化到int8显存占用和推理速度都会更有优势但要留意精度损失。如果你只是做个人项目或小流量服务不压缩也完全没问题。真正上线时再根据响应时间和吞吐要求逐步优化。6.3 加入“无答案”能力真实场景里很多问题其实在所给文档里没有答案比如用户问了一个和文档无关的问题。抽取式模型默认会给一个起始和结束位置这就很容易强行抽一段不相干的话出来。解决方法是在数据里加入“无答案”样本并设计一个置信度阈值当span分数低于阈值时直接回答“未找到答案”。CMRC2018原始数据不包含无答案标签但你可以手动构造一些负样本把问题和完全无关的上下文拼在一起标签设为某个空位置或者单独加一个二分类头判断是否有答案。这个改动对工程体验提升非常大。另外多轮对话场景下需要维护用户问句和上下文的关系。简单做法是把历史对话拼到问题后面复杂一点则要引入对话状态管理。这个方向已经超出单一BERT微调的范畴但迭代路径是清晰的每加一个能力都像在已有底座上叠加一个新模块。最后再分享一个小技巧。我这次项目里最省时间的做法不是先全量训练而是先用100条数据训练5分钟跑通整个训练到评估的闭环确认指标计算没有问题再上全量数据。很多人一上来就全量训练等到第二天看结果发现代码里有个标签偏移bug白白浪费十几个小时。先小步快跑确认链路通顺这个习惯在NLP微调项目里非常管用。如果你准备照着这个项目动手第一步不是赶紧训练而是把CMRC2018数据加载出来打印几条样本肉眼确认问题、上下文和答案偏移。这一步做好后面基本不会出大乱子。把简单任务做稳定再逐步加复杂设计这条路对刚接触阅读理解和问答系统的人来说应该是最顺利的走法。