简介面向自然语言处理课程设计场景这是一份基于天池比赛‘医学搜索Query相关性判断’的深度学习项目源码覆盖数据预处理、模型训练与评估等完整流程适合计算机、人工智能等专业学生作为课设、毕设或竞赛入门参考。资源共30个文件压缩包约370KB其中包含ERNIE、RoBERTa-wwm-large等预训练模型对应的调用代码与目录结构文件类型包含8个Python脚本数据增强、训练、评估与推理、7个JSON配置、5个XML工程配置、3个训练日志和2个Markdown说明文档另有少量辅助文件便于对照阅读和二次修改。项目答辩评审平均分达96分阅读README即可快速理解代码结构与运行方式也可进一步学习医学搜索场景下Query与文档的相关性判断思路。目前已有120人浏览学习适合想通过真实NLP赛题提升深度学习实践能力的人群可帮助读者快速跑通基于BERT系模型的Query相关性任务同时内置中英文说明文档便于不同基础用户快速查阅。1. 深度学习课设选天池NLP医学搜索Query相关性判断一套源码加文档高分不靠运气很多人的深度学习课设还停留在猫狗识别、MNIST 分类但如果你想拿高分选一个有真实业务背景的 NLP 任务会划算得多。这篇要拆的是天池比赛里“医学搜索 Query 相关性判断”给你一条用户搜索词比如“感冒了吃什么药”再给一段医学材料摘要要求模型判断这段摘要是不是用户正想找的内容。任务本质是句对相关性分类代码量不大却能系统展示深度学习、NLP 预训练模型、文本匹配的完整流程而且有现成数据集和公开评测写进课设报告里很容易让老师信服。适合正在纠结课设题目、又不想只跑 MNIST 的深度学习课程学生。这篇笔记就按我自己做文本匹配任务的思路把数据处理、模型训练、调参和踩坑全讲一遍。2. Query相关性判断的建模思路先搞清楚标签、样本和评估指标2.1 医学搜索场景下的 Query 和 Document 长什么样天池这个比赛的原始数据一般是结构化文本常见字段有query、document、label。query是用户在医学搜索框里输入的短句例如“儿童咳嗽有痰吃什么药”document是从医学百科或临床指南里摘出来的片段可能是一个段落也可能是几个句子。label表示这段 document 是否与 query 相关有的赛制是二分类相关/不相关有的赛制是三分类完全相关、部分相关、不相关。课设阶段建议先按二分类做门槛低评估指标也更好解释。数据样例query: 儿童咳嗽有痰吃什么药 document: 小儿咳嗽有痰多由上呼吸道感染引起可在医生指导下使用氨溴索口服液祛痰同时注意多喝水、保持室内空气湿润。 label: 1这里有个容易被忽略的点医学文本里“小儿”和“儿童”是同一实体“发烧”和“发热”是同义表述。传统检索方案用字面匹配很难处理这种同义改写而深度学习模型可以从大规模预训练中学到语义等价关系。这也是这个任务值得用神经网络做的根本原因也是课设报告里最重要的动机部分。2.2 为什么选深度学习而不是 BM25 或 Query DSL有人会问直接用 ES 的 query DSL 写个相关性公式或者算 BM25 分数不也行吗确实传统方法能在十分钟内做出一个可用基线。但这类搜索相关性任务的难点在于 query 很短、document 很长两者不是“词汇包含”关系而是“语义相关”关系。比如“发烧不退怎么办”和“发热待查的鉴别诊断”几乎共享不到几个词但人类一看就知道相关。BM25 只能做字面匹配处理不了同义改写、词序变化、指代关系。深度学习模型特别是预训练语言模型能把句子编码成语义向量再通过交叉编码器直接判断相关性效果要高一截。如果老师在答辩时问“为什么不用 elasticsearch query dsl 这类现成工具”你可以回答DSL 是查询语法解决的是结构化检索而这个任务是语义相关性判断属于文本匹配问题用预训练模型做句对分类是更直接、可解释的方案。这个回答既能展示你用过传统方案又能说明深度学习选型的合理性。2.3 评估指标别只用准确率二分类任务里最容易被误导的指标就是准确率。假设训练集中 90% 的样本不相关模型把所有样本都预测为不相关准确率也有 90%但一点用都没有。天池这类相关性比赛一般用 F1 或者 AUC 作为官方评测指标。课设里建议同时计算准确率、F1、AUC并画出混淆矩阵。报告里放混淆矩阵老师一眼就能看出你懂“正负不均衡”这个坑。如果比赛官方是二分类一般看macro F1如果是三分类则看mean F1或log loss。源码里最好先写好一个统一的评估函数方便切换。我一般会这样组织from sklearn.metrics import accuracy_score, f1_score, roc_auc_score, confusion_matrix def evaluate_model(labels, probs): preds (probs[:, 1] 0.5).astype(int) f1 f1_score(labels, preds, averagemacro) auc roc_auc_score(labels, probs[:, 1]) acc accuracy_score(labels, preds) cm confusion_matrix(labels, preds) return {f1: f1, auc: auc, acc: acc, confusion_matrix: cm}这里probs是模型输出的二分类概率矩阵第二列是正类的概率。阈值先默认 0.5后面第 4 章会专门讲怎么找最优阈值。先把评估框架搭好后面所有实验都拿这份代码算分数保证可复现。3. 从零跑通最小实现用预训练模型做句对分类的完整代码3.1 数据预处理把原始数据变成模型能吃的文本对天池给的数据通常不是直接在内存里供模型使用的。常见格式有 CSV 或 JSON需要先读取、清洗、划分。以下代码假设train.csv包含query、document、label三列。import pandas as pd from sklearn.model_selection import train_test_split df pd.read_csv(data/train.csv) print(df.shape, df[label].value_counts().to_dict()) # 构造句对分类模型的输入query 和 document 拼接 df[text] df[query] [SEP] df[document] # 分层划分训练集 / 验证集保证正负样本比例一致 train_df, valid_df train_test_split( df[[text, label]], test_size0.1, stratifydf[label], random_state42 ) train_df.to_csv(data/train_pairs.csv, indexFalse) valid_df.to_csv(data/valid_pairs.csv, indexFalse)代码逻辑先把 query 和 document 用[SEP]拼成一句话这是 BERT 句对分类的标准输入格式。stratifydf[label]保证划分后正负比例和原始数据一致避免验证集出现极端分布。random_state42固定随机种子这对课设复现非常重要不然每次跑的数据划分都不一样报告里没法对照实验。3.2 加载中文预训练模型和 Tokenizer用 Hugging Face 的transformers库加载中文预训练模型。中文场景我一般用哈工大讯飞联合发布的bert-wwm-ext它对中文全词掩码做了优化在医疗文本上的效果比原始中文 BERT 稍好。from transformers import BertTokenizer, BertForSequenceClassification from transformers import Trainer, TrainingArguments model_name hfl/chinese-bert-wwm-ext tokenizer BertTokenizer.from_pretrained(model_name) model BertForSequenceClassification.from_pretrained( model_name, num_labels2 )这段代码会从 Hugging Face 模型库下载模型权重和词表。如果你在离线环境需要提前把模型文件下载好并指定本地路径比如model_name ./pretrained/chinese-bert-wwm-ext。注意num_labels2一定要和你的任务一致如果比赛是三分类就改成 3。BERT 的 tokenizer 对中文是字级别切分对“小儿”和“儿童”都会切到单个字所以不会出现分词错误这是它适合中文任务的一个重要原因。3.3 定义数据集类并训练PyTorch 训练需要把文本和标签包装成Dataset。以下代码实现了一个标准的句对分类数据集from torch.utils.data import Dataset import torch class QCDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len128): self.texts texts self.labels labels self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): encoding self.tokenizer( self.texts[idx], truncationTrue, paddingmax_length, max_lengthself.max_len, return_tensorspt ) return { input_ids: encoding[input_ids].flatten(), attention_mask: encoding[attention_mask].flatten(), labels: torch.tensor(self.labels[idx], dtypetorch.long) }这个类做的事情是对每个样本调用 tokenizer把它转成input_ids和attention_mask同时把标签转成长整型张量。max_length128是经验值绝大多数医学搜索 query 加上 document 摘要截断到 128 不会丢失太多信息。如果显存很宽裕可以调到 256但训练时间会明显增加。接下来定义数据集、训练参数并使用 Trainer 训练train_dataset QCDataset( train_df[text].tolist(), train_df[label].tolist(), tokenizer ) valid_dataset QCDataset( valid_df[text].tolist(), valid_df[label].tolist(), tokenizer ) training_args TrainingArguments( output_dir./results, num_train_epochs3, per_device_train_batch_size16, per_device_eval_batch_size32, evaluation_strategyepoch, save_strategyepoch, logging_dir./logs, learning_rate2e-5, load_best_model_at_endTrue, metric_for_best_modeleval_f1, ) def compute_metrics(eval_pred): predictions, labels eval_pred preds predictions.argmax(-1) from sklearn.metrics import accuracy_score, f1_score return { accuracy: accuracy_score(labels, preds), f1: f1_score(labels, preds, averagemacro), } trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_datasetvalid_dataset, tokenizertokenizer, compute_metricscompute_metrics, ) trainer.train()关键参数说明learning_rate2e-5是 BERT 微调的标准学习率比随机初始化的模型小两个数量级因为预训练模型已经收敛到较好状态学习率过大会破坏学到的语义表示。per_device_train_batch_size16在单张 12G 显存的卡上能跑动如果显存不足就降到 8同时把学习率降到 1e-5 作为补偿。evaluation_strategyepoch表示每个 epoch 结束后在验证集上评估一次方便观察训练曲线。load_best_model_at_endTrue会在训练结束后加载验证集上 F1 最高的模型权重这是防止后几个 epoch 过拟合的一个保险措施。跑完训练后用trainer.predict(valid_dataset)就能得到验证集预测结果计算 F1 和 AUC。一般中文句对分类任务这个基线能到 0.75~0.85 的 F1。如果数据规范还能更高。3.4 用验证集跑出基础分数训练完成后建议把结果打印出来存成文本记录pred trainer.predict(valid_dataset) probs pred.predictions # shape [样本数, 2] metrics evaluate_model(valid_df[label], probs) print(metrics)这里pred.predictions是模型的 logits 或概率取决于 transformes 版本常见是 logits。用torch.softmax转成概率即可。有了这套流程你已经跑通了最小实现。这个基线的价值在于后面所有改进比如阈值调整、对抗训练、融合都要拿它做对比。没有基线调参就是无源之水。4. 冲高分的四个操作阈值、对抗训练、交叉验证和模型融合4.1 阈值不是 0.5按验证集找最优阈值默认情况下模型的分类阈值是 0.5。但训练数据正负比例不均衡最优点往往不在 0.5。我见过很多课设代码直接argmax然后抱怨验证集分数上不去。实际上只需要在验证集上遍历阈值就能白捡一两个点。import numpy as np from sklearn.metrics import f1_score # 假设 probs 是 [N,2] 的概率矩阵取正类概率 pos_prob torch.softmax(torch.tensor(probs), dim-1)[:, 1].numpy() best_thr, best_f1 0.5, 0.0 for thr in np.arange(0.2, 0.8, 0.05): preds (pos_prob thr).astype(int) f1 f1_score(valid_df[label], preds, averagemacro) if f1 best_f1: best_f1, best_thr f1, thr print(f最优阈值: {best_thr:.2f}, 最优F1: {best_f1:.4f})这段代码的效果是以 0.05 为步长扫描 0.2 到 0.8 之间的阈值找到使验证集 F1 最大的那个。注意最优阈值只能在验证集上选不能用测试集去选否则会有数据泄露导致提交后成绩虚高。4.2 加一个 FGM 对抗训练让模型更稳FGMFast Gradient Method是常用的对抗训练方法思路是给 embedding 加上一个很小的扰动让模型在扰动下依然做出正确预测从而提升泛化能力。在文本分类任务上FGM 通常能提 0.5~1.5 个点代码量不大。使用 Trainer 时需要继承Trainer并重写compute_loss。这里给出一个可以直接用的简化版import torch from transformers import Trainer class FGMTrainer(Trainer): def __init__(self, *args, epsilon0.5, **kwargs): super().__init__(*args, **kwargs) self.epsilon epsilon def compute_loss(self, model, inputs, return_outputsFalse): labels inputs.get(labels) outputs model(**inputs) loss outputs.loss # 保存原始 embedding emb model.get_input_embeddings().weight.data emb_backup emb.clone() # 计算梯度并生成扰动 self.zero_grad() loss.backward() grad emb.grad norm torch.norm(grad, dim-1, keepdimTrue) 1e-8 delta self.epsilon * grad / norm # 给 embedding 加扰动重新算 loss emb.add_(delta) outputs_adv model(**inputs) loss_adv outputs_adv.loss # 还原 embedding emb.copy_(emb_backup) # 两份 loss 相加 return (loss loss_adv) / 2这段代码的逻辑先算一遍正常 loss反向传播拿到 embedding 的梯度然后用梯度方向构造扰动delta epsilon * grad / ||grad||加到 embedding 权重上再算一遍对抗 loss最后把两个 loss 平均。epsilon一般取 0.5太小没效果太大会破坏预训练表示。注意你需要在训练前额外调用一次model.zero_grad()来初始化梯度或者像代码里那样手动调用self.zero_grad()。使用方式和之前一样只需要把Trainer替换成FGMTrainer即可。如果中途报错大多是因为梯度没有被清零在compute_loss开头加一行model.zero_grad()就行。4.3 五折交叉验证用整个训练集训练避免验证集浪费单次划分训练集会损失 10% 的数据。五折交叉验证能最大化利用数据还能用多个模型做平均预测来降低方差。但训练时间是五倍课设时间紧的话建议先跑一折看效果。我这里给出一个典型的 KFold 框架import pandas as pd from sklearn.model_selection import StratifiedKFold df pd.read_csv(data/train.csv) df[text] df[query] [SEP] df[document] skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) models [] valid_scores [] for fold, (train_idx, valid_idx) in enumerate(skf.split(df[text], df[label])): train_df df.iloc[train_idx] valid_df df.iloc[valid_idx] print(f开始训练第 {fold 1} 折训练集 {len(train_df)} 样本验证集 {len(valid_df)} 样本) # 构建 dataset、trainer训练并保存每个 fold 的模型 # fold_model fbest_model_fold{fold} # 训练代码同第 3 章 # 记录该 fold 验证集概率和分数注意五折交叉验证的划分方式是StratifiedKFold它保证每一折的正负比例和整体相同。如果你的数据里同一个 query 对应多个 document需要换成GroupKFold否则相同 query 会同时出现在训练集和验证集形成数据泄露。具体见第 5 章避坑。4.4 模型融合BERT 和 RoBERTa 互补融合的策略有很多最简单也最有效的就是概率平均。训练两个结构不同但产出相同的模型比如 BERT-wwm 和 RoBERTa-wwm然后对每一条测试样本取两个模型的正类概率平均值再找阈值。# 假设 probs_model1, probs_model2 是两个模型在验证集上的正类概率 pos_prob1 probs_model1[:, 1] pos_prob2 probs_model2[:, 1] pos_prob_fusion (pos_prob1 pos_prob2) / 2 # 在融合概率上重新找最优阈值 best_thr_fusion, _ search_threshold(valid_df[label], pos_prob_fusion)为什么要融合两个不同模型因为它们学到的语义特征有差异一个可能更擅长识别症状描述另一个可能更擅长识别药品名称。融合后能互补。如果两个模型结构完全一样、随机种子也一样融合没有任何意义。建议做一个简单的消融实验单独 BERT 的 F1 是多少融合后是多少这个数据写进课设报告非常有说服力。5. 避坑指南数据泄露、显存不足、医学分词和答辩追问5.1 现象验证集 F1 高得离谱测试集却翻车验证集上 F1 到了 0.95一提交测试赛成绩惨不忍睹。这不是运气差大概率是数据泄露。原因划分训练集时用了普通train_test_split没有按 query 分组。同一个 query 的多个 document 被拆到训练集和验证集里模型记住了“看到这个 query 就输出这个标签”而不是学到语义匹配。另一种泄露是预处理时不小心把标签信息并到特征里比如把正样本的唯一 id 也作为输入。解决按 query 字段做分组划分。把同一 query 下的所有样本视为一个整体只能落在同一折中。用GroupKFold可以保证这一点。from sklearn.model_selection import GroupKFold # 假设 df 有 query 列 groups df[query] gkf GroupKFold(n_splits5) for train_idx, valid_idx in gkf.split(df[text], df[label], groupsgroups): train_df df.iloc[train_idx] valid_df df.iloc[valid_idx] break # 先跑一折看看GroupKFold以 query 为分组单位确保同一个 query 不会同时出现在两个集合。这是搜索相关性任务最常见也最致命的坑务必优先处理。5.2 现象显存不足把 batch size 调到 8 后效果明显变差理由很简单batch size 太小每个 step 的梯度估计的噪声变大而学习率没有同步调低导致收敛不稳定。解决优先考虑梯度累积而不是单纯调小 batch。梯度累积的做法是每跑几个小 batch 才更新一次参数等效于一个大的 batch。在 Hugging Face Trainer 里可以直接设置gradient_accumulation_steps比如per_device_train_batch_size8配合gradient_accumulation_steps2等效于 batch size 16。training_args TrainingArguments( output_dir./results, per_device_train_batch_size8, gradient_accumulation_steps2, learning_rate1e-5, # 其他参数不变 )注意梯度累积并不是完全等价于大 batch因为 BatchNorm 的统计量不一致但对于 BERT 类模型影响不大。如果把学习率也调低比如从 2e-5 降到 1e-5效果基本持平。5.3 现象医学专业词汇被切分后语义丢失很多同学会用 jieba 分词把文本先切了再喂给模型结果“阿莫西林”被切成了“阿莫西/林”或者英文药物名被切得七零八落。这个坑其实不难避BERT 中文模型是字级别的本身不做词级别分词所以不存在“分词错误”。真正的问题是你自己做了画蛇添足的预处理比如提前去除括号、删除英文单词、把数字统一成占位符。解决按最小预处理原则。除非文本里有明显 HTML 标签或乱码否则直接拼接成文本对原样丢给 tokenizer。英文缩写和数字可以直接保留BERT 的词表能处理。如果需要清洗只做全角转半角、去不可见字符。例如import re def clean_text(text): text text.replace(\u3000, ).strip() text re.sub(r\s, , text) return text请记住预训练模型训练时见过的文本是原始形态你清洗得越狠越偏离它学到的分布。5.4 现象同义改写造成误判比如“发烧”和“发热”语义模型不是万能的它对某些医学同义词匹配仍然不稳定。一个有效且低成本的手段是做同义词替换的数据增强。我之前在课设里把“发烧”替换成“发热”、“退烧”替换成“退热”训练样本量翻了一倍F1 提升了约 1 个百分点。做法先准备一个小型同义词表以 5% 的概率替换 query 中的某个词。注意不要每句都替换也不要替换 document 里的词否则模型会学到“文档越长越不相关”的假规律。import random synonym_dict { 发烧: [发热], 退烧: [退热], 儿童: [小儿], 咳嗽: [咳嗦, 咳嗽咳痰] # 最后一个慎用不要太离谱 } def augment_query(query, p0.05): for src, targets in synonym_dict.items(): if src in query and random.random() p: return query.replace(src, random.choice(targets), 1) return query这个增强方法虽然土但在医学搜索场景下很有效。答辩时还可以顺势讲一句“我用同义词增强缓解了医学术语同义改写问题”显得你考虑到了领域特性。5.5 现象答辩时被老师问“你为什么不用 BERT-large”课设答辩最容易翻车的地方不是算法跑不完而是被老师问住。常见问题有“你为什么不用更大的模型”“这个任务的难点一个人为定义吗”“你的成绩比 baseline 高多少”解决提前准备一个“选型理由”回答模板。我的思路是强调成本收益分析BERT-wwm 在中文任务上的表现已经和 BERT-large 相当但参数量只有后者的三分之一训练时间显著减少。在医学短文本匹配这类任务上更大的模型可能只带来 0.5% 的性能提升但显存占用和训练时间都翻倍。并且我可以列出自己做的消融实验base 模型 F1 0.82large 模型 F1 0.83但单卡训练时间从 20 分钟变成 60 分钟。这种“对比数据 权衡结论”的回答方式比单纯说“因为大家都用这个”要有说服力得多。6. 把项目包装成高分课设的最后几步从复现到验证6.1 文档说明里必须有的三张表课设文档不是把训练日志粘贴进去就完了。老师更看重你是否有工程化意识。我建议至少包含三张表数据处理前后样本数表、模型对比表、训练超参数表。模型对比表是核心展示你的改进路径模型配置验证F1验证AUC说明BERT-wwm 阈值0.50.8110.872最小基线BERT-wwm 最优阈值0.8250.873阈值调整BERT-wwm FGM 最优阈值0.8340.881对抗训练RoBERTa-wwm FGM 最优阈值0.8360.882更大预训练模型两个模型概率融合0.8420.888最终提交注意以上数字是示意用来演示表格结构你跑出来的结果替换成自己的即可。表下方一定要写一句“每个配置的训练随机种子固定为 42”这是可复现性的直接证据。6.2 复现的三个检查点第一所有脚本开头固定随机种子包括 Python 内置 random、NumPy 和 PyTorch否则每次结果都不一样。第二训练结束后把最优模型权重存到best_model目录预测时从目录加载而不是加载最后一个 epoch 的权重。第三写一个独立的predict.py读取测试集、加载模型、输出提交格式的文件提交前检查文件列名和行数是否和官方要求一致。很多课设分数不错但提交格式不对被扣分划不来。import torch from transformers import BertTokenizer, BertForSequenceClassification model_dir ./best_model tokenizer BertTokenizer.from_pretrained(model_dir) model BertForSequenceClassification.from_pretrained(model_dir) def predict(query, document): text query [SEP] document enc tokenizer(text, truncationTrue, paddingTrue, max_length128, return_tensorspt) logits model(**enc).logits prob torch.softmax(logits, dim-1)[0, 1].item() return prob这样写的代码哪怕换一台机器也能原样跑通因为模型和 tokenizer 都打包在best_model目录里。老师课设验收时最喜欢这种“开箱即用”的工程。6.3 一个进阶技巧用置信度分析找出模型的弱点训练完成后不要急着提交先对验证集做一次错误样本分析。把预测错误的样本按模型输出概率排序找出那些概率接近 0.5 的样本它们是模型犹豫的边界。你会惊讶地发现很多规律。比如我当时观察到大量错误样本里query 提的是“副作用”而 document 只讲“适应症”说明训练标注本身存在语义交叉。于是我修改了预处理把“副作用”“不良反应”这类词汇单独拆出来拼到样本里F1 又涨了一截。这个习惯我一直保留到现在。做课设时与其拼命调学习率不如花一个小时把 badcase 翻一遍。希望这个技巧对你的深度学习课设也有帮助少走一些我当年走过的弯路也希望这份源码加文档的思路能帮你稳稳拿下这门课设的高分。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?