ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Python基于BERT的智能问答系统:从抽取式问答到毕设落地

Python基于BERT的智能问答系统:从抽取式问答到毕设落地 简介这是一个基于Bert的中文智能问答系统毕业设计项目面向计算机专业学生和NLP初学者特别适合用于课程设计、毕业设计或项目实战练习。项目主要解决如何利用预训练模型完成知识图谱问答与命名实体识别的问题聚焦KBQA与NER两大核心任务融合Bert、LSTM-CRF等经典结构提供完整可运行的源码和文档说明设计评审得分98分具备较高参考价值。压缩包共57个文件大小约1.62MB文件以Python脚本为主约25个py文件辅以Markdown说明文档、XML/JSON等配置文件、Shell脚本以及训练与测试数据集目录涵盖模型参数、输出结果、数据预处理、实验结果日志等模块结构清晰易读。已有100人学习下载使用者可从中获得从数据构造、实体识别训练到相似度计算的完整实现思路包含NLPCC2016知识图谱数据集与相关预处理脚本配合中文文档能快速复现和调试。项目源码已经过严格调试可直接运行适合在此基础上扩展为实用的智能问答系统。1. Python基于Bert的智能问答系统为什么它是高分毕设里性价比最高的一档“python基于bert的智能问答系统”近年在NLP方向的毕业设计里出镜率很高。很多人觉得它难是因为一上来就想做大而全的开放问答实际按“给一段文本、问一个问题、在原文中框出答案区间”的阅读理解范式来做模型、训练、指标、演示全都可控。它的价值是把数据清洗、预训练模型微调、评估体系、交互演示四条线一次性串起来正好覆盖毕业设计评审需要的完整证据链。适合NLP基础一般但想拿一段完整项目经历的同学也适合想在四到六周内产出一版可演示成果、并有余力加入领域数据的人。即使手里只有一张显卡甚至只靠CPU推理这个方向依然可控真正劝退人的不是模型复杂度而是数据编码和标签对齐里的那些细节。2. Bert问答三条技术路线生成式、检索式和抽取式怎么选2.1 同样叫问答先分清楚三种任务形态围绕“智能问答系统”这个标题第一件要厘清的事是同样叫问答底层任务有三种训练成本、演示效果和答辩可解释性差别很大。生成式用类似T5或Bart的seq2seq模型逐字生成答案能从上下文里组织出一句完整回答。听起来最先进但普通显卡训练门槛高而且模型会“编答案”生成一段原文里根本没有的内容。这种不可控性在答辩演示时最危险评审问“这答案从哪来的”你很难解释清。检索式把问答拆成召回和排序两段先用句向量在知识库里找Top-K候选再让排序模型挑最接近的答案。它适合开放域难点在于知识库组织、负样本构造和阈值选择每一样都要额外调一段时间评估指标也容易变成“命中就算过”说服力弱。抽取式完全复用机器阅读理解范式。它把question和context拼成BERT的一段输入模型在token序列上预测答案的起始位置和结束位置。模型不生成新内容只做原文区间选择答案天然是原文的子串没有自由生成带来的幻觉压力。输出可以直接计算EM精确匹配和F1重叠度答辩时一张表就能说明白。路线任务定义典型模型训练成本答案可控性毕设适配度生成式生成自由文本T5 / Bart高低可能幻觉一般不建议检索式召回加排序DPR / Sentence-BERT中中依赖知识库适合开放域选题抽取式预测start和endBertForQuestionAnswering低高答案来自原文最推荐这张表可以直接放进毕设文档的“技术选型”一节改写成你自己的对比分析。选题时另一个常被追问的问题是为什么用Bert而不是更新的预训练模型。常见回答是Bert-base参数规模约1.1亿单卡可训、CPU可推且问答任务上公开实验多、可解释材料充足越大越新的模型对毕设算力要求不友好反而不容易收口。2.2 中文数据集怎么选公开数据、自建数据还是混合用抽取式问答的中文数据主要有三类来源。最常见的公开数据集是CMRC 2018任务定义就是给定context和question要求给出答案起止位置DRCD来自繁体中文语料需要先做转简体和标点统一SQuAD是英文集但流传的中文翻译版标点、空格和答案偏移在二次清洗时很容易出错个人不推荐毕设直接用它。如果想让系统带点“领域定制”的味道可以自建几百到上千条问答对。这类自建数据规模不大但注释格式必须和模型读取格式一致。通常使用SQuAD风格的JSON格式一条context下面挂若干qasanswers里同时存答案文本和answer_start。这里有个关键认知answer_start是字符偏移而不是token偏移中文里一个汉字按一个字符计算。后面做输入编码时还要把字符偏移换算成token偏移这地方被很多人说成“玄学”其实只是一次逐token区间匹配。{ context: 广东工业大学校训是团结、勤奋、求是、创新。, qas: [ { question: 广东工业大学的校训是什么, answers: [ {text: 团结、勤奋、求是、创新, answer_start: 13} ] } ] }这段JSON里“团结、勤奋、求是、创新”从第13个字符开始。“广东工业大学校训是”正好13个字这个偏移必须在写完标注脚本后手工验一遍。评估时EM会去掉标点再比但answer_start仍以原始文本为准否则F1计算会出现答案截半的假阳性。2.3 先把文档说明的结构想清楚再决定代码怎么写高分毕设通常要求“源码文档说明”这里的文档不是配角。很多同学先跑通代码再补文档最后发现训练参数、数据统计、实验结果三处对不上只能临时改图。我一般会把文档结构当作写代码时的清单任务定义、数据处理、模型结构、实验设计、结果分析五个板块代码每完成一个阶段就把日志和数据量记录到对应板块。还有一个必须提前定下来的内容验收指标。建议文档写清楚要报告EM和F1不只报测试集整体分数还要按context长度分段、按问题类型分段统计指标比如短段落和长段落差多少。这些分段统计会直接影响第3章滑动窗口的参数设计所以文档框架要先立住再写代码。3. 把问答数据喂给BERT输入编码、滑动窗口与标签对齐这章是整篇最容易翻车的地方。数据不对模型训得再久也是白费。动手前先确认Python环境、模型权重目录和数据集的原始JSON格式。3.1 先固定依赖版本bert模型参数提前放到本地目录python安装教程很多真正难的是把transformers、torch和数据集库的版本关系理清楚。毕设机器环境五花八门最怕的不是模型不收敛而是训练到一半版本冲突。按顺序建环境最省心。conda create -n qa python3.8 -y conda activate qa pip install torch transformers datasets安装顺序要点先把torch装上再装transformers和datasets。transformers在运行时会依赖torch的设备管理能力顺序反了容易出现“装好了transformers但导入报错”的问题。如果训练机不能直接访问外网下载模型就在能联网的机器上把bert-base-chinese目录整个下载下来连同配置文件、词表和权重一起拷贝到项目model子目录代码改为from_pretrained(model/bert-base-chinese)它就不会每次启动都去检查网络。这是bert参数下载的离线下分发做法很多实验室机器都这么用。3.2 用AutoTokenizer把question和context拼成一条输入BERT处理问答的标准输入格式是[CLS] question [SEP] context [SEP]。transformers的AutoTokenizer可以直接完成这种拼接不需要手工拼字符串再去tokenize。from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(model/bert-base-chinese) def encode_qa(question, context, max_length512): encoding tokenizer( question, context, max_lengthmax_length, truncationonly_second, stride128, paddingmax_length, return_offsets_mappingTrue, return_tensorspt, ) return encoding这里三个参数容易出错。truncationonly_second表示超长时只截断第二段text_pair也就是contextquestion不会被牺牲。stride128是滑动窗口的重叠步长让靠后部分的答案有机会跨窗口被召回。return_offsets_mapping会在分词后返回每个token在原始文本里的字符起止位置这是把答案从字符偏移换算成token偏移的必需信息。注意当打开overflowing_tokens时offset_mapping是二维的第一个维度对应窗口序号。3.3 把答案字符偏移对齐成start/end token位置对齐这一步是问答系统的核心黑匣子错一位就全错。步骤是先拿到答案的字符区间start_char到end_char然后遍历offset_mapping找到第一个覆盖答案起始字符的token记为start_token再找到第一个覆盖答案结束字符的token记为end_token。def find_span(offset_mapping, answer_start, answer_end): start_token None end_token None for idx, (char_start, char_end) in enumerate(offset_mapping): if start_token is None and char_start answer_start char_end: start_token idx if end_token is None and char_start answer_end char_end: end_token idx if start_token is not None and end_token is not None: return start_token, end_token return None逻辑说明字符区间如果落在某个token的内部就取这个token的索引。这样即使答案和分词边界错位也能以token粒度去预测。容易忽略的是end_token的判断条件右侧用了char_start answer_end char_end而不是。原因是一个字符的结束位置恰好等于下一个token的起始位置时它应该归入前一个token用错边界会导致预测答案永远多出半个字。3.4 滑动窗口截断时答案可能不在窗口里当context超过max_lengthtokenizer会按stride把长文本切成多个窗口。每个窗口都要判断一次答案是否完整落在里面完整才保留不完整直接跳过绝不要把半个答案硬塞进标签。def build_qa_samples(example, max_length512, doc_stride128): samples [] encodings tokenizer( example[question], example[context], max_lengthmax_length, truncationonly_second, stridedoc_stride, return_overflowing_tokensTrue, return_offsets_mappingTrue, paddingmax_length, ) answer_start example[answer_start] answer_end answer_start len(example[answer]) for i, offset_mapping in enumerate(encodings[offset_mapping]): span find_span(offset_mapping, answer_start, answer_end) if span is None: continue start_token, end_token span samples.append({ input_ids: encodings[input_ids][i], attention_mask: encodings[attention_mask][i], token_type_ids: encodings[token_type_ids][i], start_positions: start_token, end_positions: end_token, }) return samplesdoc_stride参数如果设得过大答案跨窗口丢失的概率变高设得过小样本数膨胀训练变慢。常见做法是设为max_length的四分之一512配128。还有一种边界答案本身长度超过单个窗口比如把一段摘要当作答案。此时要么把答案截断到窗口内再标注要么换更长的序列配置而不是硬塞进滑动窗口。3.5 把样本封装成Dataset让Trainer直接读取build_qa_samples返回一个list每个元素都是等长的训练样本包含input_ids、attention_mask、token_type_ids和start/end标签。最后再包一层torch.utils.data.Dataset让transformers的Trainer能直接消费。import torch class QADataset(torch.utils.data.Dataset): def __init__(self, samples): self.samples samples def __len__(self): return len(self.samples) def __getitem__(self, idx): sample self.samples[idx] return { input_ids: torch.tensor(sample[input_ids], dtypetorch.long), attention_mask: torch.tensor(sample[attention_mask], dtypetorch.long), token_type_ids: torch.tensor(sample[token_type_ids], dtypetorch.long), start_positions: torch.tensor(sample[start_positions], dtypetorch.long), end_positions: torch.tensor(sample[end_positions], dtypetorch.long), }封装完成后把train、dev、test三个集合分别保存成独立的训练样本文件。这里常见的坑是token_type_ids部分新版本tokenizer会把token_type_ids从model输入里移除因为新的模型可以在没有它的情况下工作但QA任务还是需要它来区分question和context两个语义区域所以统一保留不删。4. 训练与调参用Trainer跑通并盯住EM和F14.1 用Transformers的Trainer做标准微调数据质量过关后训练是相对标准化的部分。transformers的Trainer封装了batch、梯度更新、日志、保存等细节毕设代码用它的可读性也最好。from transformers import BertForQuestionAnswering, Trainer, TrainingArguments model BertForQuestionAnswering.from_pretrained(model/bert-base-chinese) training_args TrainingArguments( output_dircheckpoints/qa, num_train_epochs3, per_device_train_batch_size16, per_device_eval_batch_size16, learning_rate3e-5, warmup_ratio0.1, logging_steps50, save_steps500, evaluation_strategyepoch, load_best_model_at_endTrue, fp16True, ) trainer Trainer( modelmodel, argstraining_args, train_datasetqa_train_dataset, eval_datasetqa_dev_dataset, ) trainer.train()BertForQuestionAnswering是一个包含BERT主干加两个线性输出头的模型一个预测start位置一个预测end位置。训练时它接收start_positions和end_positions两个标签内部计算交叉熵。fp16只在显卡支持混合精度时打开可以明显降低显存占用CPU环境不要开。epoch结束时会自动计算loss并保留效果最好的checkpoint这就是答辩报告里“最优模型按验证集挑选”的来源。4.2 五个必调参数先按参考值跑再根据现象定向调以下参数是微调BERT做抽取式问答的常规起点。这些数字来自公开实验的经验区间不是玄学。按表里的值跑一遍再根据三天的实验现象做调整。参数参考值作用失效现象与调整max_seq_length512或384决定输入序列上限答案找不到时加滑动窗口不要无限加长度per_device_train_batch_size16或8单卡显存占用显存OOM就先减半再用gradient_accumulation_steps补learning_rate3e-52e-5到5e-5微调学习率loss震荡就降loss不降就看数据对齐warmup_ratio0.1前10%步数线性升学习率训练初期loss爆炸就调大它num_train_epochs32到4全量数据遍历次数验证EM不再上升就早停调参原则是一次只动一个变量每次实验的EM、F1、训练时长记在表里。高频翻车现象是loss下降但验证EM不涨这多半不是参数问题而是第3章的标签对齐错了。4.3 评估指标EM和F1的代码与解读抽取式问答的常用指标是EM和F1。EM是精确匹配答案经过标准化后完全一致才算对F1是所有测试样本预测token与真实token之间的精确率和召回率的调和平均。两条曲线要一起看EM太苛刻F1太宽松合在一起才有说服力。import re import string import collections def normalize_answer(s): def remove_articles(text): return re.sub(r\b(a|an|the)\b, , text) def white_space_fix(text): return .join(text.split()) def remove_punc(text): exclude set(string.punctuation) return .join(ch for ch in text if ch not in exclude) return white_space_fix(remove_articles(remove_punc(s.lower()))) def exact_match(pred, gold): return int(normalize_answer(pred) normalize_answer(gold)) def token_f1(pred, gold): pred_tokens normalize_answer(pred).split() gold_tokens normalize_answer(gold).split() common collections.Counter(pred_tokens) collections.Counter(gold_tokens) num_same sum(common.values()) if num_same 0: return 0.0 precision num_same / len(pred_tokens) recall num_same / len(gold_tokens) f1 (2 * precision * recall) / (precision recall) return f1normalize_answer先把英文标点、冠词、大小写统一再用split切词。中文走同样逻辑时string.punctuation不包含中文标点需要把“。、”这类字符一并加入过滤集合否则评估口径会不一致。代码跑完后建议先人工构造一组标准答案和预测答案确认EM和F1结果符合预期再上全量测试集计算。5. 避坑排查这五类问题答辩前自己先翻一次车5.1 现象训练刚开始就OOM显存直接打满训练一开始就报CUDA out of memory是最常见的起步坑。原因不只是batch_size还有max_seq_length。BERT的中间激活值大小和序列长度近似线性相关当max_length取512、batch_size取16时8GB显存基本到顶。解决方式先per_device_train_batch_size减半再检查max_seq_length仍不够时开启gradient_accumulation_steps用多个小batch累积梯度模拟大batch。这样牺牲一点训练速度但能让流程先跑起来。5.2 现象预测答案总多一个字或少一个字推理阶段经常出现答案边界偏差。原因大概率是token和字符的边界对不上。BERT的WordPiece会把一部分中文词组拆成更细的子token简单用字符下标当作token下标自然会错。解决方式以offset_mapping作为唯一依据从字符区间换算token区间不要手工猜位置。推理时同样用offset_mapping把模型输出的token区间反解回字符串。这一步在前处理里做好了推理阶段基本不会出现边界问题。5.3 现象loss降到很低但验证集EM几乎为0这是最隐蔽的翻车现场。loss正常下降、模型没有爆炸但验证集一个精确匹配都出不来。原因通常是滑动窗口切分后label对错了样本某个实现没有检查第i个窗口是否真的包含答案把所有窗口的start和end全部标成同一个值模型学到的是错位映射。解决方式写一个数据自检脚本随机抽几十条样本打印question、context、answer、start_token、end_token以及用tokenizer.decode(input_ids[start_token:end_token1])得到的文本确认解码文本和真实答案一致。这个过程只要做一次能省掉后面几天无用功。5.4 现象标准化代码评估出来的F1忽高忽低评估脚本昨天跑F1是0.82今天重跑变成0.79。原因多数是normalize_answer没有统一处理中文标点。string.punctuation只覆盖英文标点中文的逗号、句号、顿号不会被清除导致对比时基准不一致。解决方式在remove_punc中显式加入中英文标点集合。更稳妥的做法是把评估脚本里涉及标准化逻辑的部分单独抽出来配若干条人工用例跑一遍再上全集。评估口径一致性在毕设文档的“实验设置”一节里要写清楚否则评阅老师自己重跑一遍对不上数整个实验可信度都会受质疑。5.5 现象答辩演示时点一次要等几秒甚至答案落到[SEP]上演示阶段如果每次都重新加载模型、每次都用大batch延迟会很难看。另一个经常出现的问题是推理代码没有对logits做maskstart和end的预测最高分落在[SEP]或padding token上答案直接越界。解决方式模型只在启动时加载一次之后放在显存里复用对start_logits和end_logits做一个mask只保留question和context覆盖到的token位置padding位置全部置为负无穷预测区间反解后还要用答案长度约束做一次修正。演示脚本里固定十几个典型问题答辩前整轮跑一遍确认输出稳定再上场。这五条踩坑经验对应五条不同的排错路径根子都落在数据编码、模型推理和评估口径三块。答辩前花半小时把这几类问题自查一遍比临时改参数靠谱得多。6. 把“文档说明”写成证据链结构、表格与断网自查6.1 文档按“三分钟能看懂”的结构组织高分毕设的文档说明核心是让评审快速找到三类东西任务为什么这样定义、数据怎么来的、结论由哪些数字支撑。建议按“摘要、背景与任务、数据说明、模型方案、实验设计、结果分析、总结”的顺序写。摘要不写空话直接给结论任务是什么、用了什么模型、最优EM和F1是多少。数据说明部分放一张表公开数据集和自建数据集各多少条答平均长度、context平均长度、滑动窗口参数是多少。实验分析部分要有对比至少放两组bert-base-chinese微调结果对比随机初始化结果不同训练数据量对EM的影响。这两组对比是答辩时最常被追问的提前做掉能挡掉一半问题。6.2 源码里的README和演示脚本源码说明不是把训练的代码复制进文档而是把运行步骤、数据格式、复现指标三段写清楚。运行步骤要能从零开始下载数据、跑预处理脚本、跑train.py、跑eval.py、跑demo.py。数据格式要给出一个和上面JSON样例一致的新样例。复现指标要写明在什么版本依赖、什么随机种子下得到的EM和F1。演示阶段还有一个容易忽略的细节线上答辩网络波动、推理变慢都可能发生。最好把模型、数据、演示脚本全部放到本地离线跑通。我每次答辩前都会做一件事把训练日志最后一轮的EM、F1、总样本数、模型参数表一并截屏和演示样例放到同一个目录。老师问指标直接截屏调出来而不是现场翻终端。这个习惯帮我挡过不少次现场追问也推荐你建立起来。希望帮到你。本文还有配套的精品资源点击获取
返回列表