多实例学习与注意力机制在糖网筛查中的实践指南

发布时间:2026/10/13 4:57:47
多实例学习与注意力机制在糖网筛查中的实践指南 简介糖尿病视网膜病变是糖尿病常见并发症多实例学习专门处理实例级标注不明确的医学图像。这一资源包提供面向该病变检测的MIL-DR数据集配套代码适合医学图像分析和机器学习研究者使用。压缩包共包含5个文件有两个Jupyter Notebook脚本、一个模型权重文件、一个Python源文件与一份Markdown说明文档整体大小约10.83MB轻量且便于部署。Notebook脚本完整覆盖图像预处理、特征提取、多实例模型搭建、训练与评估流程其中预处理涉及尺寸调整与数据增强特征提取可借助预训练卷积网络模型部分使用池化聚合实例预测损失函数与评估指标均有实现。代码中还包括模型权重可直接加载进行迁移学习或复现实验说明文档则梳理了数据集的组织方式和复现步骤。该资源已有七百六十人学习通过这份代码能够理解实例级标注的处理思路、掌握多实例学习在医疗影像中的实际应用并获得Grad-CAM等可视化解释模型的技巧对开展相关研究与模型调优具有参考价值。1. 多实例学习碰上糖网数据为什么每个患者是一袋实例糖尿病视网膜病变筛查时一张眼底照片里可能只有几个出血点却决定患者是否需要转诊。传统图像分类把整张图塞进网络结果小病灶被背景淹没模型几乎学不到微动脉瘤这种只占十来个像素的线索。多实例学习MIL换了个思路把一张眼底图切成若干小块每一块是一个实例整张图就是一个包只要包里有一个阳性实例这个包就判为阳性。这正好匹配临床上只有图像级标签、没有像素级标注的尴尬处境。这份《MIL-DR》资源是一套完整的 Jupyter Notebook从眼底图预处理、实例特征提取到注意力多实例模型训练全程可复现适合做医学影像弱监督学习的人也适合想搞懂 MIL 怎么落地的新手。2. MIL 的首要原则包假设与糖网标签的对应关系2.1 从实例到包标准假设与广义假设多实例学习最早出现在药物活性预测里核心是包和实例的层级关系。标准 MIL 假设是一个包被标记为正当且仅当包内至少存在一个正实例包为负则所有实例都为负。放到眼底图上一张图被切成 patch如果某个 patch 里有出血点或渗出物那张图就是病变图。这是最干净、也最常用的设定。但临床数据往往不完全满足标准假设。比如一张眼底片子上只有一处很小很淡的早期病变可能连标注医生都犹豫又比如包内存在噪声实例像反光、污点。这时候完全按一票即阳处理会把噪声当成正例。所以后来的 MIL 研究引入了广义假设允许包标签由实例得分的某种聚合函数决定比如最大池化、平均池化、注意力加权。注意力机制能在训练中学习每个实例的重要性不要求实例标签明确存在这就是 Attention MIL 在医学影像里流行的原因。2.2 DR 分级任务怎么映射成包预测糖尿病视网膜病变的常规分级从无病变到增殖期通常被简化成二分类是否需要转诊 / 是否存在威胁视力的病变。图像级标签已经公开但像素级标注成本极高人力不可能逐张圈出出血点。MIL 把读整图变成读包批量操作可以把一张图切成几十甚至上百个 patchpatch 就是实例。一个容易被忽略的点是切什么尺寸、切多少块直接影响模型能不能看到病灶。微动脉瘤可能只有 2050 像素如果 patch 切到 128×128病灶在 patch 里占比还是很小如果切 32×32又可能把同一病灶切碎。实践中需要参考数据集原图分辨率和病灶尺度。本资源默认将眼底图缩放至 256×256再以 64×64 的窗口、无重叠滑窗切出 16 个 patch这样每个 patch 含 64×64 个像素通常足够覆盖早期出血点。2.3 包的组织方式按患者切还是按单眼图像切同一个患者可能两眼各有一张眼底图这两张图不是独立的若左眼有病变右眼病变概率也高而且两眼可能有相同拍摄环境和噪声。如果把两只眼的图都放进训练集验证集里也出现该患者的另一只眼就造成信息泄漏模型会把这个患者的拍摄风格当特征。本资源的策略是把每个患者的每只眼图像作为独立样本但按患者 ID 划分训练 / 验证集保证同一患者的所有图像只出现在其中一个集合里。这样可以避免模型记人。若数据包含多次随访图像也按时间序列拆分不用时间交叉防止后期图像特征影响早期预测。3. 动手第一步预处理眼底图并提取实例特征3.1 图像读取、缩放与 Patch 切分在 Jupyter Notebook 里我一般先用 OpenCV 完成读图和切块。代码要能直接跑且要记录参数方便后面复现。import cv2 import numpy as np def crop_patches(img_path, target_size256, patch_size64): img cv2.imread(img_path) if img is None: raise ValueError(f无法读取图像: {img_path}) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (target_size, target_size), interpolationcv2.INTER_AREA) patches [] for y in range(0, target_size - patch_size 1, patch_size): for x in range(0, target_size - patch_size 1, patch_size): patch img[y:ypatch_size, x:xpatch_size] patches.append(patch) return np.stack(patches) # shape: (16, 64, 64, 3) # 参数说明 # target_size256原始眼底图先缩放到 256x256统一分辨率 # patch_size64每个实例的边长16 个 patch (256/64)^2 # INTER_AREA缩放时用区域插值对医学图像更平滑避免高频噪声这段代码的关键在于无重叠滑窗。如果你希望实例间有重叠可以设置 stride patch_size比如 stride32这样 patch 数量变成 (256-64)/321 的平方即 7×749 个。重叠会让模型看到更多上下文但计算量增加同时也更容易让相邻 patch 共享背景特征反而可能模糊边界。对早期糖网病灶我建议先无重叠跑一版如果漏检率高再调成重叠。3.2 用预训练 CNN 把每个 Patch 变成向量MIL 模型输入通常是实例特征向量而不是原始 patch 图像。直接用预训练 CNN如 ResNet18提取每个 patch 的特征。本资源把 ResNet18 的最后一层全连接去掉取 512 维特征。import torch from torchvision import models, transforms from PIL import Image # 加载预训练 ResNet18去掉最后一层分类头 backbone models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) backbone torch.nn.Sequential(*list(backbone.children())[:-1]) # 输出 (B, 512, 1, 1) backbone.eval() preprocess transforms.Compose([ transforms.ToPILImage(), transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def extract_features_from_patches(patches): feats [] with torch.no_grad(): for patch in patches: input_tensor preprocess(patch).unsqueeze(0) # shape (1,3,64,64) feat backbone(input_tensor) # shape (1,512,1,1) feat feat.flatten().numpy() # shape (512,) feats.append(feat) return np.stack(feats) # shape: (16, 512)这里把每个 patch 单独过网络效率不高但直观。实际 Notebook 里可以一次 batch 处理所有 patch。要注意backbone.eval()是必须的预训练模型里的 BatchNorm 层在训练和评估状态下行为不同提取特征时只能走 eval否则统计量会因小 batch 而抖动导致特征不稳定。如果你不想让 ResNet 的可学习参数参与反向传播提取完特征后就冻结它后面 MIL 只学注意力层和分类层。3.3 构建包级数据集固定实例数或动态填充由于每张图切出的 patch 数固定16 个时可以把包直接拼成张量。若切重叠窗口每个包 patch 数可能不同训练时就需要做 padding 或用 mask 处理。本资源默认固定 16 个实例所以最简单。class BagDataset(torch.utils.data.Dataset): def __init__(self, feature_list, bag_labels): self.features feature_list # list of arrays, each shape (num_patches, 512) self.labels bag_labels # list of int def __len__(self): return len(self.labels) def __getitem__(self, idx): x torch.tensor(self.features[idx], dtypetorch.float32) # (16, 512) y torch.tensor(self.labels[idx], dtypetorch.float32) return x, y如果实例数不固定需要在__getitem__里返回 mask 或按最大长度 padding。更推荐的做法是在预处理时随机丢弃一些 patch把数量统一到某个固定值或者直接固定无重叠切法。你会发现固定实例数能让后续 attention 实现少很多边界问题对于人数较少的团队或新手更友好。4. 训练 Attention MIL 模型从注意力加权到包分类4.1 注意力机制让模型学会挑重点 patchAttention MIL 的核心不是直接池化所有 patch 特征而是学习每个 patch 的权重再对特征做加权求和。权重由实例特征经过一个小网络得到并用 softmax 归一化。这样模型可以盯着那些有病灶的 patch忽略干净的背景 patch。import torch import torch.nn as nn class AttentionMIL(nn.Module): def __init__(self, input_dim512, hidden_dim128): super().__init__() self.attention nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.Tanh(), nn.Linear(hidden_dim, 1) ) self.classifier nn.Linear(input_dim, 1) def forward(self, bag): bag: (batch_size, num_patches, input_dim) 返回 logit 和注意力权重 (batch_size, num_patches) attn_logits self.attention(bag).squeeze(-1) # (batch, num_patches) attn_weights torch.softmax(attn_logits, dim1) # (batch, num_patches) bag_rep torch.sum(attn_weights.unsqueeze(-1) * bag, dim1) # (batch, input_dim) logit self.classifier(bag_rep).squeeze(-1) # (batch,) return logit, attn_weights这段代码里Tanh作为非线性激活通常比 ReLU 稳定因为 ReLU 会把负特征全部置零可能导致某些 patch 永远得不到梯度。hidden_dim控制注意力网络的容量设太小学不动太大泛化差128 在 512 维输入下是安全起点。4.2 训练循环用 BCE Loss 处理二分类包标签是 0/1用二元交叉熵。考虑到类别不平衡可以在BCEWithLogitsLoss里加pos_weight或者简单用加权采样。def train_one_epoch(model, dataloader, optimizer, device): model.train() total_loss 0.0 for x, y in dataloader: x, y x.to(device), y.to(device) optimizer.zero_grad() logit, attn model(x) # logit: (batch,) loss nn.functional.binary_cross_entropy_with_logits(logit, y) loss.backward() optimizer.step() total_loss loss.item() * x.size(0) return total_loss / len(dataloader.dataset) # 使用示例 model AttentionMIL(input_dim512, hidden_dim128).to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-4) # Adam 适合小模型 # 训练轮数建议 30~50配合早停参数lr1e-4是一个保守值。如果 loss 下降太慢可以提高到 1e-3 并加权重衰减如果训练震荡则降回 1e-4。我一般会记录每个 epoch 的验证 AUC用 AUC 而不是准确率选模型因为 DR 数据经常是阴多阳少准确率会被多数类拉高。4.3 评估AUC、敏感度、特异度糖网筛查最怕漏诊所以敏感度recall比准确率重要。评估时我用sklearn计算 AUC然后根据验证集最佳阈值给出敏感度和特异度。from sklearn.metrics import roc_auc_score, roc_curve def evaluate(model, dataloader, device): model.eval() all_logits [] all_labels [] with torch.no_grad(): for x, y in dataloader: x x.to(device) logit, _ model(x) all_logits.append(logit.cpu().numpy()) all_labels.append(y.numpy()) logits np.concatenate(all_logits) labels np.concatenate(all_labels) auc roc_auc_score(labels, logits) fpr, tpr, thresholds roc_curve(labels, logits) # 找约登指数最大的阈值 idx np.argmax(tpr - fpr) best_thr thresholds[idx] return auc, best_thr, tpr[idx], fpr[idx]这里 logit 是未经过 sigmoid 的原始值直接用roc_auc_score可以接收原始分数。best_thr用于后续预测时把分数转成 0/1。注意阈值应该只从验证集选不能用测试集选否则会乐观偏差。5. 避坑与常见问题标签泄漏、参差不齐与过拟合5.1 标签泄漏同一个患者两眼的图被分到训练和验证现象验证 AUC 很高但换一个外部数据集准确率暴跌。 原因把同一个患者的左眼和右眼分别放进了训练集与验证集模型记住了患者级别特征比如拍摄亮度、眼底色素沉着风格。 解决在划分数据集前按患者 ID 去重。先用 sklearn 的GroupShuffleSplit按患者 ID 分组保证同一患者的图像不会跨集合。具体做法是为每张图维护patient_id然后只把patient_id列表传给分组器。5.2 包内 patch 数不一致导致 tensor 无法堆叠现象DataLoader 报错RuntimeError: stack expects each tensor to be equal size。 原因切 patch 时用了重叠滑窗或者某些图像经过掩膜裁剪后大小不同导致包内实例数不同。 解决方案一把所有实例数统一到某个固定值例如随机采样 16 个 patch或固定无重叠切方案二用 pad 或 mask在 attention 计算时屏蔽掉无效位置。对于初学者我建议直接固定 patch 数。如果数据量小想保留更多上下文可以设置重叠且用 mask下面给出 mask 的 forward 修改示例attention 分数加一个极大负值在 mask 位置softmax 后权重为 0。5.3 预训练模型 BatchNorm 没冻结导致特征不稳定现象同样一张图两次提取特征结果不同或训练早期 loss 大幅跳动。 原因提取特征时模型处于train()模式BatchNorm 会依据当前 batch 统计量进行归一化小 batch 下统计量抖动。或者特征提取后仍然允许 BN 参数更新改变了特征分布。 解决提取特征阶段必须model.eval()且不更新参数。如果特征提取与训练同时进行需要冻结 backbone 所有层并在训练前就设置requires_gradFalse。还有一个常被忽略的小坑图像预处理时用了跟 ImageNet 一致的 mean/std但输入 patch 是 OpenCV 的 BGR 顺序必须转成 RGB否则特征会明显退化。5.4 类别不平衡导致模型永远预测阴性现象训练结束后验证集 AUC 在 0.5 附近预测结果几乎全是 0。 原因阴性样本占多数BCE loss 被大量负样本主导模型发现预测全 0 也能得到很低 loss。 解决最直接的办法是给正样本加权重BCEWithLogitsLoss(pos_weighttorch.tensor([neg_num/pos_num]))。也可以在数据加载时用WeightedRandomSampler让每个 batch 正负比例接近 1:1。对于糖网筛查漏诊的代价远大于误诊所以我会在训练后用验证集阈值往低偏调让敏感度达到 0.95 以上再评估特异度。注意不要盲目降低阈值否则模型会把大量噪声图片当成阳性转诊压力会激增。6. 进阶验证注意力热力图与阈值校准6.1 把注意力权重映射回原图Attention 权重的意义不只是帮助分类还能定位模型认为有问题的 patch。我习惯在推理阶段把attn_weights保存下来然后画在原图上。因为 patch 是无重叠切割权重可以直接 reshape 成 4×4 的网格再放大到 256×256。import matplotlib.pyplot as plt def visualize_attention(original_img, attn_weights): # original_img: 256x256 RGB # attn_weights: (16,) 对应 4x4 网格 grid attn_weights.reshape(4, 4) heatmap np.kron(grid, np.ones((64, 64))) # 上采样到 256x256 plt.figure(figsize(8, 4)) plt.subplot(1, 2, 1) plt.imshow(original_img) plt.title(Original) plt.axis(off) plt.subplot(1, 2, 2) plt.imshow(original_img) plt.imshow(heatmap, alpha0.5, cmapjet) plt.title(Attention Heatmap) plt.axis(off)从热力图里可以直观看出模型是否聚焦在血管交叉、出血点附近。如果热力图随机分布说明模型没学到病灶特征需要检查预处理或特征提取如果热力图集中但定位错误比如总盯着拍摄反光区域说明训练数据里反光与标签有虚假相关。6.2 用验证集做阈值校准模型输出的 logit 只是分数不是概率。很多初学者默认阈值 0看 AUC 不错就直接上线结果敏感度不足。正确的做法是拿着验证集画 ROC 曲线选择目标敏感度对应的阈值。比如对糖网筛查我通常要求敏感度 ≥ 0.95这时阈值往往会从 0 降到 -1 甚至 -2。然后在这个阈值下记录特异度如果特异度太低说明模型分数区分度差回头改进特征或增加数据。6.3 验证泛化独立外部数据集的必要性训练集和验证集来自同一家医院、同一台设备模型很可能学到设备噪声。所以我习惯把一份资源里的数据按来源划分至少用一个来源不同的独立测试集看效果。如果没有外部数据就用交叉验证或自举法评估方差。每次训练前固定随机种子保证可复现。从那以后我每做一次 MIL 实验都会强制在 Notebook 里输出三样东西AUC曲线、注意力热力图、按敏感度目标选出的阈值。少了哪一样我都会怀疑模型是不是在背题。这份资源里的 Jupyter Notebook 已经把这些步骤串成了完整流程你拿自己的数据集替换路径就能跑通希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询