☰
乳腺癌病理图像自动分类:从patch到WSI的深度学习落地指南
2026/10/5 9:08:21 网站建设 项目流程

简介:PDF资料《基于深度学习的乳腺癌病理图像自动分类》定位清晰,面向医学影像处理、计算机辅助诊断与深度学习应用方向的科研人员、临床工程师和学生。乳腺癌是威胁女性健康的常见恶性肿瘤,病理图像是临床确诊和制定治疗方案的重要依据,但传统人工分类耗时费力、依赖专业知识,且难以提取高质量特征。论文提出改进的深度卷积神经网络模型,结合数据增强与迁移学习解决小样本过拟合问题,在乳腺癌病理图像分类任务上识别率达到91%,正文涵盖数据收集与预处理、模型构建与训练、评估指标等完整技术路线。资源为单份PDF文件,共1.56MB,系《计算机工程与应用》2018年第54卷第12期正式论文,包含中英文摘要、关键词、方法阐述、实验结果与参考文献,结构完整便于查阅。目前已有248人学习下载,适合希望快速掌握深度学习病理图像自动分类研究思路与实践要点的读者。

1. 基于深度学习的乳腺癌病理图像自动分类:为什么不能直接套自然图像的CNN

一张乳腺癌病理切片(WSI)导出后常常是十万像素级别的巨图,切片上既有复杂的组织结构,又叠加了染色深浅、扫描仪型号和切片厚度的干扰。如果按普通图像分类的习惯把整张图缩放到224×224喂进CNN,病灶细节早就丢失;如果直接全分辨率逐像素推理,计算量又没有上限。深度学习自动分类真正要解决的问题,是让模型在上千万像素中找到那几百个关键的肿瘤细胞团,并给出可复核的类别判断。这篇围绕病理图像自动分类整理一条从数据切patch、模型训练到指标验证的落地路径,适合手里有WSI数据、想尽快跑通并交付的工程师和医工交叉方向的学生。

2. 病理图像自动分类的数据准备:WSI切patch、组织掩膜与类别均衡

2.1 公开数据集与标签体系:从Camelyon16到PatchCamelyon

病理图像项目的第一步不是选模型,而是把“标签颗粒度”定下来。公开数据里最常用的是Camelyon16和PatchCamelyon:前者给的是WSI级别标签,一张几十万像素的切片只有一个“有无癌转移”的二分类标记;后者把WSI切成256×256的patch,每个patch自带0/1标签。这两种标签对应完全不同的训练目标,前者适合做整张切片自动分类,后者适合先验证patch级特征能否区分细胞形态。做乳腺癌病理图像自动分类时,我建议先用PatchCamelyon这类patch级数据把流程跑通,再迁移到院内slide级数据上做MIL;否则从第一天就在弱标签上调试,问题会和模型混淆在一起。

标签体系还会影响后续的人工复核成本。如果只做二分类,模型输出“有癌/无癌”并不够,病理医生需要知道可疑区域在哪;如果做多分类,比如区分导管原位癌、浸润癌和良性病变,那么每一类的样本量和典型形态都要提前审查。拿到一批切片先抽50张缩略图,找有经验的标注者确认标签一致率,而不是直接把文件名当标签,这一道工序省掉后面大量返工。

2.2 用OpenSlide把WSI切成256×256的patch:坐标、级别与组织掩膜

WSI文件格式有svs、ndpi、tif,OpenSlide是读这类格式最稳的库。切patch前必须先定位组织区域,否则一张切片三分之二是空白背景,把这些样本送进模型,模型会学到“背景规律”而不是“肿瘤形态”。我的做法是先用低倍率(level 2或3)读一张缩略图,做OTSU阈值拿到组织掩膜,再回到level 0坐标系下按patch_size切块。

