医学搜索Query相关性判断:基于ERNIE与RoBERTa的NLP赛题工程实现

发布时间:2026/10/13 22:22:26
医学搜索Query相关性判断:基于ERNIE与RoBERTa的NLP赛题工程实现 简介这是一份面向计算机相关专业在校学生、老师及开发者的天池医学搜索查询相关性判断赛题完整实现基于自然语言处理与深度学习技术覆盖数据增强、模型训练、评估与推理等关键环节适合作为毕业设计、课程设计、作业或竞赛入门的高分参考。压缩包共三十个文件包含八个脚本、七个配置文件、五个工程设置文件以及日志、表格数据、说明文档等脚本对应数据增强、训练评估、模型选择等流程配置文件可灵活调整实验参数整体约370KB目录按数据、预训练模型、代码模块清晰划分便于快速定位与二次开发。资源内置ERNIE、RoBERTa等预训练模型的调用示例使用说明与英文文档一并提供。目前已有120人学习下载代码经充分测试答辩评审平均分达96分下载后可联系作者获取远程教学或运行指导帮助快速跑通完整深度学习赛题方案。1. 医学搜索 Query 相关性判断为什么说这是最适合课设的 NLP 赛题医学搜索场景里用户输入的 Query 和候选文档之间往往字面不重合但语义强相关这个赛题要求模型判断一段 Query 与一篇医学文档的相关性等级本质是文本匹配分类任务。这份深度学习课设资源是一套跑通并拿到高分排名的完整工程实现data 目录放着原始赛题数据pretrain_models 里备好了 ERNIE 和两套 RoBERTa 预训练基座code 目录下是数据增强、训练评估、推理测试的全套脚本。NLP 入门课设做到这个程度既有模型选型的思考空间又有实打实的调参路径。适合计算机、人工智能方向的同学用来做课程设计或毕设主线。下面我从数据开始把整条链路拆开讲。2. 赛题与数据处理先定义“相关”再谈模型2.1 任务拆解Query 与文档的相关性不是字符串匹配这个赛题的输入是双文本结构一边是用户输入的医学搜索 Query一边是候选文档。候选文档来源很杂疾病百科条目、药品说明书、临床指南摘要都有有些文档标题就一句话有些正文长到几百字。模型要输出的是一个人工标注的相关性等级。这里的关键点是标注者眼里的“相关”和我们直觉上的“相关”并不完全一致。常见做法是分成三档0 表示完全无关1 表示部分相关2 表示完全相关。这种分档对医学场景尤其重要因为很多 Query 和文档属于“沾边但没答到点上”。比如 Query 是“高血压 饮食禁忌”文档讲的是“高血压患者的运动建议”你说它完全无关吧它确实在聊高血压你说它完全相关吧它压根没回答饮食问题。模型真正要学的就是这种既模糊又有规律可循的中间态。如果图省事用 Word2Vec 词向量取平均或者 TF-IDF 余弦相似度你会很快撞上字面匹配的天花板。“咳嗽 吃什么药”和“儿童咳嗽的护理与用药指南”两者重合了“咳嗽”和“药”但 Query 的重点是治疗药物文档的落点是护理和用药字面分偏高人工标注却倾向于中等相关。反过来“感冒 流鼻涕”和“普通感冒的主要症状表现”几乎没有字符重合语义上却是强相关。这些 case 靠传统特征很难区分预训练模型微调成了主流方案。所以这份资源从一开始就选择了 ERNIE 和 RoBERTa 两条预训练路线而不是从词向量开始堆特征。你当然可以换别的基座模型但保持项目和原脚本一致能少走很多对分和对齐的弯路。我自己的经验是先把数据读明白、把标签分布摸清楚再谈模型选型否则后面所有调整都像在开盲盒。2.2 数据标注体系与标签分布data 目录下存放原始赛题数据组织形式是 JSON 行式文件每行一条样本。样本字段一般包含 Query、候选文档和人工标注的 label。训练集规模不算大几万条的量级这对预训练模型微调来说偏紧张所以资源里专门写了 data_augment.py 做数据增强后面第 4 章会展开讲。先看标签分布医疗搜索场景有个特点完全无关的样本往往占比不高因为候选文档在构造时已经做过一轮筛选。大量样本集中在“部分相关”和“完全相关”这两档。这个分布直接影响训练策略。如果直接用默认的 CrossEntropyLoss模型会往样本量大的类别偏导致“完全无关”这一类召回率很低整体分数被拖下来。我一般会在训练前先跑一次统计脚本看每个 label 的样本数。如果发现类别不均衡就把类别权重传进 Loss# 按标签频数计算权重抑制多数类对梯度的主导 import math from collections import Counter labels [sample[label] for sample in train_samples] counter Counter(labels) total sum(counter.values()) class_weights [] for c in sorted(counter.keys()): freq counter[c] / total # 常见做法是取对数逆频率避免少数类权重过大 class_weights.append(1.0 / math.log(1.0 freq))这段代码的作用是把样本占比转成每个类别的 Loss 缩放系数。比如“完全无关”只占 10%它的权重就会比占 40% 的“完全相关”大几倍。注意 class_weights 的类别顺序必须和模型输出头的 num_labels 一致否则权重会套错类别。有同学在这上面栽过跟头loss 一直不收敛最后发现是类别权重和标签编号错位了一个位置。另外官方数据通常只给训练集和测试集验证集要自己从训练集里切。资源里 utils.py 读取数据时没有写死切分比例留了参数。常见做法是 9:1 切分并且固定随机种子保证每次复现切出来的验证集完全一致。如果不固定种子模型早停的位置也会跟着漂你很难判断超参调整到底是变好了还是只是运气变了。2.3 预处理流程从原始 JSON 到训练样本# utils.py读取原始 JSON 行并构造统一的 sample 结构 import json from transformers import BertTokenizer def load_samples(data_path): samples [] with open(data_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue item json.loads(line) samples.append({ query: item[query], doc: item[document], label: int(item[label]) }) return samples def tokenize_samples(samples, tokenizer, max_len128): input_ids, token_type_ids, attention_masks, labels [], [], [], [] for s in samples: encoded tokenizer.encode_plus( s[query], s[doc], max_lengthmax_len, truncationlongest_first, paddingmax_length, return_tensorspt ) input_ids.append(encoded[input_ids].squeeze(0)) token_type_ids.append(encoded[token_type_ids].squeeze(0)) attention_masks.append(encoded[attention_mask].squeeze(0)) labels.append(s[label]) return input_ids, token_type_ids, attention_masks, labels这段代码把原始文本变成了模型输入张量。tokenizer 必须和预训练模型配套ERNIE 和 RoBERTa 虽然都走 BertTokenizer 家族但词表不同不能混用。资源里 pretrain_models 下面分了三个子目录分别对应 ERNIE、RoBERTa-wwm-ext-large 和 RoBERTa-large 句子对版本加载哪一个tokenizer 就必须指向哪一个目录。encode_plus 里有几个参数值得认真说。max_length128 表示把 Query 和 Document 拼接后的序列统一截断到 128 个 token超出部分丢弃。truncationlongest_first 表示拼接超长时优先从较长的那一段里裁掉多余内容对 Query 短、文档长的样本最友好。paddingmax_length 把所有样本补齐到同一长度方便组成 batch 做张量运算。如果你显存够用把 max_length 从 128 提到 256通常能带来小幅提升因为长文档能保留更多有效信息代价是训练时间和显存占用同步上涨。我一般会先拿小数据跑一次确认显存峰值能接受再决定要不要加长。预处理里还有一个容易被忽略的点Token 顺序。Query 在前还是 Document 在前会直接影响微调效果。常见做法是 Query 作为第一个句子输入Document 作为第二个句子对应 token_type_ids 里的 0 和 1。预训练模型在做下一句预测任务时对第一个句子的语义主体会更敏感。资源里 encode_plus 第一个位置传的是 query遵循的就是这个约定。改代码时务必保持这个顺序否则复现效果会偏离你预期。3. 模型选型与微调策略ERNIE、RoBERTa 与长文本对的取舍3.1 为什么赛道主流是预训练模型而不是词向量加权文本匹配任务的传统做法是给 Query 和文档分别算向量然后算余弦相似度。词向量加权也好TF-IDF 加权也好本质都是把句子的语义压缩成一个固定的向量。问题在于医学 Query 往往很短一个词的变化就能改变整个意图而固定向量难以捕捉这种细粒度差异。预训练模型的做法完全不同。它把 Query 和 Document 拼接成一对输入在 Transformer 的每一层里让两个文本互相做 Attention。这意味着最终判断相关性时模型能看到 Query 里哪个词和文档里哪个词产生了强关联而不是把两边各压缩成一个向量再比相似度。ERNIE、RoBERTa 这些模型在中文语料上做过大规模预训练对医学领域的词汇表达已经有了基础语义理解微调时只需要少量标注数据就能适应具体任务。具体到这份资源pretrain_models 下备好了三个基座ERNIE、RoBERTa-wwm-ext-large、RoBERTa-large 句子对版本。这三个模型在文本匹配任务上的表现差异主要来自预训练目标和模型规模。ERNIE 侧重在预训练阶段融入词汇、句法、语义多层次信息对中文医学实体更敏感。RoBERTa 系列则是更大的模型参数加上更充分的训练数据泛化能力更强但显存占用和推理耗时也更大。3.2 ERNIE 与 RoBERTa 的差异及适用场景先看 ERNIE。它的核心特点是预训练时把实体词和短语作为掩码对象模型对实体边界有更强的感知。在医学搜索场景里Query 里出现“高血压”“糖尿病”这类实体词时ERNIE 比普通 BERT 更容易抓住实体所在的位置。如果你的显存有限机器只能跑 base 级别模型ERNIE 是不错的起步选择。再看 RoBERTa-wwm-ext-large。这个模型是 RoBERTa 的中文变体用了全词掩码策略预训练时把完整词的所有子词一起遮蔽而不是只遮挡一个子词。对中文这种分词边界相对清晰的语言全词掩码让模型学到了更稳定的词级语义在句子对分类任务上通常比原始 BERT 高一到两个点。RoBERTa-large 句子对版本则是主打双句输入的 large 模型参数规模比 base 大很多。在这个赛题里它能带来更高的分数上限但训练显存需求也最大。我自己的经验是如果单卡显存 24GBlarge 模型配合 128 的序列长度勉强能放下一批 8 个样本再多就要走梯度累积了。三个模型怎么选我的建议是先用 ERNIE 跑通整个训练预测链路确认代码无误再切到 RoBERTa-wwm-ext-large 提分。如果机器资源允许最后用 RoBERTa-large 句子对版本跑一套完整结果参与模型集成。这个顺序能让你在有限的时间里既拿到可用的提交结果又留出做实验的余地。3.3 训练超参数配置batch size、学习率与序列长度# run_ernie.pyERNIE 基座的微调配置 from transformers import BertConfig, BertForSequenceClassification num_labels 3 # 对应预处理里统计的标签数 config BertConfig.from_pretrained( pretrain_models/ERNIE, num_labelsnum_labels ) model BertForSequenceClassification.from_pretrained( pretrain_models/ERNIE, configconfig )这段代码的作用是从本地预训练目录加载权重同时把模型输出头改造成分类任务所需的 num_labels 个类别。注意 from_pretrained 的第一个参数是本地目录路径不是模型仓库名。资源里 pretrain_models/ERNIE 目录下必须有 config.json 和权重文件缺一样都会加载报错。微调阶段我习惯把学习率设在 2e-5 到 3e-5 之间batch size 在 16 到 32 之间。预训练模型已经收敛到了很好的语义表示学习率太高会把学到的知识冲掉太低又收敛太慢。RoBERTa-large 因为参数多学习率可以适当降到 1e-5 到 2e-5。序列长度这块128 是速度与效果的平衡点256 会带来小幅提升但训练时间明显拉长。Warmup 比例一般取 10%让模型在训练初期用很小的学习率稳定几步再逐步上升到峰值最后线性衰减到零。如果不设 warmup经常会出现前几个 step 的 loss 剧烈震荡后面才慢慢稳定浪费掉一部分训练时间。3.4 三份训练脚本的定位差异资源里 code 目录下有三个入口脚本run_ernie.py、run_large_roberta_wwm_ext.py、run_large_roberta_pair.py。名字已经把定位说清楚了分别对应三个预训练基座。三个脚本共用了 train_eval.py 里的训练评估主流程区别只在模型加载和部分超参。run_large_roberta_pair.py 的 pair 体现在输入侧它把 Query 和 Document 构造成句子对用 token_type_ids 区分两个句子这种输入方式和第 2 章预处理那段代码是对应的。如果换了输入格式比如把两段文本拼接好再喂进去token_type_ids 的设置就要同步改否则模型无法区分哪部分是 Query、哪部分是 Document。训练脚本里还应该关注权重保存逻辑。常见的正确做法是每个 epoch 结束后在验证集上算一次指标如果比历史最优好就覆盖保存 best_model.bin。如果不做这个判断、只保存最后一轮权重可能因为最后的训练阶段已经过拟合导致提交效果反而更差。资源里 train_eval.py 在这个位置的处理逻辑是值得拿到别的任务里复用的。注意ERNIE 与 RoBERTa 的词表不通用切换基座时tokenizer 的 vocab 路径要和模型权重严格对应这是最容易踩的一个坑。4. 从数据增强到训练评估把代码跑通的完整链路4.1 数据增强脚本的逻辑与边界data_augment.py 的存在是因为医学搜索赛题的数据量往往不够用。几万条训练样本对预训练模型微调来说复现次数多了就很容易过拟合。数据增强的思路是在保持标签语义不变的前提下让模型看到更多样的表达方式。常见做法有三种。第一种是同义词替换把 Query 里的疾病名、症状词替换成同义表达比如“高血压”换“血压高”“咳嗽”换“咳”。第二种是随机插入在样本中间插入无意义但有语法功能的词。第三种是对文档做回译式改写机器翻译到英文再翻译回中文生成新的表述。这三种方法里回译质量最高但耗时也最长同义词替换最省事但要小心医学实体词不能乱换。# data_augment.py基于同义词表的轻量数据增强 import random def synonym_replace(text, synonym_dict, replace_prob0.3): chars list(text) for i, ch in enumerate(chars): if ch in synonym_dict and random.random() replace_prob: chars[i] random.choice(synonym_dict[ch]) return .join(chars)这段代码的逻辑很简单遍历文本中的每个字符如果命中同义词表就以 replace_prob 的概率替换成同义字。注意这里的同义词表需要你自己根据医学场景维护比如“高”和“升高”、“疼”和“痛”。replace_prob 一般控制在 0.2 到 0.3 之间太高会把句子改得不通顺反而引入噪声。数据增强有一个边界要特别注意只能增强训练集不能增强验证集和测试集。验证集要保持原始标注才能真实反映模型在原始数据分布上的表现。如果验证集也做了增强你评估出来的分数会虚高放到官方测试集上大概率对不上。4.2 训练评估主线train_eval.py 的模块拆解train_eval.py 是整个工程的核心负责训练循环、验证评估、模型保存三件事。读取数据、切分验证集、构造 DataLoader这些都在前面几步完成然后进入标准的 PyTorch 训练循环。# train_eval.py训练循环骨架 from transformers import AdamW, get_linear_schedule_with_warmup optimizer AdamW(model.parameters(), lr2e-5) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps ) for epoch in range(epochs): model.train() for batch in train_loader: outputs model( input_idsbatch[input_ids], attention_maskbatch[attention_mask], token_type_idsbatch[token_type_ids], labelsbatch[labels] ) loss outputs.loss loss.backward() optimizer.step() scheduler.step() optimizer.zero_grad()这段代码对应的是 Transformers 库接口。AdamW 是带权重衰减的 Adam 优化器权重衰减能限制模型参数过大缓解过拟合。学习率 2e-5 是为了微调预训练权重不建议超过 5e-5。get_linear_schedule_with_warmup 把学习率先从小升到大再到后期线性降下来一共走 total_steps 步。训练循环里有一个细节token_type_ids 对 ERNIE 和 RoBERTa 这类使用绝对位置编码的模型不是必须的但对句子对任务建议保留它告诉模型前一段是 Query、后一段是 Document。如果不传这个参数部分模型会把拼接后的序列当成一个连续句子相关性判断时少了边界信息效果会打折扣。训练结束后train_eval.py 会在验证集上计算准确率或 F1按验证分数决定要不要保存当前权重。常见做法是只保存验证集上分数最高的那一次覆盖掉之前的权重文件。这就是模型早停的基础避免训练到最后过拟合带来的分数回退。4.3 三个模型入口脚本的配置与执行三个入口脚本对应三种基座模型执行时的前置条件基本一致先把 pretrain_models 下对应目录的权重文件准备好再把数据路径指到正确位置。# 三种模型的启动方式逐行执行 python run_ernie.py # ERNIE 基座先跑通链路 python run_large_roberta_wwm_ext.py # RoBERTa-wwm-ext-large提分主力 python run_large_roberta_pair.py # RoBERTa-large 句子对版本参与集成第二个脚本在单卡上跑时建议先把 batch size 调小如果显存不够它会直接报 CUDA out of memory。第三个脚本跑之前要确认对应的 pretrain 目录下权重确实是 PyTorch 格式有些开源权重是 TensorFlow 格式导出的加载前需要先做一次格式转换否则模型结构对不上训练出来的分数会非常奇怪。4.4 测试脚本与推理一致性验证test.py 负责加载训练好的模型对测试集做预测并输出提交文件。这里的核心问题是训练时的数据预处理和测试时必须是同一套逻辑。比如训练时 Query 在前 Document 在后测试时就不能调换顺序训练时序列长度截断到 128测试时也要按 128 截断。# test.py加载最佳权重并预测测试集 model.eval() predictions [] with torch.no_grad(): for batch in test_loader: outputs model( input_idsbatch[input_ids], attention_maskbatch[attention_mask], token_type_idsbatch[token_type_ids] ) logits outputs.logits preds torch.argmax(logits, dim-1) predictions.extend(preds.cpu().numpy().tolist())这段代码用 torch.no_grad() 关闭梯度计算节省显存和推理时间。argmax 把模型输出的三分类 logits 变成标签预测。如果你要提交概率文件就把 argmax 换成 softmax直接输出每个类别的概率分数集成的时候会用得上。推理一致性验证是一个好习惯。我一般会在测试集上跑两次第一次用训练好的最佳权重第二次用最后一轮权重对比两次预测结果的差异。如果差异很大说明训练还没收敛或权重保存逻辑有问题。如果两次几乎一致那模型状态基本稳定可以放心提交。5. 避坑指南预训练模型加载、显存与提交成绩不一致5.1 预训练模型加载失败现象运行 run_ernie.py 时在 from_pretrained 那一步直接抛 OSError提示目标目录下找不到 config.json 或找不到权重文件。原因预训练目录结构不完整。from_pretrained 方法要求目录下至少有 config.json 和一个权重文件比如 pytorch_model.bin 或 model.safetensors。有些模型包下载下来是压缩包解压后目录嵌套了一层直接指向外层目录当然加载不到。解决先去 pretrain_models/ERNIE 目录下确认文件列表。如果发现权重被包在下一层子目录里就把子目录里的内容移到外层。移动时注意区分 config.json 和权重文件的命名有些中文模型权重叫 model.ckpt需要先转换成 pytorch_model.bin 才能被正常识别。这一步做完再重跑加载就能通过。5.2 损失不下降现象训练开始后loss 在前几百步一路下滑但到某个位置之后几乎不再变化验证集分数也卡住不动。原因典型原因是学习率设置过大模型在预训练权重的基础上反复震荡始终跳不出局部区域。另一个常见原因是类别权重和标签编号错位比如把“完全无关”的权重套到了“完全相关”的标签上梯度方向本身就是错的。解决把学习率从 3e-5 降到 1e-5如果 loss 开始缓慢下降说明不是学习率的问题。用第 2 章里写的类别权重计算逻辑重新生成 class_weights并打印出 label 和 weight 的对应表人工核对一遍。确认这两项没问题后再把 warmup 比例从 10% 提到 20%给模型一个更平滑的起步loss 曲线通常会变得更稳定。5.3 验证集与测试集成绩不一致现象本地验证集上准确率 92%提交到官方测试集上只有 88%分差明显。原因最常见的是验证集切分时没有固定随机种子导致每次跑出来的验证集分布不一样模型保存的位置也不是对准同一个分布的。另一个原因是数据泄漏比如在做数据增强时把增强后的样本也放进了验证集模型已经见过这些文本评估分自然虚高。解决在切分验证集之前先调用 random.seed(42) 或 numpy.random.seed(42)把随机源固定下来。检查 data_augment 的调用范围确保增强只作用于训练集。数据泄漏这块除了增强样本还要注意有没有把 Query 或 Document 完全相同的样本同时放进了训练集和验证集如果存在应该按文本去重后重新切分。5.4 显存不足OOM现象换到 RoBERTa-large 后程序在第一个 batch 就报 CUDA out of memory小模型一切正常。原因large 模型的参数量是 base 的三倍以上激活内存也相应膨胀同样的 batch size 直接超显存。解决先把 batch size 降到 4 甚至 2如果还是 OOM就启用梯度累积。梯度累积的意思是每几个小 batch 才做一次参数更新等效增大了 batch size但显存占用不变。具体做法是把 train_eval.py 里的 loss.backward() 改成累积逻辑每隔 accumulation_steps 个 batch 才执行 optimizer.step()。如果显存依然不够就把序列长度从 128 降到 96对短 Query 场景影响很小。5.5 数据增强翻车现象用了同义词替换之后验证集分数不升反降而且训练集 loss 一直低于验证集 loss差距越来越大。原因同义词表里混入了医学术语的不当替换。比如把“高血压”替换成“血压高”没问题但把“糖尿病足”拆开替换成“糖尿病”和“足”语义就偏了。还有随机插入操作产生了不通顺的句子模型学到的是错误的语言规律而不是增强后的语义鲁棒性。解决先停用数据增强用原始数据重跑一遍作为基线验证分数是否因为增强而下降。如果是就把同义词表里的医学实体词全部列入保护名单禁止替换。替换概率从 0.3 降到 0.1让增强样本只占训练集的小比例。回译增强如果成本太高就只对 Query 部分做不对长文档做效果和成本的平衡更划算。6. 进阶技巧概率平均集成与阈值搜索把分数再往上拉一档前五章讲的是把单模型链路跑通的完整过程。到了这个阶段你想再往上提分最有效的不是继续调参而是把三个模型的结果做集成。常用的集成方式是概率平均把每个模型对测试集样本输出的三分类概率分别取平均再按平均后的概率取 argmax。# 集成逻辑两个模型概率平均 def ensemble_predict(probs_ernie, probs_roberta, weights(0.5, 0.5)): probs weights[0] * probs_ernie weights[1] * probs_roberta return probs.argmax(axis1)weights 参数控制两个模型的融合比例。ERNIE 和 RoBERTa 如果分数接近用 0.5 和 0.5 就能稳定获得提升。如果明显一个大一个小我一般会把分数高的一方权重调到 0.6 到 0.7。三个模型一起做概率平均也可以但要注意大模型占比过高会把集成结果拉偏必要时在验证集上做一次小范围权重搜索比拍脑袋定比例靠谱。阈值搜索也是容易被忽略的一环默认 argmax 不一定是最好。你可以先用验证集计算出每个类别的概率分布再按照验证集分数最优的原则搜索一组阈值。先选好阈值后再用它们去预测测试集。从那以后我每次提交前都强制走一遍集成验证先确认三个模型的最佳权重都保存完整再加载测试集依次输出概率文件最后在验证集上比对单模型和集成的准确率确认集成收益为正再提交。这一步帮我避开了好多次因为单模型过拟合导致的分数虚高。希望帮到你。本文还有配套的精品资源点击获取