
简介本资源是天池2021“全球人工智能技术创新大赛”医学影像报告异常检测赛道第三名的完整技术方案面向AI医疗方向的研究者、算法工程师及医学图像分析学习者聚焦于X光/CT等影像与对应文本报告的联合建模与异常识别任务。压缩包共185个文件22.83MB含139个Python脚本涵盖CNN特征提取、LSTM/DCNN文本建模、NEZHA预训练微调、模型融合与推理部署、14个CSV数据集与中间结果文件、8个Markdown技术文档含方案设计说明、实验记录与消融分析、3个Dockerfile及Shell脚本支持环境复现。已有499人学习下载提供从数据清洗、多模态建模、交叉验证到提交生成的全流程可运行代码目录结构按训练/评估/推理分层组织并附PDF方案概述与PPTX答辩材料便于深入理解高排名队伍的技术选型逻辑与工程落地细节。 2021年天池的“全球人工智能技术创新大赛”里医学影像报告异常检测这个赛道我印象很深。当时我们队伍三个人做了将近一个月的实验最后拿到第三名。这两天整理代码顺手把整个方案沉淀成一篇复盘。如果你也在做医疗AI文本方向或者准备打类似的NLP比赛这篇应该能帮你少踩不少坑。先交代一下背景这个赛题不是图像分割也不是目标检测。输入是一段放射科医生写的影像报告文本输出是判断这份报告是否存在若干类异常。本质是多标签文本分类但又有很强的医疗领域特性。很多队伍一上来就把通用BERT丢进去训练结果被验证集教做人。医疗报告的语言习惯、否定表达、模板化程度、标签分布和新闻、电商评论完全不是一个世界。所以这篇复盘的重点不是罗列trick而是讲清楚每一步为什么这么做以及哪些操作看着合理、实则是陷阱。1. 赛题本质医学影像报告异常检测到底在检测什么1.1 任务更像文本分类而不是图像识别很多朋友一听到“医学影像报告异常”第一反应就是图像模型。实际上赛题给的是报告文本不是影像原始图片。比如一份胸部CT报告里面会写“右肺上叶见磨玻璃影边界清晰周围未见明显渗出”模型需要根据这句话判断是否存在“磨玻璃影”这类异常标签。所以这是一个典型的多标签文本分类问题。输入是文本序列输出是多个标签的0/1向量。这里有个很容易忽略的点标签之间不是互斥的一份报告可能同时存在好几种异常也可能完全是正常报告。模型输出层不能用softmax做单分类而是每个标签独立判断用sigmoid输出概率再通过阈值决定是否为阳性。1.2 评估指标的坑赛题用的评估指标是macro F1不是accuracy。多标签分类里accuracy会被绝大多数负类样本带偏。比如某个异常标签只占全部样本的2%模型把所有样本都判为阴性准确率高达98%但F1直接崩盘。Macro F1会先对每个标签单独计算F1再对所有标签取平均。这意味着每个标签尤其是少数类标签都被赋予了相同的权重。医疗场景里漏掉一个“气胸”标签的后果比多报一个“陈旧性病变”严重得多。所以方案设计的重心不是把整体准确率做上去而是让每个标签的查准率和查全率尽可能平衡。1.3 赛题数据的特点与隐藏难点医学影像报告有几个非常鲜明的特点文本很长经常超过512个tokenBERT类模型需要处理截断问题。语言高度模板化不同医院的报告格式差异大同一病灶的描述方式五花八门。否定词是重灾区“未见异常”“无明显增大”“不考虑肿瘤”这些表达如果模型没学会识别否定很容易把阴性误判为阳性。标签分布极不均衡常见标签和罕见标签的样本量可能差几十倍。还有一个隐藏难点测试集的分布可能和训练集有偏差。比赛官方会尽量保证数据来自不同分布如果模型只是记住了医院模板而不是真正理解语义线上分数就会明显掉。这也是为什么很多队伍本地验证集跑得很高一上测试集就崩。2. 赛前准备与模型选型为什么最后选了中文医疗预训练模型2.1 基线搭建先把流程跑通再做trick我们第一步不是去试各种花哨模型而是用通用中文BERT跑了一个最简单的baseline。目的有三个验证数据加载、标签编码、评估代码是否正确。确定一个可以对比的分数基准。暴露数据里的问题比如label缺失、文本乱码、长度分布异常。Baseline跑通后后面的每一步改进都有对照。如果一开始就上复杂融合模型出了问题根本不知道是模型的问题还是数据的问题。这个习惯在比赛里特别重要。2.2 预训练模型横向对比我们在通用模型和医疗领域模型之间做了多组对比。具体试过的模型包括模型特点验证集表现BERT-base-chinese通用中文语料预训练基准线RoBERTa-wwm-ext全词掩码泛化稍好比BERT略高MacBERT纠错式掩码对文本改动更温和明显提升NEZHA-base相对位置编码中文任务效果稳定与MacBERT接近医疗领域模型如BioBERT中文变体/医疗语料微调模型在医学文本上继续预训练效果最好最终我们选择了医疗领域模型作为主力。原因不难理解通用模型虽然语义理解能力强但医学报告里有大量专业术语和固定搭配比如“磨玻璃影”“胸腔积液”“肺纹理增粗”这些词汇在通用语料中出现的频率很低模型的词向量表征不够精准。领域预训练模型等于提前见过这些表达微调时只需要少量样本就能快速适配。2.3 使用领域模型的注意点领域模型不是银弹。换模型后还要检查几个细节分词器的词表是否覆盖了报告里的特有符号和单位比如“cm”“mm”这类缩写。最大序列长度是否一致不同模型的position embedding长度可能不同。是否需要医学领域的自定义分词。中文BERT大多基于字所以分词影响不大但如果用带词的模型分词不一致会直接影响效果。我们还做了一个小实验把报告的“影像所见”和“诊断意见”两部分拆开分别用模型编码后再拼接。结果发现双塔结构的收益不如直接把完整文本输入模型。因为诊断意见虽然承载最终结论但影像所见里包含了大量细节证据拆开后会丢失前后文的逻辑关系。所以最终方案还是用单序列输入。3. 数据清洗与样本构造决定模型上限的细节3.1 报告文本的结构医学影像报告一般分两部分前半段是“影像所见”描述影像上看到的客观现象后半段是“诊断意见”或“印象”给出综合判断。异常标签通常和诊断意见对应但影像所见里的形态描述同样关键。我试验过三种输入方案只用诊断意见只用影像所见完整文本只用诊断意见时因为文本短模型训练快但某些标签在诊断意见里表达不明确反而在影像所见里更清晰。完整文本效果最好但要处理长文本截断的问题。3.2 清洗规则不能为了干净而丢掉语义医疗报告的数据清洗比一般文本更敏感。我们做的清洗操作很少主要是去除头部患者信息、医院名称、检查编号等隐私字段。统一全角半角字符把中文标点统一为全角。去除重复标点和多余空格。保留“未见”“无明显”“不除外”这类否定词不做去停用词处理。这里有个反直觉的点很多人做文本分类喜欢去掉停用词但医疗报告中的“无明显异常”和“有异常”只差一个“无”字去停用词等于自杀。我们在实验里对比过stopword过滤验证集F1直接掉了2到3个百分点果断放弃。3.3 长文本截断策略头尾保留比从头截断靠谱BERT类模型输入长度一般限制在512。医疗报告经常超过这个长度尤其CT和MRI报告。最简单的截断方式是从头截断把超过512的部分丢掉。但这样做会丢掉最后的诊断意见而诊断意见恰恰是标签最集中的区域。我们用的策略是“头尾”保留保留前128个token通常是检查方法和影像所见开头。保留后384个token通常是影像所见后半部分和完整诊断意见。中间部分舍弃。这个策略基于一个观察报告的重要信息集中在开头和结尾。开头交代检查部位和方式结尾给出诊断结论。中间的大量形态描述虽然有用但相对冗余度更高。调整头尾比例时我在验证集上做了小范围搜索发现1:4的比例最合适模型能同时看到“这是什么检查”和“最终结论是什么”。3.4 标签共现与类别不平衡我做了标签共现矩阵发现几个标签经常一起出现比如“肺气肿”和“肺大泡”“胸腔积液”和“肺不张”。这些共现关系有临床逻辑但模型不一定能学到。我尝试过在后处理中加规则比如当模型预测“肺大泡0”但“肺气肿1”时将“肺大泡”的概率上调。结果发现这类规则在验证集上有效但在测试集上收益不确定。因为比赛数据的标注噪声不小强行加规则可能引入错误。对于类别不平衡我实验了三种方案重采样对少数类样本过采样效果不稳定容易过拟合。加权BCE给少数类更高的loss权重有效但需要调权重系数。Focal Loss降低易分类样本的loss贡献让模型关注难样本效果最好但训练后期需要小心震荡。具体做法我放在下一节讲。4. 训练细节与提分操作从0.82到0.87的差距来自这里4.1 分层学习率与对抗训练预训练模型微调最怕两件事灾难性遗忘和过拟合。分层学习率是解决这两个问题的常用手段。我们对不同的参数组设置不同学习率Embedding层和底层Transformer学习率设为1e-5因为这些层已经学到了通用语义不需要大幅度调整。高层Transformer学习率设为2e-5。分类头学习率设为3e-5因为这是随机初始化的部分需要快速收敛。实际训练中这个设置比统一学习率稳定很多验证集loss下降更平滑。对抗训练也很有用。我们用的是FGMFast Gradient Method核心思想是在embedding层添加一个微小的扰动让模型对输入的小变化不敏感从而提升鲁棒性。伪代码如下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 or embeddings 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 name in self.backup: param.data self.backup[name] self.backup {}使用时在每个batch的前向传播后计算梯度然后调用attack再反传一次最后restore。注意epsilon不能太大0.5在医学文本任务上会引入太多噪声我们最终用0.3。4.2 损失函数的选择与调整一开始我们用标准的BCEWithLogitsLoss效果中规中矩。换成Focal Loss后少数类的F1有明显提升。公式是FL(p_t) -alpha * (1 - p_t)^gamma * log(p_t)gamma控制对难样本的关注程度gamma越大模型越关注难分样本。alpha用来调节正负样本不平衡。我们在验证集上搜索了gamma和alpha的组合最终gamma2、alpha0.75效果最好。但这里有一个教训Focal Loss不能从头用到尾。训练早期模型还没学会基本语义把所有焦点放在难样本上会把大量噪声样本当成难样本去学习导致loss震荡收敛变慢。我们的做法是前2个epoch用BCE之后切到Focal Loss。如果你发现训练曲线在中期出现抖动可以试试这个策略。4.3 多折交叉验证与模型集成单模型容易过拟合尤其在比赛这种小数据集上。我们采用5折交叉验证每折训练一个模型预测时对5折的平均概率作为最终输出。集成策略有两个方向同模型不同seed稳定方差但提升有限。不同架构模型融合MacBERT、RoBERTa、NEZHA各训5折总共15个模型做融合提升明显。不同架构的模型学到的特征有互补性。比如MacBERT对句子级语义理解更好RoBERTa对长文本更稳健融合后能覆盖各自盲区。集成权重不是平均而是在验证集上用网格搜索确定最终是MacBERT权重0.4、RoBERTa权重0.35、NEZHA权重0.25。4.4 阈值搜索与后处理分标签阈值比全局0.5靠谱多标签分类默认用0.5作为阈值但0.5几乎不可能最优。每个标签的分布不一样有些标签模型输出普遍偏低有些偏高。正确的做法是每个标签单独搜索阈值。我们在验证集上对每个标签遍历0.3到0.7步长0.01选择让该标签F1最大的阈值best_threshold {} for label in labels: best_f1 0 best_thr 0.5 for thr in np.arange(0.3, 0.7, 0.01): pred (probs[:, label] thr).astype(int) f1 f1_score(y_true[:, label], pred) if f1 best_f1: best_f1 f1 best_thr thr best_threshold[label] best_thr这个操作在验证集上大概提升了0.01到0.02的F1属于性价比最高的后处理手段。之后还可以设置规则如果模型对标签A的概率很高但对标签B的概率略低于阈值而A和B在训练集中有强共现关系则把B也置为1。但这类规则要谨慎只选择共现频率极高且临床逻辑明确的pair。5. 踩过的坑与复盘如果你也做医学文本分类这些经验可以少走弯路5.1 本地验证集F1很高一上测试集就暴跌这个问题我们遇到过两次。第一次排查发现验证集和训练集来自同一家医院、同一时间段报告模板高度相似。模型实际上是在“背模板”而不是“理解语义”。测试集换了报告格式后模型直接失效。解决方案是调整数据划分方式。我们后来按检查时间做划分用较早的数据训练、较晚的数据验证保证验证集分布和训练集有一定差异。虽然验证集F1略微下降但线上分数稳定了。这也是一个通用经验比赛里的验证集划分不能只用随机划分要考虑数据的真实分布。5.2 为了推理速度截断到128线上掉分严重比赛后期为了加快推理速度有队友提议把输入长度从512截到128。本地验证集的F1只下降了0.003看起来人畜无害。但线上测试集里的报告明显更长截到128导致一部分诊断意见被切掉线上F1掉了0.02。这个教训很深刻输入长度的改动不能只看平均表现要关注极端样本。报告文本长度的长尾分布很严重有些报告可能超过1000个token截到128等于直接丢了结论。我们在最终方案里保留了512长度并加入了梯度累积来压缩显存占用而不是牺牲输入质量。5.3 伪标签到底要不要用比赛进入最后三天时我们发现测试集预测结果中置信度高的样本可以作为伪标签加入训练。具体做法是用当前模型预测测试集挑出概率大于0.95且小于0.05的样本分别作为正样本和负样本加入训练集再继续训练一轮。结果比较尴尬验证集F1提升约0.001线上没有任何提升。原因可能是测试集和训练集的分布差异较大伪标签的置信度高并不代表标签正确反而放大了一部分系统性误差。我的建议是除非你正处于排行榜前几名的冲刺阶段且准备充分否则伪标签在医疗文本任务上的性价比很低。5.4 关于medical domain模型的补充心得说到预训练模型的选型最后再补充一点如果比赛允许多次推理可以在测试时用TTATest-Time Augmentation对文本做轻微扰动例如同义替换、随机调整标点、大小写变化等取多次预测的平均。不过文本TTA的效果不如图像TTA稳定因为文本语义非常敏感。我在实验里试过对“未见异常”这类短语做同义替换效果反而下降所以最终没有使用。如果你要尝试记得先在验证集上确认收益不要盲目加。现在回头来看这个第三名方案的核心竞争力不在单个模型而在于每个细节的稳定积累数据清洗时保留否定词、截断策略保头保尾、损失函数选择、分标签阈值搜索、多模型融合。每项都谈不上惊艳但每一步都让验证集分数实打实向上走。医疗AI的落地比比赛更复杂语义判断只是第一步之后还有可解释性、错误分析、临床验证等大量工作。希望这篇复盘对你有用。本文还有配套的精品资源点击获取