大概两年前,某三甲医院病理科的一位带教老师拿到一套乳腺癌病理AI模型,兴致勃勃地在自己科室的日常切片上跑了一遍。结果很尴尬:同一批乳腺癌,换了个扫描仪、换了个染色批次,模型的假阳性率直接翻倍;而把结直肠癌的切片丢进去,输出基本等于瞎猜。这不是他操作有误,而是病理AI目前最普遍的病——单癌种模型根本扛不住真实世界的多样性。
要做病理AI的通用化,绕不开两个核心问题:让模型自己学会“先看清哪里像癌,再判断是什么癌”,以及把数据集里来自中心、染色、取材习惯的偏差拆掉。前者对应标题里的“癌症感知注意力”,后者对应“去偏”。这套方案我在某泛癌病理项目里完整跑过一遍,从patch采样、注意力监督到域对抗去偏,踩了不少坑,也拿到了一些实打实的效果。下面按实际做过的路径拆开讲,尽量把每步为什么这么做说清楚。
1. 单癌模型为什么换个癌种就崩:三类偏差逐一拆解
很多人以为模型跨不了癌种是因为“没见过的癌种不会认”,实际上问题更隐蔽。我把一个只见过乳腺癌切片的模型直接拿来测肺癌、结直肠癌和肾癌,崩溃原因可以拆成三层,一层比一层难处理。
1.1 染色风格不是小事,它是模型真正的“捷径”
病理切片的HE染色在不同中心、不同批次之间差异非常大。有的中心苏木精偏重,细胞核发蓝发紫;有的中心伊红偏重,间质红得刺眼。单癌种模型训练时,如果数据基本来自同一家中心,模型很容易直接学成“颜色分类器”——见到某种颜色分布就说是癌,根本没在认真看细胞形态。
我做过一个对照实验:把同一批乳腺癌切片的颜色分布统计出来,故意反向拉升对比度再测试,单癌种模型的AUC掉了将近0.15。这个数字说明模型对染色风格的依赖相当重。更麻烦的是,染色差异是逐片存在的,没法用一张片子代表一个中心,必须靠基本不再依赖绝对色值的特征表达,或者配合组织学层面的归一化策略。
1.2 组织学形态先验:模型记住了乳腺癌的“套路”,而不是“癌”
乳腺癌的形态学特点很明显,比如导管原位癌的筛状结构、浸润癌的巢状排列,模型学这些“套路”很容易。但通用化要求模型理解的是“异型性”这个底层概念,而不是某个癌种的特征组合。一旦到了肾透明细胞癌——胞浆透明、排列成片,或者到了黏液腺癌——大量黏液湖里漂浮着少量癌细胞团,模型之前学到的形态先验完全不适用。
我当时做了一项简单的特征可视化:用CAM(类别激活图)去看单癌模型在甲状腺癌切片上的激活区域,结果模型对大片正常滤泡结构给出了高激活,因为滤泡的空泡样外观和乳腺癌某些腺管结构有表面相似性。这说明模型没有学会“什么是不典型”,只学会了“乳腺癌长什么样”。这种先验偏差靠堆数据也不一定解决,因为病理图像的类别形态方差本来就大,单癌模型找不到足够的跨癌共性特征。
1.3 patch采样策略把模型推向“背景识别器”
第三个偏差来自训练方式本身。大多数病理AI模型是在patch级别训练的,一整张40倍全切片扫描往往要裁出几万个256×256的patch,但其中真正包含肿瘤的区域可能只有一小部分。常规做法是随机采样,结果就是模型看到的大部分patch都是正常组织、脂肪、坏死或空白区域。
单癌场景下模型还能硬背下来,因为背景组织相对统一;到泛癌场景,不同器官的正常组织结构差异极大——肺的肺泡、肾的小管、肠的腺体——随机采样等于让模型在一个严重不均衡的分布里学“什么是异常”。我有一个很直观的观察:模型对纯背景patch的误报占了总假阳性的60%以上。所以通用化的第一步不是换更大的模型,而是得让模型先学会“把目光停在可疑区域”。
2. 癌症感知注意力:让模型先把目光停在“像癌的地方”
既然patch级别分类容易迷失在背景里,一个自然的想法是给模型加注意力机制。但直接把Transformer那套标准自注意力搬过来是不够的,我在实验里看到了一个很典型的现象:在几千个patch组成的序列里,标准注意力头倾向于平均分配权重,因为背景patch太像了,模型分不出谁更重要。
2.1 普通注意力在万级patch里被背景淹没
一张标准的WSI切成patch后,即使只保留20倍视野下组织覆盖率较高的块,也经常还剩几千甚至上万个patch。标准Transformer的注意力在这么多patch上会发散,尤其是初始化阶段,Q和K的分布还没学起来,注意力分数几乎均匀。于是模型本质上是在所有patch的Embedding上做一个大平均池化,和CNN的全局平均池化没什么实质区别,注意力机制形同虚设。
我试过把patch数量硬压到256个,注意力能稍微集中一点,但代价是漏掉了很多散布在边缘的小病灶。后来加了基于组织密度的采样,也只能保证采样点落在组织上,不能保证落在“有意义的组织”上。问题的根源在于:注意力需要一个先验目标,不能完全靠分类损失反向传播自己摸索。
2.2 用辅助监督把注意力拉到肿瘤区域
解决思路是给注意力一个“癌症感知”的信号,让模型在训练时就知道哪些patch更像肿瘤。具体做法是加一个辅助监督头:对于有patch级标签的训练数据,把每个patch是否含有肿瘤作为二分类目标;没有patch级标签时,用弱监督方式从WSI级标签生成伪标记——用初始模型跑一遍,取激活值最高的top-k patch作为正样本,激活值最低的负样本作为背景。
我用的注意力监督损失写起来不复杂:
import torch import torch.nn.functional as F def attention_supervision_loss(attn_weights, patch_labels, temperature=2.0): """ attn_weights: (B, num_heads, num_patches),已经过 softmax patch_labels: (B, num_patches),1 表示该 patch 含肿瘤,0 表示背景 """ # 注意力权重先用温度做平滑,避免某些头过于尖锐 log_attn = torch.log(attn_weights + 1e-6) / temperature # 只对有效标签位置计算交叉熵,无标签位置掩码掉 mask = (patch_labels >= 0).float() loss = -(patch_labels * log_attn).sum(-1) / (mask.sum(-1) + 1e-6) return loss.mean()这个辅助损失的作用很直接:强制让注意力权重在“含肿瘤patch”上更高。但它不能单独用,必须和主分类损失共同优化。我实验里两种损失的配比是1:0.3,辅助损失系数太强的话,模型会过度关注局部细节,忽略整体组织学结构,反而不利于判断癌种。
2.3 工程实现:一个可插拔的癌症感知池化头
实现层面,我没有重新搭一个完整的分支网络,而是把癌症感知注意力做成一个可插拔的池化模块,接在patch编码器之后。流程是:patch编码器把每个patch变成向量,然后进入Transformer层交互上下文信息,最后不是取所有token的平均,而是用癌症感知注意力权重做加权求和。
下面是一个简化版本的伪代码结构,实际项目里我把数据并行、梯度累积、EMA都加进去了,但核心逻辑不变:
class CancerAwareAttentionPooling(nn.Module): def __init__(self, embed_dim, num_heads=8): super().__init__() self.num_heads = num_heads self.attn = nn.MultiheadAttention(embed_dim, num_heads, batch_first=True) self.linear = nn.Linear(embed_dim, 1) def forward(self, x, return_attn=False): # x: (B, N, D) N为patch数量 attn_out, attn_weights = self.attn(x, x, x) # attn_weights: (B, N, N),取每行均值得到每个patch的全局重要性 patch_importance = attn_weights.mean(dim=1) # (B, N) # 辅助监督在训练时接patch_importance attn_scores = self.linear(attn_out).squeeze(-1) # (B, N) # 癌症感知加权求和 scores = F.softmax(attn_scores * patch_importance, dim=-1) pooled = (x * scores.unsqueeze(-1)).sum(dim=1) return pooled, scores这里有个细节容易被忽略:为什么要让attn_scores再乘以全局patch_importance,而不是直接用注意力权重?原因是Transformer的注意力矩阵反应的是patch之间的两两关系,有些patch可能“被别的patch关注”,但不代表它本身更像肿瘤。乘上一个自注意力均值得分,相当于把“上下文重要性”和“自身显著性”结合起来,我在实验里这一乘让正样本patch的权重集中度提高了近一倍,定性热图也明显更聚焦在癌细胞富集区域。
3. 去偏不是染色归一化:拆中心捷径与癌种捷径的两层设计
注意力模块解决的是“模型该看哪里”,但模型看对了地方不等于判断标准就是通用的。它可能还是在用某个中心的染色风格、某个扫描仪的噪声纹理、某个癌种独有的组织排列方式做判断。这部分偏差必须单独处理,也就是去偏。
3.1 染色归一化为什么治标不治本
很多团队提到通用化,第一反应是上染色归一化,比如经典的Macenko方法,把每个patch的颜色分布映射到一个参考分布上。我早期也这么干,但很快发现两个问题。第一,归一化只在颜色空间做变换,解决不了扫描仪特有的噪声和伪影,比如某些设备在20倍下有规律的点状噪点,模型照样能靠这些纹理完成分类,泛化照样崩。第二,也是更要命的,染色归一化会把一些有诊断意义的色差抹掉,比如黏液、坏死区域的颜色变化在归一化后变得不敏感,模型容易漏掉真实阳性。
我的结论是:染色归一化可以作为预处理的辅助手段,但不能替代模型层面的去偏。真正的去偏要让模型在特征空间里学不到“来自哪个中心”的信息,而不是把输入像素强行对齐。
3.2 域对抗分支:让特征里“看不见”来自哪个中心
去偏方案我采用的是经典的对抗学习思路:在主干特征之上并行接一个域判别分支,目标是判断当前patch来自哪个中心(或者哪个数据集来源),同时通过梯度反转层让主干特征向“域不可分”的方向优化。训练时主分支努力做对癌种分类,域判别分支努力做对中心分类,两边对抗,最后主干特征就只保留癌种判别需要的形态信息,而丢掉中心特有的风格信息。
核心公式是一个带权重的对抗损失:
L_total = L_cls + λ_attn * L_attn - λ_domain * L_domain这里的L_domain前面是减号,因为要通过梯度反转让主干向着“让域判别器更难判断”的方向更新。实现上两种方式:一种是手动反转梯度,更稳定;另一种是直接用官方实现里的GradientReversal层。我更喜欢手动反传梯度,这样能灵活控制不同层是否受到域对抗影响。
经验数值是:λ_domain从0.1起步,不要一上来就给到0.5以上。否则主干特征被域对抗压制得太厉害,正常的形态学特征也被连带删掉,癌种分类精度反而下降。这个度在第三节实验里会详细说,我不止一次翻过车。
3.3 去偏强度是一个旋钮,而不是开关
做域对抗去偏最容易犯的错误是把“去偏”理解成“删掉所有与中心相关的信息”。实际上,有些中心特征和癌种特征天然纠缠在一起,比如某个中心收治的大多是进展期病例,肿瘤细胞密度普遍偏高,主干特征里“细胞密度高”既能帮它判断癌种,也能帮域判别器猜中来源。你去偏去得越狠,这部分有用的形态信号就丢得越多。
我后来采用的策略是“分层去偏”:浅层特征(纹理、边缘、颜色)承担主要去偏压力,因为中心风格主要影响浅层;深层特征(语义结构、组织学排列)基本不去偏,因为这些信息更接近癌种判别的本质。实现上只需要给不同层设置不同的梯度反转系数。这个细节在公开的通用方案里很少被提到,但我觉得它才是去偏真正起作用的关键。浅层去偏系数设0.5,深层只设0.05,整体效果比统一0.3好很多。
4. 把两部分接进同一个模型:架构、损失与训练节奏
注意力与去偏不是两个孤立的模块,需要在一个统一的框架里协同工作。我在设计整体架构时,目标是“不过度改动主流分类框架,同时让各部分可独立验证”。下面给出一个可以直接参考的整合方案。
4.1 整体结构与前向流程
模型主干可以复用现有医学图像预训练模型作为patch编码器,也可以从自然图像预训练权重热启动,但强烈建议用一批病理图像做领域自适应的继续预训练,哪怕无标签数据也行。接着把编码器输出送入4层左右的Transformer做上下文建模,再接癌症感知注意力池化得到整图向量,最后分流到两个分支:癌种分类头(多分类)和域判别头(多中心二分类或多分类)。
整个前向流程大概是这样的:
- 对WSI做组织检测,滤掉空白区域,保留组织patch。
- 将patch编码为向量序列,加入位置编码。
- Transformer编码阶段:让patch之间交换上下文信息,这是判断“局部像癌”到“全局像哪种癌”的关键。
- 癌症感知注意力池化:得到整图级别表征和每个patch的重要性分数。
- 分类头输出癌种概率,域判别头输出中心/数据集来源概率,两个损失相加回传。
这个结构的优势是每个组件都可以单独做消融。想单独看注意力模块的效果,就把域对抗分支关掉;想单独看去偏效果,就把注意力监督损失撤销,只用平均池化。我在项目里做了一整套消融,这是后面说指标时的依据。
4.2 损失函数与权重设置
三个损失合在一起的配置我给出一个可复用的起点值:
| 损失项 | 权重 | 说明 |
|---|---|---|
| 主分类交叉熵(癌种) | 1.0 | 多分类,用真实癌种标签 |
| 注意力监督损失 | 0.3 | 引导注意力集中于肿瘤区域 |
| 域对抗损失 | 0.1~0.3 | 抑制中心相关特征 |
训练时我会做一个小技巧:主分类损失采用标签平滑,因为泛癌数据集中不同癌种的形态相似度很高,有些病例连病理医生之间都会争议,硬标签容易让模型过置信。标签平滑系数0.05就够,贪心设到0.1会让整体AUC轻微下降。
4.3 训练节奏:先对齐注意力,再联合去偏
一开始我直接端到端同时训练三个分支,效果很不稳定,注意力热图也乱跳。后来把训练拆成两阶段,稳定了很多。
第一阶段只训练patch编码器、Transformer和分类头,注意力监督损失保留,但域对抗分支不做梯度反转,相当于只是让域判别头在正常梯度下学会预测中心来源——这一步是“收集信息”,为第二阶段做底数。我跑了大概15个epoch,让主分类AUC先到达一个合理水平(比如验证集癌症分类F1到0.8左右)。
第二阶段再打开梯度反转,让域对抗真正生效。学习率降到第一阶段的十分之一,只调浅层去偏系数和λ_domain。这个阶段不用太长,5到8个epoch就能看到域判别准确率开始下降,说明特征空间里的中心风格信息确实在被移除。
还有一个很实用的训练技巧:每个epoch结束时对每个中心各自计算AUC,而不是把多个中心的数据混在一起算一个总体指标。混着算很容易被数据量大的中心拉高,让人误以为整体变好了。我后来只看最差的那个中心的AUC,用它作为去偏是否有效的核心指标——最差中心涨,才是真的泛化。
5. 内部验证:跨癌种、跨中心、跨染色三个维度的实测记录
这部分不打算列一堆听起来很漂亮的平均值,而是把实际跑出来的结果和翻车过程写下来。整个测试都是在某项目的内部验证集上做的,配置是256×256的patch、20倍等效放大、每张WSI采样2048个patch、4层Transformer、嵌入维度512。
5.1 实验配置与评测方式
数据集来自多家医疗中心,覆盖乳腺癌、结直肠癌、肺腺癌、肾透明细胞癌和甲状腺癌五种癌种,分三个维度评测:
- 跨癌种:训练时留出一个癌种完全不参与训练,测试模型对“没见过的癌种”的识别能力。
- 跨中心:每个癌种至少有两个数据来源,训练时全部来自A中心,测试只用B中心的片子。
- 跨染色批次:同一中心不同时期染色的切片,模拟日常病理科的真实波动。
每个维度都对比三个模型:单癌模型(只有分类头)、加入注意力监督的模型、加入注意力与去偏的完整模型。评价指标用AUC、F1和“最差中心AUC”。
5.2 指标改善:注意力与去偏各自贡献了多少
先说结论,完整模型在各维度上都有提升,但贡献来源不同,拆开看才清楚。
单癌模型在已见癌种上的AUC大约是0.87,但到留出癌种上直接跌到0.72左右。加入注意力监督后,已见癌种AUC只微涨到0.88,留出癌种却涨到0.79。原因很清楚:注意力让模型先聚焦肿瘤区域,不把正常组织结构当成判断依据,这个能力是跨癌种通用的。
加入域对抗去偏后,最明显的收益出现在跨中心和跨染色批次上。跨中心AUC从0.81涨到0.86,最差中心AUC从0.74涨到0.82,而跨染色批次的表现也稳定了不少。域对抗本质上牺牲了一小部分“已见中心”上的精度,换来了“不熟悉中心”上的稳定,这个取舍在通用化方向上是值得的,尤其当目标场景是外部落地时。
完整模型与单癌模型相比,留出癌种AUC从0.72提升到0.83,跨中心最差AUC从0.74提升到0.82。这个幅度谈不上惊天动地,但在一个不调用外部大规模数据的普通项目里,算是相当可用的进步。
5.3 翻车记录:去偏过头、稀疏patch、黏液癌漏检
指标是好的,但过程中翻的车很有代表性。
第一个翻车是域对抗强度设置过高。有一轮实验我把λ_domain调到0.5,结果所有癌种AUC都掉,尤其是肾透明细胞癌下降最明显。翻看热力图发现,模型不再关注透明胞浆和核周异型性,因为那些特征在源中心里太常见,域对抗把它当成中心特征给压制了。这个教训让我明白去偏必须分“浅层”和“深层”,并且只能像拧水龙头一样慢慢加。
第二个翻车是注意力在稀疏patch上失效。有些黏液癌的肿瘤细胞散布在黏液湖中,占比极低,组织密度也不高,patch级注意力分数普遍被拉到正常黏膜区域。后来在采样阶段按组织密度分层,保证一定比例的稀疏区域patch进入训练,并把这部分patch在注意力监督损失里的权重提高了一些,漏检才算压下来。
第三个翻车更隐蔽:域对抗中心标签过粗。某个中心内部其实有两台不同型号的扫描仪,噪声特征完全不一样,我一开始只按中心名打标签,域判别头学不到真正的域差异,去偏效果自然打折。后来把“中心+扫描仪型号”作为域标签,才把精细的设备伪影也压住。
这几个翻车都变成了后续实验的固定检查项,现在新跑一个数据集,我会先看三样东西:域判别准确率有没有下降、最差中心AUC有没有涨、注意力热图有没有从“边缘区域”转移到“肿瘤核心区”。三样都通过,才敢说这个方案的通用化效果是真的。
最后再分享一个我自己后期常用的落地技巧:如果外部数据收集不齐,不要一上来就追求端到端多中心训练。先用公开来源预训练一个“泛癌底座”(就是本文这个双模块结构),到具体中心只做轻量微调,锁住注意力监督和域对抗分支,只放开分类头。这样对外部数据量的要求低很多,效果也比从头训单癌模型稳得多。病理AI通用化从来不是某个单独模块的胜利,而是注意力机制、去偏策略和数据采样三者拧在一起互相较劲的结果,知道哪里该较劲、哪里该放手,比背一个漂亮的网络结构重要得多。