公司动态
医学影像报告异常检测实战:从数据清洗到多模型融合调优
简介本资源是天池2021“全球人工智能技术创新大赛”医学影像报告异常检测赛道第三名的完整技术方案面向AI医疗方向的研究者、算法工程师及竞赛进阶学习者聚焦于X光/CT等影像与对应文本报告联合建模的临床辅助诊断任务。压缩包共185个文件22.83MB含139个Python脚本涵盖CNN特征提取、NeZha/BERT文本编码、LSTM/DPCNN报告建模、多模型融合推理等核心逻辑、14个CSV数据文件含训练集、测试集及多模型预测结果、8个Markdown文档含环境配置、训练日志、消融实验分析、3个Dockerfile与Shell脚本支持容器化部署以及PDF方案说明与PPTX答辩材料。已有499人学习下载提供从数据预处理、双模态建模、模型集成到评估可视化的全链路可复现代码特别包含merge.csv等中间结果整合逻辑与nezha.csv/han.csv等不同文本编码策略对比便于深入理解多源特征融合设计思路。 去年打天池2021“全球人工智能技术创新大赛”我们队伍选的是医学影像报告异常检测赛道最后拿到第三名。复盘的时候我们几个都觉得这套方案在医学文本处理上并没有多复杂的结构真正拉开差距的是一些容易被忽略的细节。今天把完整思路整理出来既是对自己的记录也给后面想打医学NLP赛题的同学做参考。这个赛题的核心是判断一份医学影像报告里是否存在异常描述并且尽可能把异常的部位和类型识别出来。放到真实业务里它对应的场景就是辅助影像科医生做报告质控或者把不规则的自然语言报告转成结构化结果。对打比赛的人来说它是一个很典型的“中文医疗文本标签噪声样本不均衡”的组合任务。这里先说明一下我们当时处理的输入是影像科报告的文字内容不是影像图片。如果你遇到的赛题是“识别医生手写报告照片”那方案思路会完全不同。下面所有内容都基于文本分类和文本理解这条线展开。1. 赛题理解医学影像报告异常检测到底在检测什么1.1 任务形态和评估指标要先搞清楚拿到赛题之后第一件事不是急着跑模型而是把任务形态确认清楚。医学影像报告异常检测通常有两种形态一种是给一段报告文本要求输出一个二分类标签0表示正常1表示异常另一种是更细粒度要求判断异常属于哪个器官、哪类病变甚至把异常描述的位置抽取出来。我们当时拿到第一批数据后发现报告文本来自真实体检和门诊场景已经做了脱敏处理标签是人工标注的异常标记。表面上像是二分类但里面不少报告同时出现多个异常比如“右肺上叶结节”和“双肺纹理增多”会在同一份报告里出现。如果简单按单标签二分类做会把这种多异常信息全部压成一个标签信息损失很大。所以我们最终采用了多标签分类框架每个标签对应一类常见异常描述模型输出多个概率。评估指标也要在第一时间吃透。如果官方用F1、AUC还是RecallK直接决定后续调优方向。只看AUC阈值选择就不重要看F1或宏平均就必须认真做阈值和后处理。我们当时主要参考F1和macro-F1后期所有调参都围绕这个指标展开。提示拿到赛题后建议先写一个简单baseline提交一次确认线上指标的计算方式和线下验证指标是否一致。很多队伍后面翻车就是因为线下验证指标和线上评估指标没有对齐。1.2 为什么不能靠词典硬匹配最初我们也想过是不是写一个关键词规则就能搞定。医学报告虽然术语相对统一但同一部位、同一病变在不同医生笔下会有完全不同的表达。比如“肺纹理增粗”“双肺纹理增多”“肺纹理略重”都可能指向同一个异常规则很难覆盖完整。更麻烦的是报告里大量出现“未见异常”“未见明显异常”这些否定表达一旦和病变词出现在同一句话里词典匹配基本就废了。所以这个任务本质上是对中文医学文本的语义理解。模型需要学会两个能力一是识别医学实体和异常描述二是理解否定、程度、位置关系。这也是我们坚持使用预训练语言模型而不是传统机器学习模型的原因。BERT这类模型在大规模中文语料上预训练过对句法和语义有基础理解迁移到医学报告上只需要少量数据微调就能比词典和TF-IDF高出一大截。2. 数据清洗与报告文本预处理2.1 医学报告文本不是普通文本清洗规则要单独设计很多人做文本分类习惯拿过数据直接分词但医学报告文本有几个特殊的坑。第一是格式和单位。中文报告里经常出现“右上肺野可见片状、条索状高密度影边缘模糊”其中逗号、顿号、分号混用还有“mm”“cm”等单位粘连在数字后面。我们保留数字和单位但把所有中文标点统一成半角减少无意义字符的干扰。同时处理了全角括号和半角括号混用的情况。第二是脱敏标记。赛题数据经过脱敏人名、医院名、日期大概率会变成特殊符号或连续星号。这些符号如果不处理模型会当成有效token。我们统一把连续的“*”替换成[UNK]或直接删除避免模型学习到“有星号就是异常”这种假规律。第三是报告结构。很多报告包含“检查所见”和“诊断意见”两部分。诊断意见是标签的主要依据但“检查所见”也不能丢。我们采取的策略是保留全部文本但在训练时让模型知道两段的分界使用类似[CLS]检查所见内容[SEP]诊断意见内容[SEP]的输入结构。实际验证下来这个分界信息对F1提升比较明显。第四是长文本截断。医学报告长度差异很大短的只有十几个字长的几百字。BERT类模型最大长度一般512我们统计长度分布后选择256作为最大长度。超过部分不是从头截而是从“诊断意见”往前截尽量保留结论部分。如果直接用一个固定开头截断很容易把最重要的诊断意见截掉。举一个处理前后的例子。原始报告可能是“检查所见两侧胸廓对称。右肺上叶见磨玻璃样密度结节影大小约0.6cm×0.5cm。纵隔窗示纵隔未见肿大淋巴结。诊断意见右肺上叶磨玻璃结节建议随访。”清洗后变成[CLS] 检查所见两侧胸廓对称。右肺上叶见磨玻璃样密度结节影大小约0.6cm×0.5cm。纵隔窗示纵隔未见肿大淋巴结。[SEP] 诊断意见右肺上叶磨玻璃结节建议随访。[SEP]这个输入结构一共80多个token完全在256以内。模型能清楚区分所见和意见比把整段报告混在一起输入更好。2.2 样本不均衡与数据增强医学报告数据天然不均衡正常报告数量通常远多于异常报告有些罕见异常标签更少。我们统计后发现数量最少的标签只占训练集的1%左右。针对这个问题我先试了最简单的做法给每个标签设置不同权重用BCEWithLogitsLoss里的 pos_weight 把少数类样本的loss放大。这个操作简单有效但对偶发性较强的标签帮助有限。后来又做了数据增强。医学文本不能随便改词容易把病变部位改错于是我们主要用两种增强方式同义替换只替换程度副词和连接词比如“明显”“可见”“较前”不替换疾病实体文本回译把“诊断意见”从中文翻译成英文再翻回来生成语义相近但表达不同的文本。实验下来回译对少数类标签有一点提升但提升不大。真正有效的是伪标签用训练好的模型对大量无标注报告预测把高置信度样本加入训练集重新训练。伪标签在比赛后期帮我们把整体F1提高了0.5个点左右。2.3 训练集和验证集怎么划分才靠谱医学报告通常同一个患者可能有多份报告如果直接随机划分模型可能在验证集上“作弊”。我们推断赛题数据也符合这个逻辑所以按照报告中的患者标识做分组划分确保同一个患者的报告不会同时出现在训练集和验证集。另外多标签分类任务里随机划分可能出现某个标签在验证集中样本太少难以评估。我们做了分层多标签划分尽量让每个标签在训练集和验证集中的比例接近。虽然不能完全保证均衡但比完全随机稳定很多。注意如果线下验证的时候发现F1波动很大多半是划分方式有问题。可以先固定随机种子并尝试3-5种划分选取最接近线上表现的划分方式作为最终评判标准。3. 模型选型与训练细节3.1 基座模型怎么挑中文预训练模型横向对比我们一共尝试了BERT、RoBERTa-wwm-ext、ERNIE 1.0、MacBERT和NEZHA这几个中文预训练模型。在同样数据、同样训练轮次下MacBERT和RoBERTa-wwm-ext的表现明显好于原生BERT。原因也不难理解MacBERT用全词掩码和纠错式掩码对中文文本语义建模更充分ERNIE因为融合了知识增强在实体理解上更有优势。但我们没有把赌注押在单个模型上。最终方案里同时保留MacBERT、RoBERTa-wwm-ext和ERNIE三个模型分别作为三个不同视角的编码器。每个模型结构上略有差异一个直接取[CLS]向量做分类另一个在[CLS]后面接了一层BiLSTM再过一个全连接层相当于让模型多学一层序列信息。对比下来加BiLSTM对短文本没有明显收益但对长报告有一点提升。这里放一下我们实验中的相对表现数据是团队内部验证集的结果不代表官方排行。模型验证集F1备注BERT0.883基线模型RoBERTa-wwm-ext0.902全词掩码优势明显MacBERT0.908对医学文本最友好ERNIE 1.00.901实体理解更强三模型融合0.921最终提交方案注意F1是我们在自己划分的验证集上算出来的主要用来横向对比方案不必太纠结绝对数值。3.2 损失函数细节不该只用一个BCE多标签分类的惯用做法是BCEWithLogitsLoss但医学报告异常检测有个特点标签之间不是完全独立的。“肺结节”和“肺部阴影”经常同时出现“骨折”和“软组织肿胀”也经常同时出现。纯粹的多标签二分类会忽略这种共现关系。当时我花了半天时间试了三种损失普通BCE、带pos_weight的BCE、BCE加标签共现惩罚项。实验结果显示带pos_weight的BCE提升最明显。标签共现惩罚项理论上更好但实现复杂线上提升很小后来放弃了。数据量小的时候额外约束容易过拟合。具体实现上pos_weight不是拍脑袋设的。我按每个标签负正样本数量的比例计算比如某个标签正样本占比只有2%则它的pos_weight约为49。但直接设这么大容易让模型过于激进反而把很多正常报告判成异常。后来我做了个上限将pos_weight限制在10以内并在线下验证集上调了一个系数最终取的是标签频率倒数的0.8次方效果最好。3.3 训练超参数与对抗训练超参数方面我们用AdamW优化器初始学习率3e-5batch size 32最大长度256训练5个epoch。前3个epoch冻结了BERT底层参数只训练分类头后2个epoch解冻全部参数做微调。这个“先预热再全量微调”的策略让模型收敛更快也减少了过拟合。比赛中后期我加入了FGM对抗训练。FGM的基本思想是在embedding层加入一个小的扰动使得模型对输入微小变化不敏感。实现起来也就十几行代码但对提升泛化能力很有帮助。加入FGM后线上F1提升了大约0.3个点。这里放一段FGM的核心代码方便直接复现。import torch class FGM: def __init__(self, model, epsilon0.5): self.model model self.epsilon epsilon self.backup {} def attack(self): for name, param in self.model.named_parameters(): if param.requires_grad and embedding in name: self.backup[name] param.data.clone() norm torch.norm(param.grad) if norm ! 0 and not torch.isnan(norm): r_at self.epsilon * param.grad / norm param.data.add_(r_at) def restore(self): for name, param in self.model.named_parameters(): if param.requires_grad and embedding in name: param.data self.backup[name]使用的时候在每个batch的loss反传之后、优化器step之前调用attack()再重新算一次loss并反传最后restore()恢复参数。注意扰动只加到embedding层不要动其他层。EMA指数移动平均我们也用了效果不如FGM明显但稳定了训练曲线。做法是保存参数的历史滑动平均用滑动平均后的参数做推理。EMA对训练后期loss震荡的模型比较友好如果你的最终成绩忽高忽低可以试试。3.4 多折交叉验证和伪标签的配合最终我们使用了5折交叉验证每个模型训练5个fold总共15个模型。每次训练都保存验证集上指标最好的checkpoint而不是最后一个epoch的权重。训练时间虽然翻了几倍但融合后的稳定性明显更好。伪标签放在交叉验证之后做先用15个模型的平均预测给无标注报告打分挑出每个标签得分都高于0.95或者都低于0.05的样本加入训练集再训一轮。加入伪标签后的模型在验证集上的表现有小幅上涨但数量不能太多我们控制在一万条以内避免噪声引入过多。4. 多模型融合与后处理4.1 融合不是简单平均先看模型之间差异模型融合的核心前提是模型之间有差异。如果几个模型完全一样平均也没有意义。我们特意选用不同预训练基座并在训练数据上做了一点区别MacBERT用原始文本RoBERTa用清洗后去掉“检查所见”的文本ERNIE用添加了报告结构的文本。这样几个模型虽然任务相同但内部建模视角不同融合收益最大。融合前我们先看了每个模型在验证集上的错误样本发现MacBERT擅长判断否定表达RoBERTa在长文本上表现好ERNIE对实体和部位识别更准。各自有互补性融合之后的F1比最好的单模型高1.2个点左右。4.2 概率融合与阈值搜索融合方式上我们用加权概率平均。权重不是拍脑袋而是用验证集上的简单搜索确定的初始权重都是1/3然后每次固定其他权重调整一个模型的权重直到验证集F1不再上升。最终权重大致是MacBERT 0.4RoBERTa 0.3ERNIE 0.3。拿到融合后的概率后还需要找最优阈值。多标签任务里每个标签可以有自己的阈值。我们先用验证集对每个标签做网格搜索搜索范围从0.3到0.7步长0.05选每个标签F1最高的阈值。全部标签用同一个阈值会损失一些分数尤其是少数类标签往往需要更低的阈值才能召回更多异常。4.3 温度缩放让概率更可信医学报告异常检测的另一个麻烦是模型输出的概率分布不够校准。有时候模型给出0.6的概率已经算是很高置信度有时候0.4的概率却判断错误。为了让阈值搜索更有效我们对概率做了温度缩放公式是p 1 / (1 exp(-(logit / T)))。T通过最小化验证集上的对数损失找到。加入温度缩放后阈值搜索的结果更稳定线上F1也小幅提升。4.4 规则后处理修正边界样本模型在边界样本上最容易出错。我抽了一批预测概率在0.45到0.55之间的样本发现很多错误集中在两类一类是文本里明确写了“未见明显异常”但模型给了中间概率另一类是文本里有“考虑”“可见”“考虑为”等强阳性提示词但模型概率偏低。于是我们加了一个轻量级规则后处理如果报告里包含“未见异常”“未见明显异常”这类否定短语并且模型预测概率低于0.55则强制判为0如果报告里包含“考虑为”“可见”“诊断意见明确提到”等强阳性表达并且模型预测概率高于0.45则强制判为1。这个规则只动了约5%的样本但能把边界样本的F1拉起来约0.3个点。5. 比赛过程中的关键问题与排查记录5.1 线下线上不一致原来是吃了文本顺序的亏我们第一次提交时线下验证F1在0.88左右线上只有0.85差了3个点。排查了很久后来发现是预测阶段和训练阶段文本截断方式不一致。训练时对超过256长度的文本从“诊断意见”部分开始保留但推理代码里用了简单的前256字符截断。两个阶段输入分布不一样模型泛化自然变差。这一类问题在NLP竞赛里特别容易踩。我的建议是把数据预处理函数统一封装成一个类训练、验证、推理全部调用同一个函数。不要因为推理时偷懒复制粘贴一段代码那次线上掉分完全是可以避免的。5.2 标签噪声比想象中严重医学报告标注很难完全一致同一个描述在不同医生眼里可能给出不同结论。我们随机抽了500条训练数据人工复查发现有接近3%的标签可能标错了。模型强行拟合这些错误标签会在验证集上出现奇怪的高分但实际效果差。针对标签噪声我们做了一件事第一轮训练后用模型预测训练集找出“模型预测高置信度但标签相反”的样本人工抽样验证。如果确认标签错误就把该样本从训练集中剔除。后来也试过用标签平滑缓解噪声把hard label变成软标签最终保留了这个trick。标签平滑让模型不过分相信训练集中的某个标签对噪声数据比较有效。5.3 推理时间与显存限制比赛到后期需要同时加载15个模型做融合推理成了一个问题。我们先把每个模型转成半精度FP16显存直接减半。再用batch推理把验证集的预测结果一次性生成。对于长文本没有用滑动窗口因为报告长度超过512的样本本来就少直接截断影响可控。如果遇到线上推理时间受限的场景还可以做模型蒸馏用融合模型的预测结果训练一个小的单模型比如6层BERT在保持大部分精度的同时显著降低推理耗时。我们当时因为线上没有严格的耗时限制就没有做蒸馏但如果你的场景对时延敏感这是值得考虑的方向。6. 拿到第三名的关键经验和还能改进的地方6.1 这次方案里真正起作用的三件事复盘整个比赛真正拉开差距的不是某个模型而是三件事。第一把数据吃透了。我们在数据清洗和文本结构上花的时间比调模型的时间还多。特别是“检查所见”和“诊断意见”的分段处理让模型少走了很多弯路。第二多折多模型融合稳住了上限。单模型再强也不如多个差异模型融合来得稳。我们最后能用15个模型做平均是因为训练脚本从一开始就写得足够规范换模型、换seed都只需要一条命令。第三阈值和后处理没有掉链子。很多队伍模型分数不错但直接使用默认0.5阈值少数类标签的F1被拉低了。我们为每个标签单独搜索阈值加上温度缩放最终在验证集上白捡了0.6个点。6.2 那些做了但收益不大的尝试我们也试过一些看起来很高级的方法最后没有进入最终方案。比如用图神经网络建模标签共现关系提升很小但训练成本翻倍比如用生成模型做报告文本增强生成的文本质量不够加入后反而带来噪声。如果你时间有限建议先做伪标签和对抗训练这两项是性价比最高的。6.3 后续能继续扩展的方向比赛结束后我又把方案迁移到其他医学文本任务上比如检查建议生成、影像所见结构化。整体框架基本可以复用预训练模型加多折融合加阈值校准。如果数据更大、标签更细可以进一步尝试用医学语料继续预训练BERT做领域预训练模型用序列标注模型直接抽取异常描述而不是只做分类引入病历、检验指标等旁路信息做多模态融合。这次比赛给我留下的一个习惯是以后遇到医疗文本任务我一定先做结构分析和标签审查再谈模型。模型再强也救不了脏数据和坏标签。希望这份复盘对你有用也欢迎在评论区交流医学NLP任务里踩过的坑。本文还有配套的精品资源点击获取