import openslide import cv2 import numpy as np import csv def build_tissue_mask(slide, level=2): # read_region接收的是level 0坐标系,先读低倍图用于掩膜计算 factor = slide.level_downsamples[level] region = slide.read_region((0, 0), level, slide.level_dimensions[level]) img = np.array(region)[:, :, :3] gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) # 背景偏白,组织偏紫/粉,OTSU在H&E图上有稳定的二值效果 # 用BINARY_INV让组织区域为255、背景为0 _, mask = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 返回mask仍是低倍率坐标,调用时需乘factor映射回level 0 return mask, factor def extract_patches(svs_path, out_csv, patch_size=256, stride=None): slide = openslide.OpenSlide(svs_path) mask, factor = build_tissue_mask(slide, level=2) if stride is None: stride = patch_size // 2 # 50%重叠,防止病灶正好卡在patch边界 rows = [] # 在level0坐标系按stride滑动 for y in range(0, slide.dimensions[1] - patch_size, stride): for x in range(0, slide.dimensions[0] - patch_size, stride): # 用patch中心点落在掩膜的哪一格来判断是否保留 cy, cx = int((y + patch_size / 2) // factor), int((x + patch_size / 2) // factor) if mask[cy, cx] > 0: rows.append([svs_path, x, y, 0]) # label先用0占位,后续按标注表回填 with open(out_csv, "w", newline="") as f: writer = csv.writer(f) writer.writerow(["slide", "x", "y", "label"]) writer.writerows(rows) print(f"提取patch数: {len(rows)}")

代码逻辑说明:read_region的坐标原点在level 0层面,掩膜在低倍率下计算,因此把patch中心坐标除以downsample倍数,就能判断这个patch是否落在有效组织上。patch_size取256是为了兼顾ResNet输入和细胞团尺度;stride默认取patch_size的一半,代价是训练样本翻了近两倍,但能避免小灶肿瘤恰好被切在边缘而丢失。如果显存紧张,可以把stride改成patch_size,重叠降到0,漏检风险会升高。掩膜闭运算也很重要,脂肪和坏死区域会让OTSU产生空洞,闭运算能把狭长的间隙填上。这里生成的label只是占位,必须拿到病理标注表后按slide_id join,不能把CSV里这列直接当训练标签。

2.3 数据增强与类别不均衡:翻转、旋转、Focal Loss与加权采样

训练增强有一套和自然图像不一样的规矩。组织学方向没有语义上的“上下颠倒”,所以90度的整数倍旋转和翻转都安全;但任意角度的旋转会导致patch中出现剪切插值伪影,我一般不用。颜色增强更要克制,H&E染色有既定标准,过度的色调偏移会让模型把染色波动当成类别信号。

