ARTICLE DETAIL

资讯详情

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

基于PaddleNLP的统一生成式模型:汽车问答摘要与推理实战

基于PaddleNLP的统一生成式模型:汽车问答摘要与推理实战 简介这是一份百度飞桨常规赛「汽车大师问答摘要与推理」赛题的参赛源码与项目说明打包适合计算机、数学、电子信息等专业学生用于课程设计、期末大作业或毕业设计参考。压缩包共43个文件其中41个Python脚本构成完整训练与评估流程含数据预处理、batcher、transformer_pgn模型、训练测试辅助模块等另附1份Markdown项目说明和1张二维码图片便于快速了解项目结构。包体仅47KB轻量但代码逻辑完整可直接下载运行调试。目前已有76人学习适合需要借鉴实际NLP竞赛方案、研究序列到序列摘要生成或想在飞桨框架下实践深度学习项目的读者。通过源码与说明可学习数据读取、模型搭建、训练评估等环节的工程实现为自身项目提供可复用的代码范式。1. 汽车大师问答摘要与推理从赛题标题到一套可复现的NLP方案“汽车大师问答摘要与推理”这个标题看见“大师”“问答”就知道不是常规的文本分类任务。它要求对一段多轮汽车维修对话做两件事把技师们的讨论压缩成一段摘要再直接回答用户最初的问题。难点在于技师回答里可能互相矛盾、带口头语甚至用户追问改写了原始问题。很多参赛者第一反应是分别做抽取式摘要和阅读理解但这样要维护两套模型还要处理中间结果对不上号的问题。下面这套基于百度飞桨PaddleNLP的方案把摘要和推理统一成一个文本生成任务用一个生成模型同时产出摘要和答案用一个脱敏后的开源汽车问答子集就能跑通验证按同样的路径可以迁移到其他客服对话场景。适合正在上手生成式NLP竞赛的工程师也适合想快速搭一套问答摘要baseline的团队。2. 用飞桨把摘要和推理统一成生成任务数据构造与标签设计2.1 为什么生成式模型比抽取式更适合这个赛题常规的文本摘要做法是先做抽取把对话里信息密度高的句子挑出来组成摘要再做阅读理解从对话中查找答案。这个思路在单一文本上没问题但汽车大师问答里的“多轮对话”暴露了它的短板用户追问可能把原始问题带偏技师A说“可能是火花塞问题”技师B说“检查一下点火线圈”用户最后一句“那到底换哪个”要求模型综合两个人的观点给出结论。抽取式模型在“抽取”这一步就已经丢掉了生成结论必须的推理关系后面接再强的阅读理解也补不回来。生成式模型把整个过程变成一个序列到序列的映射输入是一段按顺序拼接的对话输出是一段结构化文本前一半是摘要后一半是答案。模型在训练时可以直接学习到“多个技师回答中出现分歧 - 摘要要写争议点 - 答案要给出排除方法”这类隐含逻辑。百度飞桨的PaddleNLP中已经有现成的生成模型实现不需要自己搭Transformer直接加载预训练权重微调即可。下面先解决一个关键问题原始数据怎么变成模型的输入和输出。2.2 数据清洗与多轮对话拼接先解决输入侧竞赛数据通常是JSONL一行一个样本字段包括question、conversation、summary、answer。conversation是一个列表每个元素包含role和text。第1步是清洗带上正则去掉HTML标签、全角空格和首尾空白不要使用自定义分词直接交给tokenizer。第2步是拼接成带角色的文本块模型依靠“用户问题”“技师”这样的前缀区分不同说话人。import re def clean_text(text): text re.sub(r[^], , text) text text.replace(\u3000, ).strip() return text def build_input(question, conversation, max_turns6): turns [t for t in conversation if t.get(text)] turns turns[-max_turns:] # 保留最近 N 轮防止输入超过模型上限 lines [f用户问题{clean_text(question)}] for turn in turns: role 技师 if turn.get(role) mechanic else 用户 lines.append(f{role}{clean_text(turn[text])}) return \n.join(lines)这里max_turns6是经验值。多数汽车维修对话的有效信息集中在最近6轮更早的内容大多是“你好”“大概多少钱”这类寒暄。截断策略取末尾几轮而不是开头因为用户最后追问的内容往往决定最终答案。如果你把技师A和技师B的回答顺序交换结论会变所以拼接顺序必须按时间戳保证。2.3 标签设计摘要和答案共用一段目标文本一般有两种设计。第一种是把summary和answer拼成一行中间用[SEP]分隔第二种是把answer放前面、summary放后面。我更倾向于第一种原因有二摘要和答案在语义上是“整体结论”与“具体结论”的关系摘要先出现可以让生成模型把全局信息放在注意力靠前的位置事后切分也简单遇到模型只生成摘要时至少前半部分还可用。表格对比一下两种方案方案 | 目标文本示例 | 适用情况 | 风险 统一生成 | 摘要 [SEP] 答案 | 赛题要求同时输出两个字段 | 答案可能被摘要“带偏” 两阶段生成 | 先摘要模型再答案模型 | 两个目标风格差异大 | 误差累积训练成本翻倍在这套方案里选统一生成目标构造只有一行def build_target(summary, answer): # 中间加一个显式分隔符推理阶段按它切分摘要和答案 return f{summary} [SEP] {answer}这里的[SEP]按普通文本处理不要按special token处理。生成式模型在解码时对普通字符的保留率高按特殊token处理反而容易被过滤掉。训练数据里摘要或答案本身包含“ [SEP] ”的概率极低可以不做额外清洗。2.4 数据去重与长尾过滤别让模型学会“谢谢”汽车问答标注样本里有大量“感谢师傅”“问题解决了”这类客套话。如果这些样本的答案字段就是“不客气”模型会把常见问题都答成“不客气”。清洗时按答案长度和内容做两层过滤答案长度小于4的直接删除答案匹配“谢谢|感谢|不客气”规约的样本降权处理。降权做法是在构造Dataset时给这类样本设置采样权重或者在loss上乘一个系数不需要改代码结构。这一步对常规赛排名影响明显尤其是线下和线上分数差0.5左右时排除数据噪声通常是第一排查点。3. 基于PaddleNLP训练生成式摘要推理模型代码结构与训练循环3.1 选型加载哪个预训练模型PaddleNLP里适合这个场景的中文生成模型是UNIMOTextForConditionalGeneration配套的Tokenizer是UNIMOTextTokenizer。选择理由一是它有完整的中文预训练参数覆盖新闻、百科、社区问答二是模型参数量适中单卡V100或A100都能微调三是PaddleNLP 2.x的API把generate也统一了从训练到推理不需要切换框架。如果显存小于16G可以把输入上限从512降到384同时把batch_size减半不建议换小模型因为这类问答推理对语言理解要求高。from paddlenlp.transformers import UNIMOTextForConditionalGeneration, UNIMOTextTokenizer model_name unimo-text-1.0 model UNIMOTextForConditionalGeneration.from_pretrained(model_name) tokenizer UNIMOTextTokenizer.from_pretrained(model_name) print(模型加载完成参数量, sum(p.numel() for p in model.parameters()))from_pretrained会自动下载权重第一次执行需要联网。这里numel()只是确认参数规模不用于训练。如果公司内网限制下载可以提前把权重文件放到~/.paddlenlp/models下离线加载。3.2 自定义Dataset和DataLoader需要把清洗后的数据构造成paddle接受的格式。先定义转换函数import json import paddle from paddlenlp.datasets import MapDataset def convert_example(example, tokenizer, max_src_len512, max_tgt_len128): source build_input(example[question], example[conversation]) target build_target(example[summary], example[answer]) source_ids tokenizer.encode(source, max_lengthmax_src_len, truncationTrue)[input_ids] target_ids tokenizer.encode(target, max_lengthmax_tgt_len, truncationTrue)[input_ids] return { input_ids: paddle.to_tensor(source_ids, dtypeint64), labels: paddle.to_tensor(target_ids, dtypeint64), } def create_dataloader(path, tokenizer, batch_size8, shuffleTrue): with open(path, encodingutf-8) as f: raw [json.loads(line) for line in f] dataset MapDataset([convert_example(x, tokenizer) for x in raw]) return paddle.io.DataLoader(dataset, batch_sizebatch_size, shuffleshuffle)tokenizer.encode返回一个token dict取input_ids。这里不需要显式传attention_maskUNIMO的forward会自动构造。paddle.io.DataLoader要求数据是Tensor或可转换类型这里已经是Tensor。如果你后续要加样本权重可以在convert_example里额外返回weight字段训练时手动乘到loss上。3.3 训练循环注意UNIMO的labels不需要右移很多人在这个坑里浪费半天。用T5或BART时labels通常要右移一位让模型预测下一个token。UNIMO的ForConditionalGeneration内部已经处理了shift你只需要把目标序列的input_ids当作labels传进去loss就是标准的交叉熵。def train_one_epoch(model, dataloader, optimizer, epoch, log_interval50): model.train() total_loss 0 for step, batch in enumerate(dataloader): input_ids batch[input_ids] labels batch[labels] loss model(input_idsinput_ids, labelslabels)[0] loss.backward() optimizer.step() optimizer.clear_grad() total_loss float(loss) if step % log_interval 0: print(fepoch {epoch}, step {step}, loss {total_loss / (step1):.4f})loss返回的是一个0维Tensor取.item()也行用float()更稳妥。optimizer.clear_grad()对应PyTorch的zero_grad()漏了会导致梯度累加。训练时把模型切到model.train()虽然UNIMO里没有BatchNorm但dropout的行为依赖这个状态。3.4 关键训练参数与显存调节表格里的参数是我在一台16G显存机器上验证过的中位值不是唯一解参数推荐值说明learning_rate5e-5超过2e-4模型会忘掉预训练知识batch_size816G显存可用832G可用16max_src_len512输入超过512会被截断最好按长度统计设置max_tgt_len128摘要答案的平均长度约60-100num_beams4推理时才用训练不影响epochs33000条训练样本3轮足够再多会过拟合其中max_tgt_len如果设置太短答案常被截断设置太长推理变慢且容易生成多余内容。建议在训练前打印训练集目标文本长度分布取95分位数当作上限。3.5 用ROUGE和BLEU做线下验证每轮保存一次模型在验证集上算ROUGE和BLEU。PaddleNLP没有内建ROUGE用rouge包和nltk。from rouge import Rouge from nltk.translate.bleu_score import sentence_bleu def evaluate(model, dataloader, tokenizer): model.eval() preds, refs [], [] for batch in dataloader: input_ids batch[input_ids] outputs model.generate(input_idsinput_ids, max_length128, num_beams4) decoded tokenizer.batch_decode(outputs, skip_special_tokensTrue) for pred, label in zip(decoded, batch[labels]): ref tokenizer.decode(label.numpy(), skip_special_tokensTrue) preds.append(pred.replace([SEP], )) refs.append(ref.replace([SEP], )) rouge Rouge() return rouge.get_scores(preds, refs, avgTrue)这里ref是整段目标文本评测指标会把摘要和答案一起算。如果赛题要求分开算就在解码后按[SEP]切开再分别计算ROUGE。线下分数和线上分数差距过大的原因通常是标签顺序或者切分逻辑不一致排查顺序是文本编码格式 - [SEP]切分 - 评测脚本是否对文本做了全角半角归一化。4. 推理阶段怎样把摘要和答案拼成合格提交解码参数与后处理4.1 beam search与length penalty的配合训练完成后推理要用model.generate而不是重新跑forward。这里最影响结果的是num_beams、length_penalty和no_repeat_ngram_size。汽车问答句子里面常有“检查一下”“看看是不是”这种重复结构不限制重复会降低ROUGE。我一般用num_beams4length_penalty0.6no_repeat_ngram_size3。length_penalty小于1能让模型更短因为摘要本来就不需要太长大于1会把答案带跑偏。beam size不是越大越好从4调到6通常ROUGE只涨0.2耗时却翻倍。outputs model.generate( input_idsinput_ids, max_length128, min_length10, num_beams4, length_penalty0.6, no_repeat_ngram_size3, early_stoppingTrue, ) pred tokenizer.decode(outputs[0], skip_special_tokensTrue)min_length10防止模型输出空摘要early_stoppingTrue是在所有beam都生成eos时提前结束能省时间。这里input_ids需要shape为[1, seq_len]的Tensor。skip_special_tokensTrue可以去掉句首句尾符号但不会去掉普通文本里的[SEP]。4.2 把预测文本安全拆成摘要和答案模型输出可能是“摘要 [SEP] 答案”也可能是“摘要”还可能多出前后空白。后处理不能只做split([SEP])需要在异常分支里兜底def split_prediction(pred, sep [SEP] ): if sep in pred: summary, answer pred.split(sep, 1) elif \n in pred: summary, answer pred.split(\n, 1) else: parts pred.strip().split(。) if len(parts) 1: summary 。.join(parts[:-1]) 。 answer parts[-1] else: summary, answer pred, return summary.strip(), answer.strip()优先按分隔符切其次按换行最后按句号切。按句号切分是应急手段如果模型只输出一句话就把整句当摘要答案留空。竞赛提交时答案不能为空所以最后可以补一个默认值“请到店检查”保证提交文件格式通过。4.3 批量推理时如何避免OOM测试集可能上万条一次把整个测试集塞进generate显存会崩。常见做法是一个batch一个batch向前传且生成时要确认output_scoresFalse否则每个beam的score张量会额外占用显存。def predict_batch(model, tokenizer, test_data, batch_size16): results [] for i in range(0, len(test_data), batch_size): batch test_data[i:ibatch_size] input_ids paddle.to_tensor(batch[input_ids]) outputs model.generate( input_idsinput_ids, max_length128, num_beams4, length_penalty0.6, no_repeat_ngram_size3, ) preds tokenizer.batch_decode(outputs, skip_special_tokensTrue) for pred in preds: results.append(split_prediction(pred)) return resultsbatch_size16只对推理显存友好如果beam size大batch要相应调小。生成时每个beam都会复制一份上下文显存占用是训练时的数倍。如果16G显存还在OOM先用nvidia-smi看当前占用再把batch_size降到4。4.4 提交文件格式与常见线上报错赛题一般要求输出JSON或CSV。以JSON为例import json with open(submission.json, w, encodingutf-8) as f: json.dump([{summary: s, answer: a} for s, a in results], f, ensure_asciiFalse, indent2)注意ensure_asciiFalse否则中文全变成\uXXXX线上评测可能直接判格式错误。其他常见问题用表格列出现象原因处理线上分数为0提交字段名或JSON结构不符对比官方样例确认字段顺序生成结果全是同一句话测试集预处理截断了所有input检查build_input里的max_turns和tokenizer的max_length本地BLEU正常线上ROUGE很低摘要和答案的切分方式不一致统一用[SEP]前后部分分别评估显存OOMbatch_size和num_beams乘积过大降低batch_size优先再减num_beams到25. 让排名再涨一截数据增强、模型配对与常见坑5.1 用相似对话拼接做数据增强统一生成模型只有在训练数据分布和解码顺序合理时才能发挥好。汽车大师数据量通常只有几千条最容易涨分的增强是“样本配对”把两条对话的用户问题互换但保留技师回答。这个思路基于一个假设同样的维修结论在不同问法下应该输出相同摘要和答案。实现上不能随便互换要限制两条对话的技师回答长度接近避免模型学到噪声。def paired_augment(examples): augmented [] parts [ex for ex in examples if 20 len(ex[summary]) 120] for i in range(0, len(parts) - 1, 2): a, b parts[i], parts[i1] augmented.append({**a, question: b[question]}) augmented.append({**b, question: a[question]}) return augmented这里parts按摘要长度过滤确保配对样本质量。如果训练集中已有相似问题模型会产生困惑所以只增强一次不重复叠加。5.2 摘要和答案用不同解码策略分开拿统一生成模型在训练时看到的是“摘要 [SEP] 答案”但推理时让两个字段共享同一套beam search并不合理摘要要求精简答案要求准确。一个实用技巧是先按第4章的生成方式拿到完整预测再把答案部分单独拿出来用num_beams1针对原对话重新生成一遍最后拼回摘要。这样答案不走“摘要先占用beam”的路径准确率更高。代价是推理时间翻倍适合排行榜冲刺阶段。5.3 让代码从训练到提交可复现的3个细节固定随机种子在脚本开头加paddle.seed(42)注意DataLoader的shuffle用的是全局随机数要同时设置Python内置random.seed(42)。保存最优模型时别只存weight要把tokenizer一起存。重新加载时使用model UNIMOTextForConditionalGeneration.from_pretrained(./best_model)避免下载基础模型版本不一致带来的tokenizer id漂移。预处理函数单独抽成preprocess.py训练和推理共用。常见翻车场景是训练时对摘要做了strip推理时忘了对输出做strip导致ROUGE字符级差异。如果验证集分数不再变化试着把num_beams从4调到6length_penalty从0.6微调到0.8观察线上提交是否还能往上走。本文还有配套的精品资源点击获取
返回列表