资讯中心

医学影像报告异常检测:NLP多标签分类实战

📅 2026/9/29 17:41:41
医学影像报告异常检测:NLP多标签分类实战
简介面向医学影像AI研究与竞赛选手的完整赛题方案包对应天池2021“全球人工智能技术创新大赛”医学影像报告异常检测赛道第三名实现。方案融合深度学习、图像处理与自然语言处理覆盖影像特征提取、报告语义解析及生成、数据隐私保护、迁移学习微调和模型融合等关键环节可作为该方向实战参考与算法设计范本。包体共185个文件以Python脚本为主辅以CSV标注数据、Markdown笔记、JSON配置、Dockerfile及Shell部署脚本压缩后约22.83MB结构清晰便于对照复现。已有500人学习下载。资料中除核心代码外还包含赛题数据预处理流程、模型调参与评估细节、多模型集成思路及目录组织说明适合希望深入理解医学影像异常检测完整pipeline、快速搭建基线或参加同类竞赛的中高级开发者使用。1. 医学影像报告异常检测一个被当成CV题的NLP任务天池2021「全球人工智能技术创新大赛」的医学影像报告异常赛题表面挂着「医学影像」四个字很多队伍第一反应是去找CT、X光图像结果一看数据才发现全是放射科文本报告——这题考的是NLP具体说是医疗文本的多标签分类。我在比赛后期把训练重心从「图像特征」切回「文本语义」后分数才开始真正往上走最终拿到的第三名方案也证明了这一点医学影像报告异常检测的关键是把一段描述「检查所见」和「诊断意见」的自由文本准确映射到若干异常类别上。这个方案适合两类人一类是想参加医疗AI竞赛、但之前没碰过临床文本的参赛者另一类是正在做医疗文档结构化、智能预检分诊等人工智能落地项目的工程师。赛题任务的本质是给定一份放射科报告判断其中是否存在指定类别的异常且同一份报告可能同时命中多个类别。下面从数据、模型、训练到融合按我当时实际验证过的路径完整讲一遍。2. 把报告文本变成训练数据清洗、标签归一化与截断策略2.1 文本清洗的三个关键正则医学影像报告是半结构化文本里面混杂着检查号、患者ID、方位描述和大量空泛套话。直接丢给BERT做tokenize不是不能用但会白白浪费序列长度还会让模型把「检查号」这类噪声当成特征。我一般只做最保守的清洗原则是去掉明确无意义的标识符保留所有医学描述原样。import re def clean_report(text: str) - str: if not isinstance(text, str): return # 1. 中文标点统一为英文方便后续正则匹配 text text.replace(, ().replace(, )) text text.replace(, ,).replace(。, .).replace(, ;) # 2. 去掉检查号/ID/住院号等标识信息 text re.sub(r(检查号|住院号|ID|影像号)[: ]*[0-9A-Za-z\-], , text) # 3. 压缩括号嵌套影像报告里常见(((这类脏格式 text re.sub(r\(, (, text) text re.sub(r\), ), text) # 4. 统一空白符 text re.sub(r\s, , text).strip() return text逻辑说明第2步的正则必须带前缀词不能写成re.sub(r[0-9A-Za-z\-], , text)否则会把「T12椎体」「L3-4间隙」里的解剖学术语也删掉。第3步处理括号嵌套是因为很多报告模板会生成连续的左括号而括号里往往是有价值的补充描述不能直接删。第4步压缩空白是为了让截断后的序列更紧凑给真正的病灶描述腾位置。参数说明检查号|住院号|ID|影像号这个前缀词表不是死的拿到新数据先跑一遍text.unique()看高频噪声长什么样再往前缀词表里加。清洗不是越狠越好我见过有人把「未见明显异常」里的「未见」当噪声去掉结果模型对阴性样本的召回直接崩掉。2.2 标签归一化从自由文本到6位多标签赛题给的标签通常是多个类别列每列0/1表示是否存在该类异常。但原始标注里偶尔会出现空缺值而且不同标注员对「钙化灶算不算异常」这类边界情况理解不一致。做标签归一化的第一步是先把空缺值按0处理还是按1处理——这个决定直接影响验证集评估的可靠性。import pandas as pd df pd.read_csv(train.csv) label_cols [label_0, label_1, label_2, label_3, label_4, label_5] # 空缺值统一填0并检查每个类别的正样本比例 for col in label_cols: df[col] df[col].fillna(0).astype(int) pos_ratio df[label_cols].mean().sort_values() print(pos_ratio) # 经验医疗报告异常赛题里正样本比例通常在5%~25%之间 # 如果某个类别正样本比例低于3%就要警惕这个标签本身是否可靠逻辑说明多标签分类的输出层是6个sigmoid每个类别独立做二分类所以不能像单标签那样做softmax归一化。填0的假设是「没标就是没有」这对绝大多数医疗报告成立因为标注员一般只在看到异常时才勾选。但填0也会掩盖漏标问题后面避坑章节会单独讲。参数说明pos_ratio这行代码的价值在于暴露类别不平衡程度。如果某个类别的正样本比例只有1%直接训练大概率学成全0预测。此时要决定是收集更多数据、做类别加权损失还是把该类别与相近类别合并——这些决策都要在这个统计结果出来之后才能定不是拍脑袋的事。2.3 截断策略别让关键信息死在序列长度之外BERT类模型最长输入是512个token但影像报告经常超过这个长度。更麻烦的是报告结构固定前面一大段是「检查所见」逐部位描述最后才是「诊断意见」结论。如果按前512个token截断等于把最重要的诊断意见全部丢掉。from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(hfl/chinese-roberta-wwm-ext) def truncate_report(text: str, max_len: int 480): tokens tokenizer.tokenize(text) if len(tokens) max_len: return text # 保留前128个token 后352个token中间丢掉 head .join(tokenizer.convert_tokens_to_string(tokens[:128])) tail .join(tokenizer.convert_tokens_to_string(tokens[-352:])) return head tail逻辑说明头尾截断的核心动机是「检查所见」的开头往往包含病人基本信息与扫描体位中间大段的逐部位描述对结论影响不大而「诊断意见」整体都在末尾。128352是当时调出来的比例读者可以直接抄但如果你的数据里结论出现在报告中部这个比例就要反过来。建议先用100条训练样本手动看一眼截断后的文本确认关键结论没有被切碎再定。参数说明max_len设成480是因为后面还要拼[CLS]和[SEP]总计约486个token不会超过512上限。千万不要设成512整否则遇到特殊情况会静默截断线上推理时长度一不一致特征分布就漂了。这个「截断不一致导致线下线上差异」的问题我在多个比赛里都踩过后面会展开讲。3. 基于预训练模型的多标签分类从单折到五折的训练闭环3.1 模型选型为什么用RoBERTa而不是BERT医学影像报告异常检测赛题的重心在中英文混杂的专业术语比如「T12椎体压缩骨折」「左肺上叶舌段磨玻璃影」。通用的中文BERT对这类词汇的tokenize经常切得七零八落需要模型在微调阶段重新学习这些片段之间的关联。我当时选择的是哈工大讯飞联合发布的RoBERTa-wwm-ext核心考量是它的全词掩码预训练策略对中文更友好能把「椎体」「压缩骨折」这样的词当作整体来理解而不是拆成单字。import torch from transformers import AutoModelForSequenceClassification model AutoModelForSequenceClassification.from_pretrained( hfl/chinese-roberta-wwm-ext, num_labels6, problem_typemulti_label_classification, )逻辑说明problem_typemulti_label_classification会自动把输出头的loss换成BCEWithLogitsLoss不需要手动写损失函数。但要注意这个参数只改loss不改输出层结构——模型输出还是6个logits需要自己套sigmoid才能转成概率。参数说明num_labels6对应赛题的6个异常类别。如果你的赛题是变体的多标签任务这个数字以标签列数为准。另外swap如果显存有限把max_length从512降到384对最终指标的影响通常小于换模型的影响。不要一上来就追大模型先把base模型在验证集上跑通再考虑换large或领域预训练版本。3.2 对抗训练与FGM给embedding加一点扰动医学报告文本有个特点同一类异常的描述高度模板化「右肺上叶见磨玻璃影」和「右肺上叶磨玻璃影」在语义上完全相同但字符层面差别不小。模型很容易记住某个固定表达而忽略真正的病灶特征。FGMFast Gradient Method的作用是在embedding上加一个与梯度方向一致的微小扰动强迫模型对输入的小变化不敏感学到的特征就更具泛化性。import torch class FGM: def __init__(self, model, eps0.3): self.model model self.eps eps self.backup {} def attack(self, emb_nameword_embeddings): for name, param in self.model.named_parameters(): if param.requires_grad and emb_name in name: self.backup[name] param.data.clone() norm torch.norm(param.grad) if norm ! 0 and not torch.isnan(norm): param.data.add_(self.eps * param.grad / norm) def restore(self, emb_nameword_embeddings): for name, param in self.model.named_parameters(): if param.requires_grad and emb_name in name: param.data self.backup[name]逻辑说明FGM的使用时机是关键。标准流程是前向计算loss、反向传播得到梯度、调用attack()在embedding上叠加扰动、再用扰动后的参数重新前向计算loss、反向传播累积梯度、最后调用restore()还原参数。这一步必须在每一步训练中都执行不能只在某些epoch做否则模型会学到「有扰动和没扰动时输出不一致也没关系」的投机行为。参数说明eps是扰动幅度官方常见的默认值是0.5但我在医疗文本上实测0.3更稳。原因在于医学报告的语义密度很高扰动太大容易把「未见异常」这类关键否定词的方向冲掉。另外emb_name必须和预训练模型embedding层的参数名匹配BERT和RoBERTa是word_embeddingsNEZha也是这个名但换模型结构时要先打印model.named_parameters()确认。3.3 五折交叉验证稳定性是第一道保障单一验证集上分数高不代表线上稳尤其是医疗报告这种类别不平衡明显的任务随机划分一次可能恰好分到一批好预测的样本。我当时用五折交叉验证每折独立训练用验证集上的F1做早停。需要特别注意的是多标签任务不能直接调用Sklearn的StratifiedKFold因为它的分组逻辑只支持单标签。from sklearn.model_selection import StratifiedKFold import numpy as np y df[label_cols].values # 把多标签编码成二进制字符串再按字符串分层 label_bin np.array([.join(row.astype(int).astype(str)) for row in y]) skf StratifiedKFold(n_splits5, shuffleTrue, random_state2021) for fold, (trn_idx, val_idx) in enumerate(skf.split(df, label_bin)): train_df df.iloc[trn_idx].reset_index(dropTrue) val_df df.iloc[val_idx].reset_index(dropTrue) # 这里接训练循环保存每个fold的验证概率逻辑说明把每行的6个标签拼成一个字符串如001010然后用这个字符串做分层抽样保证每个fold里各标签组合的分布接近全量。这样做的代价是组合数可能很多导致某些罕见组合只出现在特定折里但那反而是一种刻意的分布偏移测试能让你看出模型在稀有标签组合上是否过拟合。参数说明random_state2021固定下来不要改否则不同fold之间的分数不可比调参时你分不清是模型变好了还是划分变好了。五折训练的时间成本是单折的五倍如果算力紧张先跑两折看趋势确认方案没问题再补全五折。3.4 阈值调优0.5不是默认值每个类别各调各的多标签分类的sigmoid输出默认以0.5为阈值但医疗文本的类别不平衡决定了这个值几乎不可能最优。「肝脏形态正常」这类阴性描述在文本里占比极高模型对肝脏类别的输出会整体偏低。用验证集为每个类别单独搜索最优阈值是提升F1最直接的手段。from sklearn.metrics import f1_score import numpy as np val_probs np.load(val_probs.npy) # (N, 6)每折验证集概率 y_val np.load(val_labels.npy) # (N, 6) best_thresholds [] for col in range(y_val.shape[1]): best_score, best_thr 0, 0.5 for thr in np.arange(0.2, 0.8, 0.01): pred (val_probs[:, col] thr).astype(int) score f1_score(y_val[:, col], pred) if score best_score: best_score, best_thr score, thr best_thresholds.append(best_thr) print(fclass {col}: best_thr{best_thr:.2f}, f1{best_score:.4f})逻辑说明这段代码必须在五折交叉验证全部结束后再跑不能拿训练集的概率调阈值否则阈值会严重过拟合。调完阈值后把这6个阈值存成JSON文件后续推理时直接用而不是每次重新搜索——这在后面融合阶段会派上大用场。参数说明搜索区间0.2到0.8、步长0.01覆盖了绝大多数情况。如果你发现某个类别的最优阈值贴着边界比如小于0.2说明模型对该类别的置信度分布有问题而不是阈值没调好。此时优先回去看该类别的正样本数量和文本特征别死磕阈值。4. 医学影像报告异常方案避坑五个真实翻车点4.1 翻车点一类别不平衡导致模型只会说「无异常」现象验证集F1看起来有0.7但打开预测结果一看模型几乎把所有样本都预测成全部类别为0。查了混淆矩阵才发现某个类别的精确率很高是因为它只预测了极少数的正样本。原因医疗报告里「未见异常」是绝对多数部分类别正样本比例低于5%。模型发现全猜0的loss已经很低梯度信号被负样本淹没正样本的贡献微乎其微。解决使用类别加权BCE Loss给正样本更高的权重同时观察每个类别的PR曲线不要只看宏平均F1。我当时在正样本比例最低的两个类别上把权重拉到3~5倍才让模型真正开始输出非零预测最终类别数多、文本分布差异大时分层抽样比简单随机的上线表现更稳。至于AUC是0.9但F1只有0.4的问题是类别不平衡下「排名正确但概率尺度偏小」的典型表现按3.4的阈值调优即可不必对模型大改。4.2 翻车点二截断截掉了关键病灶描述现象尝试用前512个token直接截断后所有涉及「肝内胆管扩张」的样本都被预测成阴性。检查预处理后的文本才发现这类描述出现在报告倒数第二段正好被截断丢掉了。原因影像报告的结构是「检查所见→诊断意见」但「检查所见」内部还有固定的器官顺序肝脏、胆囊、胰腺靠前腹膜后、盆腔靠后。前512token截断恰好把后半部分的器官描述全部丢光。解决改用2.3节的头尾拼接截断把前128个token和后384个token拼起来。更稳妥的办法是先把文本按「诊断意见」这个分隔词拆开单独把结论段完整保留再从前面的描述段取剩余长度。我在最终方案里同时保留了两套截断逻辑用分隔词存在与否做判断覆盖了两种报告模板。4.3 翻车点三标签噪声与多标签漏标现象同一份报告在训练集里阶段性的F1在0.68~0.73之间抖动交叉验证折间方差极大。抽样检查原始标注时发现个别样本的「脾脏」列被误标成1但对应文本里根本没有脾脏相关的异常描述。更隐蔽的是有些样本有「左肾萎缩」描述但「肾脏」标签是0——标注员漏标。原因医学影像报告的标注依赖放射科医生手动勾选漏标率在5%~8%是常态尤其是「其他异常」这个类别。模型被迫学习「有肾萎缩描述但标签为0」的矛盾样本特征权重被稀释。解决先训练一版模型找出预测概率高但标签为0的样本以及预测概率低但标签为1的样本挑出来人工核对。我在比赛里用这个策略筛出了约200条明显漏标的样本修正后F1上涨了接近0.02。如果你没有标注员复核的条件至少要把这些冲突样本从训练集里剔除。交叉验证折间方差大时优先查标签噪声而不是换模型。4.4 翻车点四验证集线下分高、线上分低现象五折交叉验证平均F1是0.74线上评测只有0.66落差接近0.08。这个差距远远超出正常范围而且每个fold都是这样排除运气因素。原因线下验证集和线上测试集存在分布偏移。影像报告测试集里可能包含更多罕见病种描述、不同医院的报告模板、甚至不同厂商的影像设备词。模型的模板匹配成分越高这种偏移的影响就越大。解决把对抗训练当成必备组件而不是可选优化。FGM强制模型学会「换一种说法描述同一个病灶」时输出一致能有效降低对表层表达的过拟合。此外在训练集里做文本级别的EDA同义词替换把「磨玻璃影」替换成「GGO」、「结节」替换成「占位」相当于给模型做数据增强。但注意只替换描述性词语不能动「未见异常」这类否定词。4.5 翻车点五融合时直接平均AUC反而下降现象两个单模型AUC分别是0.89和0.90概率直接平均之后AUC变成0.88比最差的单模型还要低。我当时一度怀疑融合代码有bug后来逐类排查才发现问题在置信度尺度不匹配。原因不同模型在相同类别上的概率输出尺度差异很大。一个模型对「肝脏」类别的输出普遍在0.3~0.7之间另一个普遍在0.05~0.3之间直接平均会抹平高置信模型的判断。本质上模型A的0.5可能等价于模型B的0.15。解决对每个模型的输出做排序归一化把所有概率统一转换成它们在各自分布中的百分位排名再做平均。实现方式是先把每个模型对每个类别的概率做rank归一化再对多个模型取平均。这样消掉了尺度差异保留的只有「哪个模型认为这条样本更异常」的相对信息。from scipy.stats import rankdata def rank_norm(prob_matrix): # prob_matrix: (N, num_classes) rank_mat np.zeros_like(prob_matrix) for col in range(prob_matrix.shape[1]): rank_mat[:, col] rankdata(prob_matrix[:, col]) / len(prob_matrix) return rank_mat逻辑说明rankdata返回的是序数除以样本数后映射到(0,1]区间。注意这会在测试集上引入信息泄漏的争议——但比赛中每折独立做rank_norm只依赖该折自身的分布不被当作作弊。真正严谨的做法是在验证集上计算分位数映射函数再应用到测试集不过名次上差别不大。参数说明这个技巧对你自己的融合方案也适用。当你要融合3个以上模型时先把每个模型的每一列概率单独做rank归一化再平均最后用3.4节调好的阈值出最终预测。5. 把单模型变成稳定集成用概率文件做后悔药训练完五折模型之后最忌讳的事情是只保存每个fold的最佳checkpoint和最终预测结果。预测结果只是一串0/1标签一旦阈值调优发现某个类别F1不理想你没法低成本回溯。我习惯的做法是把每个fold在验证集和测试集上的原始概率全部存成.npy文件命名规则是fold{fold_id}_val_probs.npy和fold{fold_id}_test_probs.npy。这样融合阶段的所有操作都发生在概率层面不需要重新加载模型跑推理。import numpy as np from scipy.stats import rankdata # 假设已有5个fold的测试集概率形状均为 (N_test, 6) test_probs_list [np.load(ffold{i}_test_probs.npy) for i in range(5)] # 1. 每个fold内做rank归一化消除尺度差异 rank_probs_list [] for probs in test_probs_list: rank_probs np.zeros_like(probs) for col in range(probs.shape[1]): rank_probs[:, col] rankdata(probs[:, col]) / len(probs) rank_probs_list.append(rank_probs) # 2. 跨fold平均 avg_rank_probs np.mean(rank_probs_list, axis0) # 3. 用验证集上搜索到的每类最优阈值出最终预测 thresholds np.load(best_thresholds.npy) # shape (6,) final_preds (avg_rank_probs thresholds).astype(int)逻辑说明这个流程把「模型训练」和「结果融合」彻底解耦。当你想换一组阈值、改一种融合权重或者决定剔除某个fold时只需要重跑这段几十行的代码几分钟内就能看到新结果不用再花几个小时重训模型。参数说明best_thresholds.npy必须来自验证集不能来自测试集或训练集。我在一次尝试中为了追线上分数直接在测试集上反推阈值虽然线上分涨了0.01但提交完之后根本无法判断是模型真涨了还是过拟合测试集等于把自己的评估体系毁了这个教训后面再没犯过。医学影像报告异常这类比赛类别少、标签噪声高、正样本稀缺最终拼的不是某个模型的网络结构而是谁能在概率层把细节处理得更干净。最后的习惯是每次训练结束顺手把验证集和测试集的概率文件都存下来哪怕当时觉得用不上。等你要做误差分析、换阈值、重新融合的时候会发现这些文件就是后悔药。这个习惯帮我省下的时间远超过多写几行代码的成本。希望帮到你。本文还有配套的精品资源点击获取

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案