ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

乳腺癌病理图像自动分类:从patch到WSI的深度学习落地指南

2026/10/5 9:08:42 拓冰建站 浏览量
乳腺癌病理图像自动分类:从patch到WSI的深度学习落地指南 简介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、tifOpenSlide是读这类格式最稳的库。切patch前必须先定位组织区域否则一张切片三分之二是空白背景把这些样本送进模型模型会学到“背景规律”而不是“肿瘤形态”。我的做法是先用低倍率level 2或3读一张缩略图做OTSU阈值拿到组织掩膜再回到level 0坐标系下按patch_size切块。import openslide import cv2 import numpy as np import csv def build_tissue_mask(slide, level2): # 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在HE图上有稳定的二值效果 # 用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_size256, strideNone): slide openslide.OpenSlide(svs_path) mask, factor build_tissue_mask(slide, level2) 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中出现剪切插值伪影我一般不用。颜色增强更要克制HE染色有既定标准过度的色调偏移会让模型把染色波动当成类别信号。import torchvision.transforms as T train_transform T.Compose([ T.RandomHorizontalFlip(p0.5), T.RandomVerticalFlip(p0.5), T.RandomRotation(90), # 只做90度整数倍旋转避免插值伪影 T.ColorJitter(brightness0.15, contrast0.15, saturation0.0, hue0.0), T.ToTensor(), # 病理图像通道均值与自然图不同统一减半归一化即可 T.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]) ])参数说明ColorJitter里把saturation设0是因为HE切片只有粉红和蓝紫两类染色饱和度扰动会引入不存在的色彩变化brightness和contrast各给0.15这是为了模拟不同切片厚度的明暗差异。类别不均衡方面如果直接用CrossEntropyLoss负样本占比常超过80%模型会倾向把所有patch预测为阴性。常见做法是加WeightedRandomSampler或者直接用Focal Loss。我更推荐后者因为它同时处理了“数量少”和“难分类”两个问题恶性肿瘤小灶通常就是难样本。import torch class FocalLoss(torch.nn.Module): def __init__(self, gamma2.0, alphaNone): 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, reductionnone) 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()这里gamma2.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(weightsmodels.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组成的bagbag的标签等于整张切片标签bag内每个patch不单独标注。MIL里最关键的是聚合方式取平均会把大量阴性patch的噪声带进来常见做法是让模型学一组注意力权重把判别性最强的patch特征选出来。工程上通常先把每张WSI的patch用ResNet提成特征缓存下来再单独训练聚合模块这样能省掉重复的前向计算。class AttentionMIL(torch.nn.Module): def __init__(self, feature_dim2048, hidden_dim256): 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, dim0) # 归一化为概率分布 bag_feat (weights.unsqueeze(-1) * features).sum(dim0) # 加权求和 return bag_feat, weightsattention输出的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-4warmup3个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_norm5.0) scaler.step(optimizer) scaler.update() optimizer.zero_grad()clip_grad_norm_在病理图像任务里不是可有可无。有些patch本身对比度极低模型可能出现单步大梯度不裁剪时loss会跳到NaNmax_norm5.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_splits5) for train_idx, val_idx in gkf.split(df, df[label], groupsgroups): 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 混淆矩阵与ROCpatch级和患者级两个口径评估指标不能只看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(fAUC{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], lr0.01, max_iter100) 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以下。原因HE切片的染色深浅、扫描仪白平衡在不同机构间差异明显。模型很容易学到“像素颜色均值”这类快捷特征而不是细胞核形态病理图像的临床数据天然来自多个中心颜色域漂移是真实部署里常见的问题。解决训练前对所有训练和测试切片做染色标准化Macenko或Reinhard方法都行。Macenko的核心是从每个patch估计染色浓度矩阵再映射到参考切片的染色空间。代价是每个patch多一次矩阵运算如果推理速度敏感可以把归一化集成到数据读取管线里而不是落盘成新图。5.2 患者数据泄漏patch随机划分让AUC虚高现象按patch随机划分训练集和验证集交叉验证AUC 0.98改成按患者分组划分后变成0.85说明前面那个数是假的。原因同一张切片的相邻patch有大量重叠区域随机划分会把几乎相同的patch分进训练和验证模型相当于默写原图。解决用4.1节的GroupKFoldgroup设为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), dtypenp.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), sigmaX3) 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)热力图用最大值聚合而不是平均值语义是“只要这个区域出现过一次高置信癌灶就算可疑”更贴合病理筛查看重点。scale32对应level0图上每32像素一个网格如果WSI太大先降采样到level2或level3再叠加。医生对某个区域有疑问直接按坐标回原图放大对照模型就不再是黑匣子。6.2 用TTA稳定预测概率不再为一次抽样的随机性头疼病理图像自动分类最后一步我通常还会加TTA测试时增强。它不增加训练成本只对同一patch做几次变换后取平均能把翻转方向导致的边界噪声抹平。如果模型在验证集上不同epoch的AUC抖动超过0.01TTA通常能稳定回0.005以内。def predict_tta(model, x, K3): # K3原图、水平翻转、旋转180三个视角取平均 with torch.no_grad(): prob torch.softmax(model(x), dim1) if K 1: prob prob torch.softmax(model(torch.flip(x, dims[3])), dim1) if K 2: xx torch.rot90(x, k2, dims[3, 2]) prob prob torch.softmax(model(xx), dim1) return prob / KTTA在病理图上的作用比自然图像更明显因为patch边缘的细胞团被翻转后可能看起来更像另一类。如果同一张切片用TTA测三次三次Top-N patch的位置不一致那大概率是模型还没收敛先别急着谈部署回头把难样本加入训练集重训。我现在的习惯是做任何病理分类项目时先保存patch坐标和TTA结果再去谈AUC提升。坐标是排错后悔药热力图是跟医生沟通的界面两者都补齐模型才不只是一个输出概率的黑盒子。希望帮到你。本文还有配套的精品资源点击获取