多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于深度学习的乳腺癌病理图像自动分类:从WSI到MIL实战

基于深度学习的乳腺癌病理图像自动分类:从WSI到MIL实战 简介这份PDF资料聚焦深度学习在乳腺癌病理图像自动分类中的应用面向医学图像处理方向的研究生、算法工程师及计算机辅助诊断从业者。内容围绕深度卷积神经网络模型展开结合数据增强与迁移学习策略缓解小样本训练下的过拟合问题实验识别率达到91%并具备较好的鲁棒性与泛化性。资源包内仅含1个PDF文件大小约1.56MB属于典型的学术论文类文档便于在电脑或移动端直接阅读与检索。文中系统梳理了从数据收集、预处理、模型建立、训练到评估的完整技术路线并对比了基于人工特征提取的传统分类方法在专业门槛、耗时与特征质量上的局限同时介绍了BreaKHis等公开数据集及LBP、GLCM等特征描述子的研究进展。目前已有248人学习适合希望快速了解该领域方法脉络、获取可复现实验思路与评估指标的读者参考。1. 病理切片里那点事为什么乳腺癌自动分类值得动手做一张乳腺癌病理切片在 40 倍物镜下扫出来动辄几万乘几万像素一个区域里挤着上万颗细胞核。病理医生要在这样的图上找浸润癌、原位癌、正常导管和间质的边界一份报告看下来二三十分钟是常态。基于深度学习的乳腺癌病理图像自动分类要解决的就是把「这张图属于哪一类」这件事交给模型先跑一遍把可疑区域圈出来医生只做复核。它适合两类人一类是想把深度学习真正落到医学场景的算法工程师另一类是手里有切片数据、想搭一套辅助筛查流程的科研或工程团队。热搜里「深度学习项目」「深度学习入门」被反复搜但医学图像和自然图像差别很大——染色批次、扫描仪型号、组织折叠都会让模型翻车这也是为什么这个方向值得认真做一遍而不是拿现成的 CNN 套上去就完事。2. 从一张 WSIs 到可训练数据集拆解乳腺癌病理分类的输入链路病理图像分类和普通图像分类最大的区别在于输入不是一张 224×224 的 JPG而是一整张全切片图像Whole Slide ImageWSI。WSI 常见格式是.svs、.ndpi、.mrxs单文件几百 MB 到几个 GB。模型没法直接吃这么大的图所以整条链路的第一步是「切片 → 分块 → 筛选 → 打标签」。这一章把输入链路拆开讲清楚因为后面模型再花哨输入错了全是白干。2.1 WSI 金字塔结构与为什么要做分块WSI 内部是一个金字塔结构通常有 0 到 4 级level 0 是最高分辨率比如 0.25 微米/像素level 越高分辨率越低。做分类时我们一般选一个中间层比如 level 1 或 level 2既保留细胞核形态又不至于让单块图太大。常见做法是把选定层切成 256×256 或 512×512 的 patch步长等于块大小不重叠或块大小的一半有重叠。不重叠切块快但边界上的病灶可能被切碎重叠切块能缓解这个问题代价是数据量翻倍。我一般先用不重叠跑通流程等模型有 baseline 了再考虑重叠。切块之后还要做组织区域筛选——切片里大片白色是玻璃背景不含组织直接丢掉能省 30% 到 60% 的计算量。筛选方法简单粗暴把 patch 转灰度算像素值小于某阈值的比例低于阈值就丢。import openslide import numpy as np from PIL import Image def extract_patches(slide_path, level1, patch_size256, tissue_thresh0.5): 从 WSI 中提取组织区域 patch level: 金字塔层级越大分辨率越低 patch_size: 单块边长 tissue_thresh: 组织像素占比阈值低于此值丢弃 slide openslide.OpenSlide(slide_path) w, h slide.level_dimensions[level] patches [] coords [] for y in range(0, h - patch_size, patch_size): for x in range(0, w - patch_size, patch_size): # 读取当前块注意 read_region 的坐标是 level 0 坐标 patch slide.read_region((x * (2 ** level), y * (2 ** level)), level, (patch_size, patch_size)).convert(RGB) arr np.array(patch) # 转灰度判断组织占比 gray arr.mean(axis2) tissue_ratio (gray 220).mean() if tissue_ratio tissue_thresh: patches.append(arr) coords.append((x, y)) slide.close() return patches, coords这段代码里level决定分辨率patch_size决定单块大小tissue_thresh控制筛选严格程度。read_region的第一个参数必须是 level 0 坐标这是 openslide 最容易踩的坑写错了读出来的位置全偏。gray 220这个阈值不是固定的不同扫描仪背景亮度不一样建议先抽几张图看一眼灰度直方图再定。2.2 标签从哪来弱监督与多示例学习病理图像有个现实问题切片级别的标签好拿这份切片是恶性还是良性但像素级或区域级标签极难拿需要病理医生逐块标注成本高到不现实。所以主流做法是多示例学习MIL, Multiple Instance Learning一份切片是一个包bag里面的 patch 是示例instance包有标签示例没有。模型要学会从包里挑出关键示例来做判断。MIL 的经典结构是「特征提取 聚合」。先用一个 CNNResNet、EfficientNet 都行把每个 patch 压成一个特征向量再用一个聚合模块attention、max-pooling、LSTM 都可以把一堆 patch 特征聚成一个包特征最后接分类头。Attention-based MIL 是目前最常用的因为它能给出每个 patch 的注意力权重相当于告诉你「模型觉得这块最像癌」可解释性比 max-pooling 好。import torch import torch.nn as nn class AttentionMIL(nn.Module): def __init__(self, feat_dim512, hidden_dim256, n_classes2): super().__init__() # 注意力网络把每个 patch 特征映射成一个标量权重 self.attention nn.Sequential( nn.Linear(feat_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, 1) ) self.classifier nn.Linear(feat_dim, n_classes) def forward(self, x): # x: (batch, n_patches, feat_dim) attn self.attention(x) # (batch, n_patches, 1) attn torch.softmax(attn, dim1) # 归一化成权重 bag (attn * x).sum(dim1) # 加权求和成包特征 return self.classifier(bag), attn.squeeze(-1)feat_dim要和特征提取器输出对齐ResNet50 是 2048EfficientNet-B0 是 1280。hidden_dim一般取 feat_dim 的一半到四分之一。注意力权重attn记得存下来后面可视化热力图要用。训练时如果包内 patch 数差异很大有的切片几百块有的几万块建议每个包随机采样固定数量比如 512 块再送进网络否则显存和 batch 组织都会很难受。2.3 数据划分别让同一张切片跨训练和验证这是医学图像里最容易被忽视、后果最严重的一步。如果你按 patch 随机划分训练集和验证集同一张切片的 patch 会同时出现在两边模型等于在「背答案」验证准确率虚高到 99%上线就崩。正确做法是按切片划分训练集、验证集、测试集各自包含不同的切片互不重叠。如果数据来自不同医院或不同扫描仪还要考虑按来源划分测试跨中心泛化能力。划分方式训练/验证是否共享切片验证准确率表现是否可用patch 随机划分是虚高常 95%不可用按切片划分否真实通常低 5-15 个点必须用按来源划分否且跨中心最低但最接近落地有条件就用我一般会留出 20% 切片做测试剩下 80% 里再切 15% 做验证。如果切片总数少于 100建议做交叉验证否则单次划分的方差太大模型好坏全靠运气。3. 模型选型与训练CNN、Transformer 还是 MIL怎么选不后悔输入链路搭好之后核心问题变成「用什么模型、怎么训」。这一章把选型逻辑和训练细节讲透包括特征提取器怎么挑、聚合器怎么选、损失函数和优化器怎么配以及训练过程中哪些参数一动就翻车。3.1 特征提取器ResNet 还是 EfficientNet要不要上 Transformer特征提取器负责把每个 patch 变成一个向量。常见选择有三类ResNet 系列、EfficientNet 系列、Vision TransformerViT系列。ResNet50 是医学图像里最稳的 baseline预训练权重好找ImageNet 上训过的权重迁移过来就能用。EfficientNet-B0 到 B4 参数量更小、精度相当适合显存紧张的场景。ViT 在数据量足够大时表现更好但病理图像标注数据通常没那么多直接上 ViT 容易过拟合。我的经验是数据量少于 1 万张切片先用 ResNet50 或 EfficientNet-B0 跑 baseline数据量上万且有多中心数据再考虑 ViT 或混合结构。预训练权重一定要用ImageNet 预训练在病理图像上虽然域不匹配但底层纹理特征仍然有用比随机初始化收敛快很多。如果手头有大量无标签病理图像可以先用自监督SimCLR、MoCo在病理数据上预训练再迁移到分类任务效果通常比 ImageNet 权重好。import torchvision.models as models import torch.nn as nn def build_encoder(nameresnet50, pretrainedTrue): if name resnet50: model models.resnet50(pretrainedpretrained) # 去掉最后的全连接层只要特征 encoder nn.Sequential(*list(model.children())[:-1]) feat_dim 2048 elif name efficientnet_b0: model models.efficientnet_b0(pretrainedpretrained) encoder nn.Sequential(*list(model.children())[:-1]) feat_dim 1280 else: raise ValueError(funsupported encoder: {name}) return encoder, feat_dimpretrainedTrue会下载 ImageNet 权重第一次跑需要联网。list(model.children())[:-1]是把最后的分类层去掉保留池化后的特征。注意 EfficientNet 的输出维度是 1280不是 2048接聚合器时别写错。3.2 聚合器与损失函数attention 之外还有什么选择聚合器决定怎么把一堆 patch 特征变成一个包特征。除了前面说的 attention MIL还有几种常见方案Max-pooling取每个特征维度的最大值。简单但对噪声敏感一个异常 patch 就能带偏整个包。Mean-pooling取平均。稳定但会稀释关键 patch 的信号。LSTM/RNN把 patch 当序列处理。能建模 patch 间关系但训练慢对 patch 顺序敏感。Transformer 聚合用自注意力让 patch 之间互相看。表达能力强但需要更多数据和算力。损失函数方面二分类用交叉熵就够了。如果类别不平衡恶性切片远少于良性加类别权重或改用 Focal Loss。多分类比如正常、原位癌、浸润癌、间质也是交叉熵但要注意混淆矩阵里哪几类容易混——原位癌和浸润癌在低倍镜下经常分不开可能需要更高倍率的 patch。import torch.nn.functional as F def mil_loss(logits, labels, attnNone, sparsity_weight0.0): logits: (batch, n_classes) labels: (batch,) attn: (batch, n_patches) 注意力权重用于稀疏正则 sparsity_weight: 稀疏正则系数鼓励注意力集中 ce F.cross_entropy(logits, labels) if attn is not None and sparsity_weight 0: # 熵正则让注意力分布更尖锐 entropy -(attn * torch.log(attn 1e-8)).sum(dim1).mean() ce ce sparsity_weight * entropy return cesparsity_weight一般设 0.0001 到 0.001太大会让注意力退化成只关注一个 patch反而丢信息。这个正则不是必须的baseline 先不加等模型能跑通了再调。3.3 训练参数学习率、batch 组织和早停学习率是最关键的参数。用预训练权重时特征提取器用小学习率1e-5 到 1e-4聚合器和分类头用大学习率1e-3 到 1e-2这叫差分学习率。如果全部用同一个大学习率预训练权重会被迅速破坏模型表现反而不如随机初始化。batch 组织上因为每个包的 patch 数不一样没法直接堆成规则张量。常见做法是每个包采样固定数量 patch比如 256 或 512这样 batch 里每个样本形状一致。采样时随机选但验证和测试时要么全用要么用固定采样保证可复现。早停看验证集损失或 AUC连续 5 到 10 个 epoch 不提升就停。医学图像数据集通常不大过拟合来得快早停能省不少时间。优化器用 Adam 或 AdamWweight decay 设 1e-4 到 1e-5。学习率调度用 cosine 或 ReduceLROnPlateau 都行cosine 更平滑。from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR # 差分学习率encoder 小聚合器和分类头大 optimizer AdamW([ {params: encoder.parameters(), lr: 1e-5}, {params: aggregator.parameters(), lr: 1e-3}, {params: classifier.parameters(), lr: 1e-3} ], weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max50, eta_min1e-6)T_max设成总 epoch 数eta_min是学习率下限。如果训练中发现验证损失震荡先把学习率降一个数量级试试别急着改模型结构。4. 避坑与排查乳腺癌病理分类里那些让人后悔药的细节这一章记录几个我在实际项目里踩过的坑每条按「现象 → 原因 → 解决」写。这些坑不挑模型、不挑框架只要做病理图像分类就可能遇到。4.1 验证准确率 99%测试集一跑就崩现象训练时验证集准确率很快到 99%但换一个测试集或者换一批数据准确率掉到 60% 甚至更低。原因几乎可以肯定是数据泄漏。同一张切片的 patch 被分到了训练集和验证集模型记住了这张切片的染色风格和背景而不是真正的病灶特征。病理图像里同一张切片的 patch 高度相似泄漏后果比自然图像严重得多。解决按切片划分数据集训练、验证、测试三者的切片 ID 完全不重叠。如果数据来自多个来源按来源划分更严格。划分完检查一遍训练集和验证集的切片 ID 交集必须为空。这个检查写进数据加载脚本里每次跑之前自动断言。4.2 模型只学会看染色深浅换个扫描仪就废现象模型在 A 扫描仪的数据上表现很好换到 B 扫描仪的切片上准确率大幅下降。原因不同扫描仪、不同染色批次会导致图像颜色分布差异很大。模型可能学到了「偏紫的就是癌」这种伪相关而不是细胞形态。这是病理图像跨中心泛化的核心难题。解决训练时做颜色增强color jitter、HSV 扰动、染色归一化。染色归一化方法里Reinhard 和 Macenko 最常用能把不同批次的颜色对齐到同一个参考。另外可以在特征提取器后加一个域适应模块或者用对抗训练让特征对不同来源不变。最简单有效的还是数据增强把颜色扰动范围开大一点。4.3 显存爆了batch size 只能设 1现象一张切片几千个 patch全送进网络直接 OOMbatch size 只能设 1训练慢到无法接受。原因MIL 的输入是变长序列如果每个包都用全部 patch显存占用和 patch 数成正比。几千个 patch 的包特征提取器前向一次就爆了。解决每个包采样固定数量 patch比如 256 或 512。采样在数据加载器里做不占显存。如果还是不够用梯度累积模拟大 batch或者把特征提取器冻结先离线把所有 patch 特征算好存成文件训练时只加载特征向量显存占用能降一个数量级。离线特征还有个好处聚合器可以反复调不用每次重新过 CNN。4.4 注意力权重全图均匀找不到病灶在哪现象训练完可视化注意力热力图发现权重几乎均匀分布没有集中在病灶区域可解释性等于没有。原因注意力网络没训好或者包内正例 patch 太少注意力被大量背景 patch 稀释。也可能是学习率太大注意力层还没收敛就发散了。解决先检查注意力层的学习率是不是和分类头一致如果太大就调小。加稀疏正则前面代码里的 entropy 项鼓励注意力集中。如果正例 patch 确实很少可以在采样时做正例优先或者用 top-k 聚合代替全注意力。另外注意力可视化要在测试集上做训练集的注意力可能过拟合。4.5 类别不平衡模型全预测成多数类现象恶性切片占 10%良性占 90%模型训练完在测试集上全预测良性准确率 90% 但召回率为 0。原因交叉熵损失被多数类主导模型学到「全猜良性」就能拿高准确率。解决损失函数加类别权重权重和类别频率成反比。或者用 Focal Loss降低易分类样本的权重。评价指标别只看准确率看 AUC、F1、召回率。如果恶性切片太少考虑过采样或数据增强生成更多恶性样本。极端情况下先做二分类恶性 vs 非恶性再细分比直接多分类稳。5. 进阶技巧用离线特征缓存把训练迭代速度提上来前面提到过离线特征缓存这里展开讲因为它是我做病理图像分类时最常用的一个技巧能把训练迭代速度提升一个数量级而且实现不复杂。思路很简单特征提取器CNN的参数在训练初期可以冻结先用它把所有 patch 的特征算出来存到磁盘之后训练聚合器和分类头时直接读特征不用再过 CNN。等聚合器训得差不多了再解冻 CNN 做端到端微调。这样大部分实验迭代都只跑聚合器速度快很多。具体实现分两步。第一步遍历所有切片提取 patch 特征并保存import os import torch import numpy as np def cache_features(slide_list, encoder, level1, patch_size256, n_samples512, save_dirfeature_cache): 离线提取并缓存每个切片的 patch 特征 n_samples: 每张切片采样多少个 patch os.makedirs(save_dir, exist_okTrue) encoder.eval() encoder encoder.cuda() with torch.no_grad(): for slide_id, slide_path in slide_list: patches, _ extract_patches(slide_path, level, patch_size) if len(patches) 0: continue # 随机采样固定数量 idx np.random.choice(len(patches), min(n_samples, len(patches)), replaceFalse) batch torch.from_numpy( np.stack([patches[i] for i in idx]).transpose(0, 3, 1, 2) ).float() / 255.0 batch batch.cuda() feats encoder(batch).squeeze(-1).squeeze(-1) # (n, feat_dim) np.save(os.path.join(save_dir, f{slide_id}.npy), feats.cpu().numpy())n_samples控制每张切片缓存多少 patch 特征一般 512 到 1024 够用。特征存成.npy文件名用切片 ID方便后面按 ID 加载。注意encoder要设成eval()并且no_grad()否则会存下计算图文件巨大。第二步训练时直接加载特征class CachedFeatureDataset(torch.utils.data.Dataset): def __init__(self, slide_ids, labels, feat_dir, n_samples512): self.slide_ids slide_ids self.labels labels self.feat_dir feat_dir self.n_samples n_samples def __len__(self): return len(self.slide_ids) def __getitem__(self, idx): feat np.load(os.path.join(self.feat_dir, f{self.slide_ids[idx]}.npy)) # 如果缓存的特征数多于 n_samples随机采样 if feat.shape[0] self.n_samples: sel np.random.choice(feat.shape[0], self.n_samples, replaceFalse) feat feat[sel] return torch.from_numpy(feat).float(), self.labels[idx]这样训练时只加载特征向量显存占用小迭代速度快。等聚合器收敛了再解冻 CNN 做端到端微调学习率调小1e-6 到 1e-5跑几个 epoch 通常还能涨一两个点。这个技巧的边界也要说清楚离线特征的质量取决于冻结的 CNN如果 CNN 本身没预训练好或者和任务域差太远缓存的特征就没那么有用。另外离线缓存不支持数据增强因为特征已经算好了所以端到端微调阶段还是要用原始图像。我的习惯是先用离线特征快速试聚合器结构和超参找到最好的配置后再端到端微调这样实验迭代快最终效果也不差。最后说个验证方法训练完之后别只看测试集准确率。把注意力权重映射回原图让病理医生看几张确认模型关注的是病灶区域而不是背景或伪影。如果医生觉得注意力位置不对模型准确率再高也不能信。这个习惯我坚持了很久帮我省了好几次「上线才发现模型看错地方」的后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表