ARTICLE DETAIL

资讯详情

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

医学NLP实战:基于ERNIE与RoBERTa的Query相关性判断源码解析

医学NLP实战:基于ERNIE与RoBERTa的Query相关性判断源码解析 简介这是一份针对天池自然语言处理医学搜索查询相关性判断赛题的深度学习课程设计与毕业设计项目内含Python源码与完整文档说明。代码已测试运行成功答辩评审平均分达96分适合计算机、人工智能、电子信息等专业学生用于课设、毕设或入门进阶也可作为项目初期演示。压缩包共30个文件以8个Python脚本为核心配合7个JSON配置、5个XML工程文件、3个日志文件和2个Markdown说明文档整体仅370KB目录结构清晰便于按模块研读。项目中完整覆盖数据增强、工具函数、训练评估等环节并集成ERNIE、RoBERTa等多种预训练模型调用可帮助理解医学文本相关性判断的完整流程。已有120人学习下载。透过这份资源可获得可直接运行的赛题方案、模型调优思路与配套文档说明若运行遇到问题还可联系作者远程教学指导。1. 一份让课设答辩拿96分的天池NLP医学Query相关性判断源码当年做课设时我最大的困扰不是不懂BERT原理而是拿着开源代码却不知道从哪跑起环境配好、路径对好、显存不炸每一步都在踩坑。这份源码包是天池比赛【NLP】医学搜索Query相关性判断的高分实现训练数据和预训练权重都打包好了用ERNIE和RoBERTa做文本对分类微调输入是医学用户的query和候选title输出三档相关性标签。代码全部测试通过答辩评审平均分96分适合计算机、人工智能相关专业做课程设计也适合入门NLP的工程师参考一套完整的数据预处理、模型微调、评估提交流程。2. 先看数据和预处理KUAKE样本、分词对齐与数据增强2.1 KUAKE数据集长什么样标签和字段怎么对齐打开data/KUAKE目录典型的训练样本以json格式存放。比赛任务是医学搜索Query相关性判断提交数据的核心字段包括query_id、query、title_id、title和label。标签有三档0代表不相关1代表部分相关2代表高度相关。与通用领域文本分类不同医学搜索的query往往很短像“小孩咳嗽吃什么药”“发烧39度怎么办”候选title则是一条来自医疗站点的答案标题两者长度差异明显这对模型判断相关性是直接的挑战。{ query_id: q_1024, query: 小孩咳嗽吃什么药, title_id: t_3890, title: 小孩咳嗽怎么办_宝宝咳嗽_育儿问答, label: 1 }拿到数据后建议先统计query和title的平均长度。如果拼接后的总长度超过模型最大长度限制常见做法是固定query部分不截断title部分从开头保留关键位置。天池样本里的title经常带站点后缀例如“_育儿问答”“_39健康”这属于噪音但不完全是噪音有的模型能从后缀里学到来源可信度的隐含信息。建议在预处理阶段先保留它做对比实验后再决定是否清洗不要一上来就全删。标签分布也要留意。医学搜索样本里部分相关占了很大比例如果直接用原始分布训练模型会偏向预测多数类。源码包里utils.py会先输出每一类的样本数量这是最基础的体检步骤。另一个容易被忽略的点是数据划分同一个query可能对应多个title划分训练集和验证集时必须按query_id整体划分不能简单按行号乱分否则同一个query的多个关联title会同时出现在训练和验证里导致验证F1虚高提交后分数对不上。2.2 utils.py里的文本清洗、截断与拼接逻辑utils.py是数据准备的核心文件。它做的事情可以拆成四步分词、清洗、拼接、编码。因为用了BERT系列的tokenizer中文默认按字切分不需要额外引入jieba分词。清洗环节主要针对全角符号、URL、连续空格医学文本里常见的“”和中文引号如果不统一会让tokenizer的vocab查不到产生大量[UNK]。# code/utils.py 中文本处理的核心逻辑简化 def build_text_pair(query, title, tokenizer, max_len128): # 1. 清理不可见字符统一全半角 query clean_text(query) title clean_text(title) # 2. 手动截断query保留前64个字符title从开头保留 query query[:64] title title[:64] # 3. 拼成 [CLS] query [SEP] title [SEP] tokens [[CLS]] tokenizer.tokenize(query) [[SEP]] tokens tokenizer.tokenize(title) [[SEP]] # 4. 超过max_len就截断 tokens tokens[:max_len] segment_ids [0] * len(tokens) # 这里简化了segment的区分实际按query/title的分界点标记 return tokens, segment_ids上面的代码把query和title简单拼起来但有几个参数值得注意。max_len我一般设128因为医学query短title也不会特别长128已经能覆盖95%以上的样本更长的序列会拖慢训练速度尤其在RoBERTa-large这类大模型上序列长度直接决定显存占用。截断策略上query从右边截title也是从头保留因为title的关键信息通常集中在前面。这里的segment_ids在简化写法里没有完全区分出来实际代码里BERT要求query部分的segment_id为0title部分为1否则位置嵌入会互相污染。分词这一步ERNIE和RoBERTa的tokenizer对中文的切分基本一致但vocab.txt不同所以一定不能用同一个tokenizer跑两个模型。预算不够的机器直接跑四个预训练模型会崩溃我通常先用ERNIE跑通再上RoBERTa-large。train_eval.py里的数据构建还会做dynamic padding也就是在一个batch内取最长样本的长度来pad而不是把所有样本都pad到128这样能省下不少显存。2.3 data_augment.py医学查询的数据增强怎么做才不出错天池这类比赛的训练数据量不大KUAKE训练集通常只有几千条数据增强是提升泛化的重要手段。data_augment.py里做的主要是三种增强同义词替换、随机交换、回译。但医学文本不能随便替换把“腹泻”替换成“拉肚子”没问题把“高血压”换成“低血压”就会破坏标签语义。源码里维护了一张医学同义词表只允许在可控范围内替换。# code/data_augment.py 中同义词替换的简化逻辑 medical_synonyms { 发烧: [发热, 体温升高], 咳嗽: [咳, 咳嗽症状], 腹泻: [拉肚子, 腹泻症状], } def synonym_augment(query, p0.15): words list(query) new_query [] for char in words: if char in medical_synonyms and random.random() p: choices medical_synonyms[char] new_query.append(random.choice(choices)) else: new_query.append(char) return .join(new_query)最关键的是替换概率p。我实践下来的经验是0.1到0.2比较安全p太大会让原有query面目全非模型学到的是增强后的噪音而不是语义不变性。增强只能应用在训练集验证集和测试集必须保持原始文本否则评估分数是虚高的。随机交换通常用在短文本上对“小孩咳嗽吃药”这种四个词的短query交换顺序后语义变化很大我倒不推荐在这个比赛里用随机交换。回译增强需要机器翻译接口离线做不了。源码里的data_augment.py只实现了本地可跑的同义词替换和随机删除这样课设答辩时可以直接演示不需要外网依赖。如果你想把增强效果量化可以统计增强前后的样本总数并在答辩PPT里放一张相同训练轮数下的F1对比图这是评委很认可的验证方式。另一点要注意的是增强最好在离线阶段一次性完成并缓存不要在每次epoch训练时重新做一遍否则数据分布会抖动。3. 模型与训练入口ERNIE和RoBERTa的实现差异3.1 bert.py把预训练模型封装成文本对分类器工程包里最核心的封装是bert.py它定义了BertForSequenceClassification这个类。本质上是在预训练模型顶部加一个分类头用[CLS]位置的输出向量过一层线性层得到三个类别的logits。为什么用[CLS]而不是池化所有token因为BERT的[CLS]在预训练阶段就被训练成汇总全句语义的向量在文本对任务里它抓取的是query和title之间的匹配关系比简单平均池化更有效。# code/bert.py 关键片段结构示意 from transformers import BertModel, BertConfig class BertForSequenceClassification(nn.Module): def __init__(self, config_path, num_labels3): config BertConfig.from_json_file(config_path) self.bert BertModel.from_pretrained(config_path) self.classifier nn.Linear(config.hidden_size, num_labels) self.dropout nn.Dropout(0.1) def forward(self, input_ids, attention_mask, segment_ids): outputs self.bert( input_idsinput_ids, attention_maskattention_mask, token_type_idssegment_ids ) cls_output outputs.last_hidden_state[:, 0, :] logits self.classifier(self.dropout(cls_output)) return logits注意这里的from_pretrained和初始化写法。如果是Hugging Face的BertModel直接传config_path就行如果本地只有pytorch_model.bin和bert_config.json就要先load_state_dict再加载这是源码包与在线transformers版本之间常见的兼容性差异点。classifier的输入维度是hidden_sizeERNIE和RoBERTa-large的hidden_size都是1024但ERNIE层数更少训练速度和显存占用有明显优势。加载模型后建议第一时间打印模型的参数量并打印pytorch_model.bin的键值数量确认权重加载完整。很多同学跑完整个训练才发现模型权重没加载上随机初始化从头跑F1自然上不去。这一步虽然简单但价值很大。3.2 run_ernie.py从加载权重到交叉熵损失的完整流程run_ernie.py是主入口脚本对应pretrain_models/ERNIE目录下的权重。它在main函数里完成了五件事加载数据到DataLoader、定义模型和优化器、设置学习率调度器、多轮训练循环、保存最优模型。# code/run_ernie.py 训练主流程简化 from bert import BertForSequenceClassification from utils import build_dataloader from train_eval import train_one_epoch, evaluate model BertForSequenceClassification(pretrain_models/ERNIE/bert_config.json) optimizer torch.optim.AdamW(model.parameters(), lr2e-5, weight_decay0.01) scheduler get_linear_schedule_with_warmup(optimizer, num_warmup_steps100, num_training_steps2000) train_loader, _ build_dataloader(data/KUAKE/train.json, tokenizer, batch_size16) for epoch in range(5): train_one_epoch(model, train_loader, optimizer, scheduler, epoch) evaluate(model, dev_loader, epoch)答辩复盘时我总结了这里最值得讲的三个参数。learning_rate设为2e-5这是BERT微调的常见起点如果用1e-5会更稳但收敛更慢可以配合同义词增强一起用weight_decay本质是L2正则化的一种实现源码里默认0.01对全连接层和Embedding层都生效warmup步数100步如果数据集更小可以把warmup比例提高到10%。train_eval.py里的evaluate函数每个epoch都会跑一遍dev集输出loss、accuracy和macro-F1这三个指标是比赛排名的主要依据。这个脚本的可改点在于优化器。如果想进一步压缩训练时间可以把AdamW换成AdamW带amsgradFalse的默认配置如果训练不稳定则把learning_rate降到1e-5同时把weight_decay升到0.05。我在复现时发现ERNIE在医学文本上对学习率比较敏感同样是2e-5有的随机种子能跑到0.75有的只有0.73这种波动属于正常范畴不必过度调参。3.3 run_large_roberta_wwm_ext.py大模型和小模型的关键参数区别这个脚本对应的是roberta_wwm_large_ext也就是用了全词掩码WWM和扩展词表的RoBERTa-large版本。pretrain_models目录里还有一个roberta_large_pair名字中带pair说明在预训练阶段针对文本对任务做了额外优化。大模型相比ERNIE最大的区别是序列长度保持128时batch_size需要降到8甚至4learning_rate要降到1e-5否则loss震荡明显。# code/run_large_roberta_wwm_ext.py 中的DataLoader参数简化 train_loader DataLoader( train_dataset, batch_size8, # 大模型显存紧张batch要减半 shuffleTrue, num_workers2 )为什么大模型要单独写一个脚本而不是复用run_ernie.py因为RoBERTa系列的tokenizer风格和ERNIE略有差异segment_ids的处理方式也有区别。直接把run_ernie.py换掉预训练路径很容易出现维度不匹配或vocab对不上的报错。源码分开写是工程上方便对照调试的取舍也方便你在答辩时解释不同模型之间需要维护独立配置。如果你的显卡只有11GB显存建议跑ERNIE如果是24GB显存的卡再考虑RoBERTa-large。跑大模型时还有一个习惯值得学习把日志写到文件中同时配置ema指数移动平均保存模型。虽然代码包没有直接提供ema实现但你可以用train_eval.py里的save逻辑在训练后期保存平均权重而不是最后一轮的权重通常能再涨0.5个点的F1。4. 训练评估闭环损失函数、采样权重与提交结果解读4.1 train_eval.py 的训练循环与验证逻辑train_eval.py是训练和验证的公共模块。训练循环的loss默认是交叉熵三分类任务下torch.nn.CrossEntropyLoss已经包含了softmax操作不需要额外加LogSoftmax。我在读这个文件时特别注意它对类别不平衡的处理直接使用带权重的CrossEntropyLoss权重按标签频率的反比计算防止模型把大多数样本全预测成“部分相关”那一类。# code/train_eval.py 中带权重的交叉熵损失示意 from torch.nn import CrossEntropyLoss class_counts [1000, 3000, 1500] total sum(class_counts) weights torch.tensor([total / c for c in class_counts], dtypetorch.float) criterion CrossEntropyLoss(weightweights.to(device)) def evaluate(model, dev_loader): model.eval() correct, total 0, 0 all_preds, all_labels [], [] with torch.no_grad(): for batch in dev_loader: logits model(**batch) preds logits.argmax(dim1) all_preds.extend(preds.tolist()) all_labels.extend(batch[labels].tolist()) # 计算acc与macro-F1如果类别分布极不均衡光调损失函数权重还不够。另一个常见做法是重采样源码里没有显式实现但你可以把少数类的样本在DataLoader里重复几次效果类似。注意验证时batch_size可以比训练时大因为不计算梯度显存需求更低我一般会设置成训练时的两倍。验证集上记录的不只是数字还应该保存每一条预测结果这样之后可以分析模型到底错在哪里。train_eval.py里还有一个容易被忽略的细节它对logger做了多级配置训练输出的日志会同时写到控制台和文件。答辩时如果想展示训练过程直接打开train.log看loss曲线就行不用额外写tensorboard。如果你希望更直观地看loss变化可以每100步输出一次当前step和学习率这在黑盒调试阶段非常有用。4.2 test.py 的预测输出与天池提交格式test.py读入测试集并输出预测结果。天池的提交格式一般要求两列query_id和预测label。很多人第一次提交会忽略列名或者index排序没有与测试集顺序一致在线评测直接得0分。这个脚本需要保证读取顺序和预测顺序完全一致不能在中途做shuffle。# code/test.py 输出提交文件简化 def predict_and_dump(model, test_loader, output_pathsubmission.csv): model.eval() results [] with torch.no_grad(): for idx, batch in enumerate(test_loader): logits model(**batch) preds logits.argmax(dim1).cpu().tolist() for qid, pred in zip(batch[query_ids], preds): results.append({query_id: qid, label: pred}) # 保持和测试集读取顺序一致 pd.DataFrame(results).to_csv(output_path, indexFalse, columns[query_id, label])提交后在线分数由评测环境计算本地运行test.py只能确认推理流程没报错。提交前必须确认csv没有BOM头如果用了pandas默认编码Windows下可能出现UTF-8 BOM导致列名变乱码。我一般会在生成csv后读取回来打印前5行确认query_id和label的类型分别是字符串和整数再手动比对文件行数与测试集行数是否一致。另一个容易踩的点是标签类型。如果logits的argmax结果是torch.Tensor转换成list后会出现numpy.int64类型pandas写入csv时没有问题但天池在线系统有时会因列类型异常报错。保险做法是统一转成Python int后再写文件。4.3 关键参数速查表参数ERNIERoBERTa-large(WWM)说明batch_size1686GB显存再减半max_length128128可用256显存代价高learning_rate2e-51e-5大模型小学习率epochs5-105数据少轮数不宜过多warmup_ratio0.10.1前10%步数线性升温weight_decay0.010.01全连接与Embedding一致gradient_clip1.01.0防止大模型loss爆炸这个表在答辩时可以直接讲。gradient_clip容易被忽略训练时如果loss突然出现NaN第一反应不是降学习率而是检查是否缺了clip_grad_norm_。对大模型来说梯度裁剪几乎是标配尤其在医学短文本这种数据量小的场景梯度范数偶尔会飙升到几十不裁剪的话一度训练就崩掉。5. 避坑排查五条让训练翻车的真实记录5.1 模型权重加载路径错误提示找不到pytorch_model.bin现象运行run_ernie.py报错Error(s) in loading state_dict for BertModel: Missing key(s) in state_dict。 原因大概率是pretrain_models/ERNIE目录下没有pytorch_model.bin或者文件名不一致比如下载下来的是分片文件pytorch_model-00001-of-00002.bin直接改名为pytorch_model.bin会导致加载不完整。 解决先检查目录文件是否齐全确认有bert_config.json、vocab.txt、pytorch_model.bin三个文件。分片文件需要用transformers的modeling_utils里的加载逻辑合并不要手动改名字。加载成功后打印model.bert.embeddings.word_embeddings.weight等参数统计确认不是随机初始化。5.2 batch_size32直接OOM6GB显存根本没跑起来现象训练启动后GPU显存瞬间占满报CUDA out of memory。 原因文本对长度128小模型其实可以跑但RoBERTa-large参数量3.4亿32个样本一次前向中间隐层和梯度会把显存吃满。 解决把batch_size降到8或4开梯度累积模拟较大的批大小。train_eval.py里没有原生梯度累积你可以在外层写一个循环每两步accumulate一次。或者先用ERNIE参数量比RoBERTa-large小很多训练速度快一半以上适合课设阶段反复调实验。5.3 验证集F1不涨训练集loss却一路稳定下降现象每个epoch结束后dev的macro-F1在0.5附近震荡训练集acc到了0.95。 原因基本是过拟合或者验证集构建方式有误。常见的原因是验证集和训练集存在重叠或者dev样本太少只有200条F1方差很大。 解决检查train/dev的划分方式确保按query_id而不是按行号随机划分同一个query的所有关联title要么都在训练集要么都在验证集。如果验证集只有几百条建议改用5折交叉验证看平均F1不要用单次结果下结论。训练轮数超过5轮后每轮记录验证F1如果连续两轮不涨就早停。5.4 拼接顺序和segment_ids写反结果掉了两个点现象同一份代码、同一个权重别人F1是0.78你只有0.76。 原因文本对拼接时query放在[CLS]后、title放在第一个[SEP]后但segment_ids没有跟随分界点切换模型把所有token都当成同一段无法区分query和title。 解决打印前两个batch的input_ids、attention_mask、segment_ids人工检查token化后的头尾。写segment_ids时用分界点标记第一个[SEP]前为0之后为1。这个细节很隐蔽因为不报错只是静默掉分从第3个epoch开始就会显现。5.5 test.py输出提交格式不对评测显示0分现象本地跑完test.py没有任何报错但天池评测分数为0。 原因csv列名不是固定的query_id和label或者index没有对齐测试集顺序也可能是label输出成了浮点数2.0。 解决先看提交样例的文件头保持列名一致。生成csv后用pandas读回并打印前5行确认类型是int。如果test.py里有shuffle操作必须合并排序后再写文件。最后检查总行数和测试集行数不符时多半是漏掉了一部分batch没有写入。6. 把项目改成自己的数据增强、阈值调节和模型集成的实操6.1 用数据增强把训练样本翻倍并保持标签不变源码包里data_augment.py已经实现了同义词替换。我实际跑的时候把p从0.15调到0.2同时增加了一条“hard negative”策略对每个正样本随机选取类别0的title替换原title生成一条难负样本。这个策略能让模型更关注query和title之间的语义匹配而不是单纯记住常见搭配。增强后的数据集一定要重新统计分布确保标签比例没有发生偏移。6.2 选择最优阈值而不是只用argmax三分类任务用argmax不是唯一解。对软标签可以先用argmax得到初步结果再做阈值调节。具体做法是在dev集上遍历三个类别的阈值组合让macro-F1最大。我常用sklearn的f1_score做网格搜索三个类别的阈值分别从0.3到0.7步长0.05可以找到一组较好的阈值组合。这个方法往往比换模型更直接代码量也很小。# 在验证集上搜索最优阈值示意 from sklearn.metrics import f1_score import numpy as np best_threshold None best_f1 0.0 for t1 in np.arange(0.3, 0.8, 0.05): for t2 in np.arange(0.3, 0.8, 0.05): pred softmax_logits.copy() pred[:, 1] * t1 pred[:, 2] * t2 final_pred pred.argmax(axis1) f1 f1_score(y_true, final_pred, averagemacro) if f1 best_f1: best_f1 f1 best_threshold (t1, t2)6.3 模型集成和课设验收习惯ERNIE和RoBERTa的logits平均是最简单的ensemble方式。两个模型各输出一个三维logits向量按权重相加后取argmax通常比单模型提升1到2个百分点。更稳妥的做法是在验证集上用线性搜索找两个模型的权重比例而不是直接五五开。答辩前把dev集的错误案例打印出来看哪些是模型因为text太长截断后丢失信息导致误判这些都能成为很好的讲解素材。从那以后我每次跑NLP赛题都会强制走一遍“数据体检、划分验证、提交格式自检”这条流程还会把每个实验的学习率、batch_size、F1记录在一个表格里。这套源码包最大的价值不只是能让你跑出一个分数而是让你在复盘时能讲清楚每一个环节为什么这么设计。希望帮到你。本文还有配套的精品资源点击获取
返回列表