import torchvision.transforms as T train_transform = T.Compose([ T.RandomHorizontalFlip(p=0.5), T.RandomVerticalFlip(p=0.5), T.RandomRotation(90), # 只做90度整数倍旋转,避免插值伪影 T.ColorJitter(brightness=0.15, contrast=0.15, saturation=0.0, hue=0.0), T.ToTensor(), # 病理图像通道均值与自然图不同,统一减半归一化即可 T.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]) ])

参数说明:ColorJitter里把saturation设0,是因为H&E切片只有粉红和蓝紫两类染色,饱和度扰动会引入不存在的色彩变化;brightness和contrast各给0.15,这是为了模拟不同切片厚度的明暗差异。类别不均衡方面,如果直接用CrossEntropyLoss,负样本占比常超过80%,模型会倾向把所有patch预测为阴性。常见做法是加WeightedRandomSampler,或者直接用Focal Loss。我更推荐后者,因为它同时处理了“数量少”和“难分类”两个问题:恶性肿瘤小灶通常就是难样本。

import torch class FocalLoss(torch.nn.Module): def __init__(self, gamma=2.0, alpha=None): super().__init__() self.gamma = gamma self.alpha = alpha # 传一个shape=(num_classes,)的Tensor做类别权重 def forward(self, logits, target): # 先算标准交叉熵,再乘(1 - pt)^gamma,让难样本梯度占主导 ce = torch.nn.functional.cross_entropy(logits, target, reduction="none") pt = torch.exp(-ce) loss = (1 - pt) ** self.gamma * ce if self.alpha is not None: alpha_t = self.alpha.gather(0, target) loss = alpha_t * loss return loss.mean()

这里gamma=2.0是Focal Loss论文里的默认值。对已经分对的样本,pt接近1,权重(1-pt)^gamma趋近0;对分错的样本,权重接近1,训练重心自然落在那些不典型的阳性patch上。alpha可以先不设,靠gamma调节;如果发现阳性样本召回率仍然低,再按标签频率给alpha加权。

3. 模型选型与训练配置:ResNet基准、注意力聚合与关键超参数

3.1 基线模型:为什么先选ResNet50而不是直接上Vision Transformer

很多人一看到深度学习就要直接上Vision Transformer或当前刷榜的模型,病理图像分类我倒建议先跑通ResNet50。原因不是ViT不好,而是病理patch通常只有几千到几万张,ViT在这种规模下很容易过拟合;ResNet50有ImageNet预训练权重,有成熟的BN和部署生态,显存占用还低。另一个关键因素不是模型本身,而是输入分辨率:病理图像自动分类对分辨率的敏感度远高于自然图像,224×224可能丢失细胞级细节,256×256是显存和精度的实际平衡点。加载时只需要改分类头:

import torchvision.models as models model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) # 把1000类分类头换成2类 model.fc = torch.nn.Linear(model.fc.in_features, 2)

逻辑说明:病理图像与ImageNet自然图像存在域差异,预训练权重不能完全信任,但它提供的低频边缘、纹理基元依然有效。我一般先冻结前两个stage训练3到5个epoch,让分类头稳定下来,再解冻全部层低学习率微调;如果一开始就全量微调,loss会先降后升,典型灾难性遗忘。如果做多分类,把最后的2改成实际类别数即可。EfficientNet在这种低数据场景下并不比ResNet50有优势,反而因为显存和部署生态问题多一层成本,所以基线选ResNet50最省心。

3.2 从patch到WSI:多实例学习与注意力聚合的实现

如果手里的标签是“整张切片有没有癌”,patch级分类器就不能直接用了。最可靠的做法是多实例学习(MIL):把一张WSI看成多个patch组成的bag,bag的标签等于整张切片标签,bag内每个patch不单独标注。MIL里最关键的是聚合方式,取平均会把大量阴性patch的噪声带进来;常见做法是让模型学一组注意力权重,把判别性最强的patch特征选出来。工程上通常先把每张WSI的patch用ResNet提成特征缓存下来,再单独训练聚合模块,这样能省掉重复的前向计算。

class AttentionMIL(torch.nn.Module): def __init__(self, feature_dim=2048, hidden_dim=256): super().__init__() # 先映射到隐层做非线性,再接单值打分 self.attn = torch.nn.Sequential( torch.nn.Linear(feature_dim, hidden_dim), torch.nn.Tanh(), torch.nn.Linear(hidden_dim, 1) ) def forward(self, features): # features: (num_patches, feature_dim),同一WSI的所有patch特征 scores = self.attn(features).squeeze(-1) # (num_patches,) weights = torch.softmax(scores, dim=0) # 归一化为概率分布 bag_feat = (weights.unsqueeze(-1) * features).sum(dim=0) # 加权求和 return bag_feat, weights

attention输出的weights就是后面做热力图可视化的原始分数。训练时一个batch可以同时放16个bag,每个bag随机抽20到64个patch;如果一张WSI的patch数量实在太多,不要全塞进显存,按bag随机采样即可。每次采样到的patch成员稍有不同,相当于给MIL加了一个正则化。标签反传时只有bag级标签参与损失计算,不要求每个patch都有标注,这是MIL和patch分类器最本质的区别。

3.3 训练参数:学习率、warmup、batch size与混合精度的选择

训练配置没有一个万能公式,但有一组我反复用过的起点参数,整理成表方便直接抄:

参数Patch分类器WSI级MIL备注
输入尺寸256×256256×256细胞团尺度决定,不轻易缩小
batch size3216个bag显存不足时先降batch
优化器AdamWAdamWweight decay直接复用
学习率3e-41e-4迁移学习不建议超过5e-4
warmup3个epoch5个epoch预训练权重需要缓冲
总epoch5030以验证AUC为准,不看固定epoch
混合精度开启建议开启显存减半,注意NaN回退

学习率是里面最玄但影响最大的参数。病理图像特征方差大,lr超过1e-3很容易在5个epoch内发散,一旦发散再调回来很难,因为BN统计已经被污染。warmup阶段如果train loss在2个epoch内没有明显下降,先把lr降到1e-4重来。混合精度方面,PyTorch的GradScaler能省不少显存:

from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for x, y in dataloader: x = x.cuda() with autocast(): logits = model(x) loss = F.cross_entropy(logits, y) scaler.scale(loss).backward() # 防止fp16梯度下溢成NaN的必要操作 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) scaler.step(optimizer) scaler.update() optimizer.zero_grad()

clip_grad_norm_在病理图像任务里不是可有可无。有些patch本身对比度极低,模型可能出现单步大梯度,不裁剪时loss会跳到NaN;max_norm=5.0是常见的保守值。如果开启AMP后loss频繁不稳定,直接关闭AMP,或把主干里的BN换成GroupNorm来稳定训练。

4. 验证与评估:混淆矩阵、患者级分组与概率校准

4.1 在训练循环里保留验证集:为什么只看train_acc会误判

病理图像任务的训练精度极容易“虚高”,因为相邻patch的相似度太高。如果随机把patch放进训练集和验证集,同一个病灶区域的几百个patch大概率同时出现在两边,模型相当于开卷考试,验证AUC会虚高0.05到0.15。正确做法是让同一患者的切片只出现在一个fold里,用患者ID作为分组键:

from sklearn.model_selection import GroupKFold # df至少要有:patch_id, slide_id, patient_id, label groups = df["patient_id"].values gkf = GroupKFold(n_splits=5) for train_idx, val_idx in gkf.split(df, df["label"], groups=groups): train_df = df.iloc[train_idx] val_df = df.iloc[val_idx]

这里的分组逻辑是:GroupKFold保证train_idx里不会出现某个patient_id的样本,验证集也不会出现该患者的样本。如果数据来自多个扫描仪,还可以把扫描仪ID作为第二层分组键,确保模型不是靠设备纹理认切片。做WSI级MIL时还要多注意一层:同一个slide的所有patch必须全在同一折内,不能在bag内部混入训练集的patch。

训练过程中要盯着三个信号:train loss、valid loss和AUC。train loss快速下降而valid loss开始反弹,说明模型开始死记背景纹理;valid AUC在前10个epoch一直低于0.75,先不要急着加模型复杂度,回头查数据泄漏和掩膜质量。病理图像里数据问题导致的指标虚高,远比模型容量不足常见。

4.2 混淆矩阵与ROC:patch级和患者级两个口径

评估指标不能只看Accuracy。乳腺癌病理图像里阴性patch往往占80%以上,一个把所有patch都识别为阴性的模型也能有80%准确率,但这在临床上毫无价值。我一般至少同时报告AUC、灵敏度和特异度,其中灵敏度优先级最高,因为漏掉一个癌灶的代价远高于多标一个可疑区域。

from sklearn.metrics import confusion_matrix, roc_auc_score # y_prob是模型输出的阳性概率,y_true是二分类标签 cm = confusion_matrix(y_true, y_pred) tn, fp, fn, tp = cm.ravel() sensitivity = tp / (tp + fn) specificity = tn / (tn + fp) auc = roc_auc_score(y_true, y_prob) print(f"AUC={auc:.4f} Sens={sensitivity:.4f} Spec={specificity:.4f}")

如果是WSI级任务,需要把评价口径分成两层:patch级AUC用于评价特征提取器,slide级AUC用于评价最终预测。slide级预测的常规聚合有两种:一种是对bag内patch概率取平均或最大值,另一种是第3章的MIL输出。平均值简单但容易被大量阴性patch稀释,最大值则容易被单个假阳patch带偏;MIL的好处是权重可解释,也是我现在默认采用的方案。

4.3 概率校准与阈值选择:给病理医生的不是二分类标签

模型输出的softmax概率不等于真实概率。训练数据里阳性比例是人工平衡过的,而真实科室切片中阴性占多数,这导致模型输出的0.6在真实分布里可能对应更高的风险。给医生使用前要做温度校准,让0.5这个阈值有明确意义:

import torch.nn.functional as F # 用验证集logits优化一个温度T logits_val = torch.cat(val_logits).cuda() labels_val = torch.cat(val_labels).cuda() T = torch.nn.Parameter(torch.tensor(1.5)) def cali_loss(): optimizer.zero_grad() return F.cross_entropy(logits_val / T, labels_val) optimizer = torch.optim.LBFGS([T], lr=0.01, max_iter=100) optimizer.step(cali_loss) # 单变量优化,一次closure即可 # 推理时用校准后的概率 probs = torch.softmax(logits_val / T.detach(), dim=-1)

温度校准只调整一个标量,不能用训练集来学T,否则过拟合;要用验证集。校准之后再画reliability diagram,横轴预测概率、纵轴真实频率,如果曲线离对角线远,说明模型过度自信或信心不足。实际交付时最终阈值也不该固定在0.5。如果漏诊代价高,把阈值降到灵敏度约95%的位置;更稳妥的做法是给医生一个可调的阈值滑块,旁边实时显示F1和假阳性率,让临床按自己的风险偏好选点。

5. WSI自动分类的避坑指南:五个容易翻车的地方

5.1 换了扫描仪和染色条件就崩:颜色域漂移

现象:院内切片训练的patch分类器,验证AUC能达到0.94,换到另一台扫描仪或另一家医院的切片上,AUC直接掉到0.7以下。

原因:H&E切片的染色深浅、扫描仪白平衡在不同机构间差异明显。模型很容易学到“像素颜色均值”这类快捷特征,而不是细胞核形态;病理图像的临床数据天然来自多个中心,颜色域漂移是真实部署里常见的问题。

解决:训练前对所有训练和测试切片做染色标准化,Macenko或Reinhard方法都行。Macenko的核心是从每个patch估计染色浓度矩阵,再映射到参考切片的染色空间。代价是每个patch多一次矩阵运算;如果推理速度敏感,可以把归一化集成到数据读取管线里,而不是落盘成新图。

5.2 患者数据泄漏:patch随机划分让AUC虚高

现象:按patch随机划分训练集和验证集,交叉验证AUC 0.98;改成按患者分组划分后变成0.85,说明前面那个数是假的。

原因:同一张切片的相邻patch有大量重叠区域,随机划分会把几乎相同的patch分进训练和验证,模型相当于默写原图。

解决:用4.1节的GroupKFold,group设为patient_id;如果数据里没有患者ID,至少按slide_id分。还有一层容易被忽略:不要对同一张slide做重复采样后把两份都放进训练验证,这种“双胞胎patch”也是一种泄漏。

5.3 背景区域混进训练集:模型学的是玻璃反光

现象:训练loss正常,但用注意力热力图检查时,模型高度关注的区域是空白背景或玻璃边缘,而不是细胞团。

原因:第2章的组织掩膜只判定了patch中心是否落在组织内,没判定patch内部组织占比。一个256×256的patch,如果只有左上角一小块组织,其余都是背景,模型仍然会学到背景纹理。

解决:切patch时增加组织占比判断。把mask对齐到level0,统计该patch范围内非零像素比例,低于0.7的patch直接丢弃。还要对mask做一次开运算滤掉孤立小点;如果发现训练集里混入大量低组织占比样本,用这个比例做数据清洗,比调模型参数见效快得多。

5.4 小batch加大学习率:训练曲线震荡不收敛

现象:train loss在前几个epoch反复横跳,偶尔出现NaN,验证loss不但不降还在升。

原因:病理patch特征方差本来就大,batch size如果降到8以下,BN层的均值和方差估计噪声极大;加上学习率从1e-3起步,很容易一步跨过最优区域。还有一种情况是AMP开启后fp16在低对比度patch上下溢,也表现为NaN。

解决:把batch提到16以上,或者用GroupNorm替换全部BN层;学习率从1e-4起步,开3到5个epoch的warmup;给优化器加梯度裁剪。显存不够又想用大batch时,走梯度累积,而不是粗暴减小batch。

5.5 只做patch级分类没有聚合:交不出一个可复核的结论

现象:模型对单张patch分类很准,但整张slide的预测结果让病理医生无法采用,因为没有指出“哪里可疑”。

原因:医生复核需要的不只是阳性判断,而是病灶区域。patch级分类只能输出了一个滑窗得分图,不等于slide级诊断;如果只取全部patch的多数票,小灶癌很容易被大量阴性patch淹没。

解决:把MIL的attention权重或CAM分数映射回WSI坐标,生成热力图,并把得分Top-N的patch截图导出。保留每个patch的(x, y)坐标,医生可以按图索骥回到原图放大复核;这条流程补上后,自动分类才具备临床可用的基本条件。

6. 用注意力热图、TTA与特征复用让结果真正落地

6.1 把attention权重还原成热力图:让模型告诉你“凭什么”

MIL训练出的attention weights不仅能做聚合,还能映射回原图坐标,生成一张WSI级别的可疑区域热力图。我在训练完模型后有个固定动作:跑完全部验证切片,把每个patch的(x, y, score)三列存成csv,然后在缩略图上做高斯平滑叠加:

import cv2 import numpy as np # patches: [[x0, y0, score0], [x1, y1, score1], ...] 坐标是level0 scale = 32 heatmap = np.zeros((H // scale, W // scale), dtype=np.float32) for x, y, s in patches: cx, cy = int(x // scale), int(y // scale) if 0 <= cx < heatmap.shape[1] and 0 <= cy < heatmap.shape[0]: heatmap[cy, cx] = max(heatmap[cy, cx], s) # 一个网格多个patch取最大值 heatmap = cv2.GaussianBlur(heatmap, (0, 0), sigmaX=3) heatmap_color = cv2.applyColorMap( (heatmap * 255).astype(np.uint8), cv2.COLORMAP_JET ) overlay = cv2.addWeighted(thumbnail, 0.6, heatmap_color, 0.4, 0) cv2.imwrite("heatmap.jpg", overlay)

热力图用最大值聚合而不是平均值,语义是“只要这个区域出现过一次高置信癌灶就算可疑”,更贴合病理筛查看重点。scale=32对应level0图上每32像素一个网格;如果WSI太大,先降采样到level2或level3再叠加。医生对某个区域有疑问,直接按坐标回原图放大对照,模型就不再是黑匣子。

6.2 用TTA稳定预测概率:不再为一次抽样的随机性头疼

病理图像自动分类最后一步,我通常还会加TTA(测试时增强)。它不增加训练成本,只对同一patch做几次变换后取平均,能把翻转方向导致的边界噪声抹平。如果模型在验证集上不同epoch的AUC抖动超过0.01,TTA通常能稳定回0.005以内。

def predict_tta(model, x, K=3): # K=3:原图、水平翻转、旋转180,三个视角取平均 with torch.no_grad(): prob = torch.softmax(model(x), dim=1) if K > 1: prob = prob + torch.softmax(model(torch.flip(x, dims=[3])), dim=1) if K > 2: xx = torch.rot90(x, k=2, dims=[3, 2]) prob = prob + torch.softmax(model(xx), dim=1) return prob / K

TTA在病理图上的作用比自然图像更明显,因为patch边缘的细胞团被翻转后可能看起来更像另一类。如果同一张切片用TTA测三次,三次Top-N patch的位置不一致,那大概率是模型还没收敛,先别急着谈部署,回头把难样本加入训练集重训。

我现在的习惯是,做任何病理分类项目时先保存patch坐标和TTA结果,再去谈AUC提升。坐标是排错后悔药,热力图是跟医生沟通的界面,两者都补齐,模型才不只是一个输出概率的黑盒子。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询