ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python基于Bert智能问答系统:抽取式阅读理解工程实践

Python基于Bert智能问答系统:抽取式阅读理解工程实践 简介这是基于BERT的智能问答系统毕业设计项目评审分98分面向自然语言处理方向的学生和实战学习者同时适配课程设计、项目练习等场景。项目整合知识库问答KBQA、命名实体识别NER与句子相似度计算等核心模块附带中文BERT预训练模型配置、完整文档说明及数据处理脚本难度适中源码均经过编译调试可直接运行适合需要端到端参考项目的开发者借鉴。压缩包共57个文件以25个Python脚本为主辅以11个Markdown文档、4个XML配置、以及JSON、Shell脚本、图片、日志等文件包体仅1.62MB层级清晰便于按模块阅读与二次改造。已有100人下载学习。资源提供覆盖数据构建、模型训练、测试评估、可视化展示的完整代码并包含详细的说明文档与注释可帮助使用者快速理解BERT在问答与NER任务中的实际落地流程节省环境搭建和调优时间高质量完成设计或深入学习。1. 基于Bert的智能问答系统先想清楚它到底解决什么问题如果你以为“python基于Bert的智能问答系统”是做出来一个ChatGPT那样的聊天机器人那方向一开始就偏了。这类项目本质上是阅读理解和信息抽取你给系统一段文本再抛给它一个问题它从文本里找出一个连续的片段当作答案。它不“生成”答案而是“摘抄”答案。这个区别非常关键决定了模型结构、训练数据格式、评估指标甚至答辩讲解口径都会完全不一样。这类系统在限定领域里反而很能打。比如课程答疑、规章制度问答、病历知识检索这类场景语料是有限的问题分布是相对固定的一个微调好的Bert模型效果比直接调大模型更可控、更稳定也更容易解释——对一个需要写文档和做答辩的毕设来说这三点比“功能炫酷”重要得多。它的完整落地链路是下载预训练模型构造SQuAD风格的问答数据微调Bert封装成Web接口最后用测试集和典型案例证明系统可用。适合正在做毕设的学生也想给团队做内部知识问答的开发者。2. 环境与模型资源一次配齐从conda到bert-base-chinese离线可用2.1 依赖安装把python、torch、transformers的版本关系锁住环境问题不用追求最新版本。这个项目的依赖核心是深度学习框架和transformers库版本太新反而容易踩兼容性坑。我的做法是新建一个独立的conda环境不用系统自带的python避免跟其他项目互相污染。conda create -n bert_qa python3.8 -y conda activate bert_qa pip install torch --index-url https://download.pytorch.org/whl/cu118 pip install transformers4.18,5.0 datasets pandas tqdm第一条命令中的python版本选了3.8不是越新越好。原因是torch和transformers在中版本下测试最充分而且大部分开源项目代码在3.8下都能直接运行。如果你机器上没有conda优先装miniconda而不是Anaconda体积小、够用。torch安装时需要注意cuda版本号——cu118对应CUDA 11.8如果你机器上是CUDA 12以上就换cu121对应的索引地址。怎么看自己的CUDA版本终端执行nvidia-smi看右上角CUDA Version那一行。如果电脑没有NVIDIA显卡直接用CPU版pip install torch即可后文我会说CPU推理时怎么把模型调快。transformers的版本约束写成4.18,5.0是因为两个大版本之间的接口差异很大比如Trainer的传参方式、tokenizer返回的字段顺序在5.x里可能都不兼容。锁一个大版本区间后面跑代码时少很多莫名其妙的问题。2.2 模型从哪来在线下载与离线缓存的两种落地方案Bert模型本身不是代码而是一堆权重参数文件。中文问答任务直接用bert-base-chinese这个预训练模型就行它是基于中文维基语料训练的分词方式是字级别对中文场景最省事。模型下载一般通过Hugging Face的模型库完成但网络环境不好的时候连接经常中断下载会反复失败。pip install -U huggingface_hub export HF_ENDPOINThttps://hf-mirror.com python -c from transformers import AutoModelForQuestionAnswering; AutoModelForQuestionAnswering.from_pretrained(bert-base-chinese, cache_dir./model_cache)HF_ENDPOINT环境变量用来切换Hugging Face镜像地址这是国内下载模型最常用的办法。cache_dir参数指定缓存目录好处是模型下载完只会落地一次后面重复执行加载都会走本地缓存。AutoModelForQuestionAnswering这个类会帮我们下载模型结构配置文件config.json和权重文件pytorch_model.bin不需要手动去翻文件。如果你的机器完全不能访问外网那就让有外网的同事帮你把整个bert-base-chinese模型目录拷贝过来放到一个本地路径加载时把from_pretrained(bert-base-chinese)改成from_pretrained(/本地路径/bert-base-chinese)效果完全一样。公司内网部署时我一般直接走这条离线方案省心。2.3 用一段5行代码验证model和tokenizer真的加载成功模型下载完成不等于能跑。常见的情况是模型文件是下了但tokenizer用的是另一个路径或者加载的模型类跟任务不匹配。所以我习惯在写训练代码之前先跑一段冒烟测试确认四样东西tokenizer能分词、模型能前向传播、输出维度符合预期、显存或内存没炸。from transformers import AutoTokenizer, AutoModelForQuestionAnswering tokenizer AutoTokenizer.from_pretrained(bert-base-chinese, cache_dir./model_cache) model AutoModelForQuestionAnswering.from_pretrained(bert-base-chinese, cache_dir./model_cache) question 问答系统的主要功能是什么 context 问答系统基于Bert模型实现主要功能是从给定文本中抽取问题对应的答案片段。 inputs tokenizer(question, context, return_tensorspt, truncationTrue, max_length128) outputs model(**inputs) print(问题词表长度:, inputs[input_ids].shape) print(start_logits:, outputs.start_logits.shape) print(end_logits:, outputs.end_logits.shape)这里return_tensorspt表示返回PyTorch张量truncationTrue和max_length128控制超长文本的截断。从打印结果能看到input_ids的shape是[1, 128]说明tokenizer正常工作了start_logits和end_logits的shape是[1, 128]说明模型输出的是每个位置的起始和结束打分这就是后面答案抽取的基础。关于下载存储路径多说一句不建议把模型放在项目根目录下面因为pytorch_model.bin差不多有400MB连同训练产生的中间文件会把工程目录搞得很乱。一般我放在项目外的一个models目录写一个相对路径引用。这样无论怎么移动项目模型都只有一份不需要重复下载。3. 数据与预处理把自己手上的问答对转成BERT能吃的格式3.1 两条路线先选型抽取式阅读理解还是检索加重排很多拿到源码的同学第一步就去看模型代码看半天还是不知道怎么训练自己的数据。其实最先该想清楚的是路线问题。基于Bert的智能问答系统在毕设场景里有两条主流实现路线一条是SQuAD式的抽取式阅读理解模型直接读“问题一段上下文”输出答案起止位置另一条是检索式先用句向量或关键词把候选段落捞回来再用Bert对候选句做“是否包含答案”的二分类或重排。这两条线怎么选看你的数据形态。如果语料本来就是一篇一篇的长文档问题对应文档里的某个片段那就老老实实做抽取式QA这是本标题源码最标准的实现方式训练目标明确、效果指标好量化如果语料是几十上百条FAQ问答对没给答案所在的原文段落那更推荐做“向量召回Bert精排”因为FAQ的答案是独立的句子不是从长文里抽出来的。毕设若只有少量数据优先选抽取式再人工补标注这是最稳的路线。抽取式QA需要准备的核心数据格式叫SQuAD风格JSON下文详细展开。3.2 数据格式SQuAD风格的JSON和它的字段约束SQuAD格式的数据是整个训练流程的输入契约。它嵌套了三层data里是文章文章里是paragraphs段落里有context和qas问题集合每个qas必须包含问题文本question、唯一编号id和答案列表answers答案的text必须是从context里逐字复制出来的原文answer_start是答案在context中的起始字符偏移量。{ version: custom_qa, data: [ { title: 课程管理系统说明, paragraphs: [ { context: 课程管理系统支持学生在线选课教师可以录入成绩。, qas: [ { id: q001, question: 教师可以在系统中做什么, answers: [{text: 录入成绩, answer_start: 16}] } ] } ] } ] }这段JSON里answer_start:16是个很容易算错的字段。中文字符串的偏移量按字符数计不是按字节计用Python的len(课程管理系统支持学生在线选课教师可以)可以得到19从下标19开始到24就是“录入成绩”。手工维护这个字段非常容易出边界错误所以实际准备数据时应该用字符串查找函数自动定位而不是肉眼去数。一个更常见的实操场景是你手上只有一堆FAQ问答对比如“如何重置密码——登录页点击忘记密码按短信提示操作即可”。这类数据不能直接用因为答案不在任何上下文里。常见的做法是我自己写一个转换脚本把每条FAQ配上一段背景说明文字如把“登录页点击忘记密码按短信提示操作即可”扩写成“系统登录页面提供密码重置功能用户点击忘记密码后系统会发送短信验证码按短信提示操作即可完成重置”然后把原答案替换成扩展后的句子片段。这样做的目的是让答案在结构化上能对应上answer_start的取值要求。def build_squad_item(context, question, answer): start context.find(answer) if start -1: print(f警告答案不在上下文中 - {answer}) return None return { context: context, question: question, answers: [{text: answer, answer_start: start}] }这个函数里str.find返回答案在上下文中的起始下标如果返回-1说明你的背景段落里根本不含答案原文。这时要么改写上下文把答案嵌进去要么检查答案是否有多余空格或全角半角差异。数据准备阶段这一步最费时间但也最值钱——训练集质量直接决定模型能不能收敛到一个能答辩的水平。3.3 预处理truncation、offset_mapping和答案边界对齐SQuAD格式的数据不能直接喂给模型要做两部分预处理一是文本到token id的编码二是把答案的字符偏移量换算成token偏移量。答案在字符级别是answer_start到answer_start len(answer)但Bert用的是wordpiece分词中文虽然基本是字一个token但遇到英文、数字、符号时会拆成多个子词字符偏移量和token下标就错位了必须用tokenizer返回的offset_mapping来对齐。def encode_example(question, context, answer_start, answer_end): inputs tokenizer( question, context, max_length384, stride128, truncationonly_second, return_offsets_mappingTrue, paddingmax_length ) start_char answer_start end_char answer_end token_start None token_end None offset_mapping inputs[offset_mapping] for idx, (start, end) in enumerate(offset_mapping): if start 0 and end 0: continue if token_start is None and start start_char end: token_start idx if token_end is None and start end_char end: token_end idx inputs[start_positions] token_start inputs[end_positions] token_end return inputs这里truncationonly_second特别关键它表示只截断context不截断question因为问题被截断后训练目标会受干扰。stride128表示滑动窗口的重叠长度当context很长被切成多个窗口时答案可以落在重叠区域中保证答案不会因为截断而消失。offset_mapping返回的是每个token对应到原字符串的字符起止位置(0,0)是特殊token[CLS]和[SEP]的位置遍历时要跳过。用这段代码把所有训练数据编码成input_ids、attention_mask、token_type_ids和答案起止下标之后就能直接封装成Dataset并交给模型了。你要保存一份编码前的原始JSON因为后面模型预测结果要回溯到原始文本靠offset_mapping反推答案在原文中的位置这一步保留原始数据就是给自己留后悔药。4. 训练与推理参数调到能答辩的标准4.1 训练循环与关键参数learning rate、batch size、max length怎么设训练阶段不需要写复杂的自定义Trainer直接基于PyTorch循环最透明也方便调试。先封装数据集和DataLoader再写训练循环。这里我把最小可运行的训练代码拆成两段第一段是数据封装第二段是训练循环。from torch.utils.data import Dataset, DataLoader import torch class QADataset(Dataset): def __init__(self, encodings): self.encodings encodings def __getitem__(self, idx): return {k: torch.tensor(v[idx]) for k, v in self.encodings.items() if k in (input_ids, attention_mask, token_type_ids, start_positions, end_positions)} def __len__(self): return len(self.encodings[input_ids]) def collate_fn(batch): return {k: torch.stack([item[k] for item in batch]) for k in batch[0]}封装的要点是只保留模型训练需要的字段offset_mapping这类辅助信息不要在训练批次里传进模型否则会报多余的key错误。collate_fn的作用是把多个样本的tensor按第一维堆叠成batch。from transformers import BertForQuestionAnswering, AdamW, get_linear_schedule_with_warmup model BertForQuestionAnswering.from_pretrained(bert-base-chinese, cache_dir./model_cache) optimizer AdamW(model.parameters(), lr3e-5, weight_decay0.01) epochs 3 total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps ) for epoch in range(epochs): model.train() for step, batch in enumerate(train_loader): batch {k: v.to(model.device) for k, v in batch.items()} outputs model(**batch) loss outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad() if step % 50 0: print(fepoch {epoch} step {step} loss {loss.item():.4f})参数设置这里有几个容易踩坑的点。learning_rate3e-5是Bert微调的一个非常成熟的起点不要随便改成1e-3这种大学习率预训练模型微调用大学习率会直接灾难性遗忘。weight_decay0.01对除bias和LayerNorm之外的参数生效比较合理代码示例中直接对所有参数做也是一个可接受的简化。clip_grad_norm_限制梯度范数防止个别样本把梯度带偏。epochs3是针对小数据集的默认设置如果你只有几百条数据3轮之后大概率已经过拟合但毕设评测时通常还是能给出不错的F1值。想验证模型真的学到了可以故意训练一个batch到很低的loss如果loss降不下去先查数据编码逻辑而不是换模型结构。4.2 推理后处理从start/end logits到一段可展示的答案文本训练保存好模型权重后核心的推理函数并不复杂但有两个细节决定答案质量一是处理end位置在start前面的情况二是把token范围映射回原文文本。基础版本的推理函数长这样with torch.no_grad(): outputs model(**inputs) start_logits outputs.start_logits[0] end_logits outputs.end_logits[0] start_idx torch.argmax(start_logits) end_scores_after_start end_logits[start_idx:] end_idx start_idx torch.argmax(end_scores_after_start) tokens inputs[input_ids][0][start_idx:end_idx 1] answer tokenizer.decode(tokens, skip_special_tokensTrue)这里先取start_logits的最大值作为答案起点然后只在这个起点右侧寻找end_logits的最大值从根本上避免了end_idx start_idx的翻车情况比分别独立取两个argmax再判断大小要稳定得多。tokenizer.decode会把token序列还原为中文文本skip_special_tokensTrue会把[CLS]、[SEP]过滤掉。但要特别注意一个边界如果模型对整个问题都算计不出答案强行取argmax会输出一个噪声片段。应对办法是为模型设置置信度阈值比如start_logits的最大值小于0.5就返回“未找到答案”。这个阈值需要在验证集上试一两个数值找到既不漏答也不乱答的平衡点。4.3 三个必做验证训练集能过拟合、验证集有指标、样例能跑通模型训练完不要急着交报告先做三个验证把质量底线卡住。第一步是拿训练集中的几个样本跑一遍预测确认答案是能从原文抽出来的第二步是跑一遍验证集计算EM完全匹配和F1token级重合度第三步是把问题换成没见过的问法看模型还能不能答对。def compute_em_f1(pred_answer, gold_answer): if not pred_answer or not gold_answer: return 0.0, 0.0 em 1.0 if pred_answer.strip() gold_answer.strip() else 0.0 pred_tokens set(pred_answer) gold_tokens set(gold_answer) common pred_tokens gold_tokens if len(common) 0: return em, 0.0 precision len(common) / len(pred_tokens) recall len(common) / len(gold_tokens) f1 2 * precision * recall / (precision recall) return em, f1这里用字符集合来近似计算F1对中文问答是一个简单可用的近似真实SQuAD评估是按token算的但中文在字级别做F1已经能反映系统水平。更严谨的做法是先用jieba分词再算token级的precision和recall毕设报告建议用分词后的版本讲解时也更显得专业。验证集上EM和F1一般不需要跟原版SQuAD的成绩比你的目标是自己构造的测试集上达到“大部分问题都能抽对、少部分能抽到关键短语”即可这个表现已经足够支撑“方法有效”的结论。5. 避坑与常见问题从下载失败到预测空答案的实测记录5.1 现象模型文件反复下载失败训练脚本一执行就断原因基本都是网络连接不稳定或机器没有外网权限而from_pretrained每次启动都会尝试联网检查一旦资源没完全下载就会在中途报Connection error并中断。解决方式是设置HF_ENDPOINThttps://hf-mirror.com后重新下载或者把整包模型从外部机器拷进来用本地路径加载。注意一个细节直接拷目录时要把config.json和pytorch_model.bin放在同一级不能只拷权重文件否则结构解析失败。5.2 现象显存溢出OOM报错出现在前向传播阶段原因主要是max_length开太大和batch size太大叠加的结果。Bert的显存占用跟输入长度是二次方关系序列越长消耗越夸张。解决方式是把max_length从512降到256或128把batch size从32降到16或8。如果数据本身就有很长的context可以用第3章的truncationonly_second配合stride做滑动窗口而不是一次性塞进模型。还有个容易被忽略的点paddingmax_length会把所有样本都pad到max_length短文本很多时浪费大量显存。5.3 现象模型预测答案跟原文对不上或者经常输出空字符串原因通常是答案的answer_start标错了、答案文本做了改写、特殊符号全半角不一致导致训练时标签偏移量指向了错误位置。预处理环节我还会做一次反向校验把start_positions和end_positions解码回文本跟标注的答案原文比对不一致就打印出来人工修。处理办法是检查offset_mapping的对齐逻辑特别注意(0,0)的特殊token要跳过同时确认context传进的是原始字符串不是清理过空格后的文本。5.4 现象验证集指标不低但换几个真实问题就答非所问原因大概率是训练数据太集中模型只记住了训练集里的语言模式。这是个典型的黑匣子问题——你看到F1有70多以为系统没问题实际换一批问法就翻车。处理办法是扩充问法多样性同一条FAQ写三五种不同的问法比如“怎么改密码”和“如何重置登录密码”都要有让模型见过的表达面足够宽推理阶段它才敢在相似句子上给高分。若数据量实在上不去就把重心放在检索式路线用向量召回替代阅读理解那条路线对数据量的容忍度更高。另外别忘了在推理时把torch.no_grad()包在外面不包会多占不少显存速度也慢。6. 从能跑到能答辩把问答系统做成文档和自洽的技术方案6.1 加一层Flask封装让非技术人员也能测模型代码直接跑命令行没法演示答辩时也很难打动评委。常见的做法是用Flask开一个轻量HTTP服务把predict函数包成一个POST接口前端放一个输入框和显示区域。接口代码不复杂核心就下面这十几行from flask import Flask, request, jsonify app Flask(__name__) app.route(/qa, methods[POST]) def qa(): data request.get_json() question data[question] context data[context] answer, score predict_with_threshold(question, context, threshold0.5) return jsonify({answer: answer, score: score}) if __name__ __main__: app.run(host0.0.0.0, port5000)predict_with_threshold就是在第4章推理函数基础上加了置信度判断的封装版本返回(answer, score)给前端展示。这样整个系统就形成了“数据预处理—模型训练—推理接口—前端展示”的完整链路文档里的系统设计部分正好按这四层来写。6.2 文档结构怎么和代码对应拿到源码之后很多人只看代码其实“文档说明”才是决定得分上限的部分。毕设论文的章节组织有标准套路但每个章节必须跟代码里的实际模块一一对应不能写完设计文档却找不到实现代码这种脱节是最常见的问题。文档章节对应代码/产物写作重点绪论需求背景限定领域问答为什么比开放聊天更实用选Bert而不是BiLSTM的理由需求分析数据格式、功能列表问题类型边界单文档抽取不支持多轮和生成系统设计训练脚本、推理模块给出模型结构图讲清楚start/end双输出头系统实现Flask接口、前端页面关键参数表lr、batch size、max_length、epochs测试验证集EM/F1、案例展示放3-5个典型问答截图以及失败case的分析在写测试章节的时候不能只报喜不报忧我会刻意放两个失败case并解释原因比如“问题有否定词时模型会抽错实体”。这种做法反而会让答辩老师觉得你对系统边界有清晰认识比全面吹捧效果更可信。6.3 三个可预见的进阶点完成最小系统后如果你还有精力升级路线有三条。一是把模型从bert-base-chinese换成hfl/chinese-roberta-wwm-ext它用了全词掩码训练策略在中文下游任务上一般能比Bert高1到2个点的F1换模型就一行代码的事训练和推理代码完全不用改。二是加检索模块先对知识库做切句和向量化问题来了先召回top10段落再送进Bert精排这套“召回重排”结构即使数据量再翻一倍也撑得住。三是用torch.onnx.export把模型导出成ONNX推理速度能快上不少尤其是CPU环境下体验提升非常明显。我的个人习惯是每次跑出一个新结果先截一条案例图存下来最后攒成实验结果表到了写测试报告的时候根本不用临时找素材。问答系统这种项目效果达到及格线不难难的是把整个工程链路讲清楚——而这条链路恰好也是评委最想听到的东西。希望帮到你。本文还有配套的精品资源点击获取
返回列表