
简介一套基于Python实现的多任务学习网络源码项目将ExpertNet与Resnet50结合面向医疗图像识别场景旨在通过无监督自适应方式降低对标注数据的依赖适合有一定深度学习基础并关注医学影像分析的研究者或开发者。压缩包共16个文件以10个Python脚本为核心覆盖数据预处理、模型定义、训练与评估等环节另含配置文件、说明文档与许可证整体约27KB结构清晰便于二次修改。当前已有254人学习项目中包含完整的Encoder训练、初始模型训练、生成模型与测试脚本可帮助读者理解ExpertNet多任务分支与Resnet50残差特征提取的协作方式并可直接用于肺部CT、眼底图像等任务的实验拓展是学习多任务学习与无监督医疗影像建模的实用参考。1. 医疗图像多任务学习为什么ExpertNet Resnet50要带上无监督自适应一张腹部CT往往同时包含器官分割、病灶分类、层厚对齐等多个任务。医学图像标注成本高单任务模型在另一台设备采集的数据上掉点明显。早期项目里我发现把分割、分类、重建放到同一个网络里用ExpertNet Resnet50共享特征又分头输出能有效提升小样本任务的表现但跨中心泛化仍然让人头疼。无监督自适应模型的思路是不动用目标域标签只借源域的标注信息和目标域的无标注图像在特征层面拉近两个域的距离。下面按“结构拆解 → 最小实现 → 训练与调参 → 验证与进阶”的顺序讲清楚如何用Python配合PyTorch搭一套可扩展的医疗图像多任务学习框架。2. 架构拆解ResNet50共享特征ExpertNet分派任务无监督自适应如何介入2.1 ResNet50作为Encoder从预训练权重到医学图像适配ResNet50的每个阶段输出步长分别是2、4、8、16、32最终特征图是输入尺寸的1/32。对医疗图像来说分割头需要较密的空间特征所以常见做法是丢掉最后一层全局池化取stage5输出的2048通道特征图再接一个轻量级解码器或ASPP上采样。resnet50网络结构示意图可以直接从torchvision源码里看层间通道变化是64、256、512、1024、2048理解这个通道变化对后面安排任务头很重要。医学图像通常是单通道输入常见做法是把1通道复制成3通道再送进网络也可以把conv1改成1通道并重新初始化。前者实现简单后者收敛更稳但需要更多调参。无论哪种torchvision提供的预训练权重建议保留医疗影像很少能从零训练超过ImageNet权重。另一个容易被忽略的点是输入尺寸如果原图是512×512而resnet50的默认stride会让显存占用成倍上涨我一般先在256×256上跑通流程再在推理阶段切到512×512并配合滑动窗口。2.2 ExpertNet先分路后融合还是门控路由多任务学习有两种主流做法。硬参数共享是Encoder只有一个每个任务一个输出头这是ExpertNet最简单的一种特例软参数共享则每个任务有自己的主分支同时通过交叉连接交换信息。在ExpertNet命名下最稳妥的理解是“共享ResNet50 encoder 多专家decoder 门控模块”门控根据输入图像或任务标识决定不同专家特征的权重。先明确任务关系如果分割和分类在解剖结构上强相关比如同时输出肝脏区域和是否有肿瘤专家头可以互相独立只在特征层面互相约束如果两个任务来自不同模态或不同站点门控路由的效果更好。因为医学图像数据量小门控参数不能太多一个全局池化后的256维线性层作为路由网络就够用了别一上来就做复杂的MoE否则伪标签噪声会被放大。实现上我会把ExpertNet拆成三个部件特征提取器、任务专家、门控。特征提取器就是ResNet50的encoder任务专家可以是一个接在1/32特征图上的Upsample加卷积门控做全局平均池化后再输出每个专家权重。门控的初始化可以用均匀分布也可以根据预设任务优先级给一个偏置项比如分割为主时初始化门控输出[0.7, 0.3]。2.3 无监督自适应模块对抗域对齐与一致性正则化无监督自适应模型在医疗影像中最直接的场景是跨机构、跨设备。源域有完整标注目标域只有原始扫描图像。常见做法是加一个域判别器对ResNet50提取的特征做对抗训练让判别器分不清特征来自源域还是目标域。梯度反转层让encoder在优化时自动混淆特征这是域对抗的底层套路但应用在分割上要注意判别器感受野patch-level判别比image-level稳定得多。另一条路径是一致性正则化也叫mean-teacher。维护一个冻结参数的teacher模型对目标域样本做弱增强和强增强两次前向让两个输出在特征或像素级上尽量一致。这个方案比对抗稳定但需要调EMA系数一般取0.99到0.999。在此基础上还可以用熵最小化约束低置信度区域避免模型在无标注数据上把类别边界定得过硬。三者可以组合。我一般在训练早期只用对抗对齐等源域任务loss收敛到一定程度再开一致性正则否则无监督信号的梯度会淹没多任务监督信号。这里的“自适应”就体现在损失权重和EMA更新上。3. 用Python落地最小实现数据管道与模型定义3.1 依赖与python环境准备先确认环境。python安装是第一步我建议用conda创建独立环境避免系统python和系统包互相干扰。下面是一个可复现的最小依赖清单conda create -n mti python3.8 -y conda activate mti pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install monai albumentations nibabel pandas scikit-learn tqdm说明monai提供医学图像专用IO和评估函数albumentations负责在线增强nibabel读取NIfTI。指定cu118要和你机器上的CUDA版本对应否则会装成CPU版torch后面训练根本跑不起来。这个步骤和普通项目差别不大但它直接决定后面对抗训练里batch size能开多大、能否在一个GPU上同时放源域和目标域样本。3.2 数据集结构一个CSV把事情串起来医疗图像多任务项目里第一件事是把NIfTI路径和标签整理成CSV。用表格管理比硬编码文件列表清晰得多也方便按站点、按fold切分。列名示例说明image_path/data/ct/patient_001.nii.gz原始图像路径seg_path/data/label/patient_001_seg.nii.gz分割标注路径可为空cls_label1病灶分类标签domainsource / target表示哪个站点或设备foldtrain / val / test用于交叉验证注意无监督自适应不代表源域和目标域都在同一个CSV里。我一般分成两个Dataset对象训练时每个step从源域取一个batch、目标域取另一个batch分别计算监督和无监督损失再合并梯度。目标域样本的seg_path和cls_label直接置空靠数据集的labeled标志控制返回值。3.3 构建多任务数据集类与预处理import torch import nibabel as nib from torch.utils.data import Dataset import numpy as np import cv2 class MedMTDataset(Dataset): def __init__(self, df, size(256, 256), labeledTrue, transformNone): self.df df self.size size self.labeled labeled self.transform transform def __getitem__(self, idx): row self.df.iloc[idx] img nib.load(row.image_path).get_fdata().astype(np.float32) # CT图像做窗宽窗位裁剪范围通常选[-200, 400] img np.clip(img, -200, 400) img (img - (-200)) / (400 - (-200)) img cv2.resize(img, self.size, interpolationcv2.INTER_LINEAR) if self.transform: transformed self.transform(imageimg) img transformed[image] x torch.from_numpy(img).unsqueeze(0).repeat(3, 1, 1) if self.labeled: seg nib.load(row.seg_path).get_fdata().astype(np.int16) seg cv2.resize(seg, self.size, interpolationcv2.INTER_NEAREST) seg torch.from_numpy(seg).long() cls torch.tensor(row.cls_label, dtypetorch.long) return x, seg, cls else: return x这段代码处理了三个关键点。第一窗宽窗位裁剪这是CT图像和自然图像最大的区别不做这一层处理ResNet50的预训练权重基本失效。第二单通道复制成三通道输入因为torchvision的resnet50第一个卷积层接收3通道。第三分割标注用最近邻插值resize避免类别混淆。对无标注目标域样本只返回x后续训练逻辑会把它单独送进一致性分支。3.4 模型定义ExpertNet Resnet50最小实现import torch.nn as nn import torchvision.models as models class ExpertNet(nn.Module): def __init__(self, num_classes21, pretrainedTrue): super().__init__() base models.resnet50(pretrainedpretrained) # 去掉最后的全局池化和全连接层保留1/32特征图 self.encoder nn.Sequential(*list(base.children())[:-2]) # 分割专家轻量级上采样头后续可换成UNet分支 self.seg_expert nn.Sequential( nn.Conv2d(2048, 256, 1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.Upsample(scale_factor8, modebilinear, align_cornersFalse), nn.Conv2d(256, num_classes, 1) ) # 分类专家全局平均池化后做二分类 self.cls_expert nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(2048, 256), nn.ReLU(), nn.Dropout(0.3), nn.Linear(256, 1) ) # 门控输出每个任务的权重 self.gate nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(2048, 2) ) def forward(self, x, taskseg): feats self.encoder(x) if task seg: return self.seg_expert(feats) elif task cls: return self.cls_expert(feats).squeeze(-1) else: return feats def compute_gate(self, x): feats self.encoder(x) return torch.softmax(self.gate(feats), dim-1)num_classes21对应CT多器官分割你可以按数据集改成肝脏二分类或其他任务数。pretrainedTrue能明显加快收敛但要注意encoder末尾的BatchNorm统计量会在训练初期重新学习第一个epoch建议用较低学习率做预热。Gate现在只输出两个任务的权重如果后续加入重建任务把Linear(2048, 2)改成3即可。4. 训练与无监督自适应损失函数、对抗判别器与参数调节4.1 多任务损失如何配平不确定性加权与动态权重把分割、分类、一致性三个loss直接相加是最容易踩的坑。Dice Loss通常落在0.1到1之间分类BCE约0.5到2一致性MSE可能到10以上如果不配平训练会朝向MSE的方向跑偏。常见做法是用不确定性加权给每个任务分配一个可学习的log方差seg_var torch.zeros(1, requires_gradTrue) cls_var torch.zeros(1, requires_gradTrue) cons_var torch.zeros(1, requires_gradTrue) total_loss seg_loss / (2 * seg_var.exp()) 0.5 * seg_var \ cls_loss / (2 * cls_var.exp()) 0.5 * cls_var \ cons_loss / (2 * cons_var.exp()) 0.5 * cons_var把这三个变量放进优化器参数组里一起更新即可。逻辑是如果某个任务噪声大它的log variance会自动变大权重就会变小从而不影响其他任务收敛。对于医疗图像分割任务的Dice loss通常是最可靠的你可以给它额外乘一个1.2的缩放系数让分割语义在整个训练里占主导。4.2 对抗域自适应判别器怎么接在特征图上对抗自适应需要一个域判别器。我把它定义成一个小卷积网络输入是ResNet50最后一层输出的2048通道特征图输出每个空间位置来自哪个域class DomainDiscriminator(nn.Module): def __init__(self, in_channels2048): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_channels, 512, 3, padding1), nn.ReLU(), nn.Conv2d(512, 512, 3, padding1), nn.ReLU(), nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(512, 1) ) def forward(self, feats): return self.conv(feats)训练时常见做法是交替更新判别器和ExpertNet。先在一个step里把源域和目标域的encoder特征都算出来用BCEWithLogitsLoss训练判别器再在下一个step固定判别器更新的目标函数变成让Encoder最大化判别器loss这就是对抗方向。一个容易忽略的陷阱是判别器训练过快会导致Encoder被推进一个“特征塌缩”的解虽然域分不出但解剖结构也丢了。我一般把判别器学习率设为Encoder的十分之一或者每两个step才更新一次判别器。4.3 无监督一致性正则化EMA teacher仅靠对抗对齐目标域的高频纹理和边缘信息不一定保得住。一致性正则化需要一个teacher网络其参数由student做指数滑动平均ema_decay 0.999 def update_teacher(model, teacher, decay): with torch.no_grad(): for p_t, p_s in zip(teacher.parameters(), model.parameters()): p_t.data.mul_(decay).add_(p_s.data, alpha1 - decay)目标域样本分别做弱增强和强增强过teacher和student然后拉近它们的预测分布。对医学图像常见做法是在分割输出上算一致性而不是中间特征。因为分割输出维度更低且和下游任务直接相关。EMA系数太大会让teacher学得太慢太小又会让teacher和student没有区别稳妥区间是0.99到0.999。提示teacher网络的前向要用no_grad包裹否则梯度会穿过teacher导致ema机制失效。如果显存够可以在teacher前向时保持train模式让BatchNorm统计量适配目标域。4.4 训练循环示意 参数表下面给出去掉断点续训和日志记录的核心循环展示源域监督与目标域无监督损失怎么合并for src_batch, tgt_batch in zip(src_loader, tgt_loader): x_src, seg_src, cls_src src_batch x_tgt_w weak_aug(tgt_batch[0]) x_tgt_s strong_aug(tgt_batch[0]) feat_src model.encoder(x_src) seg_pred model.seg_expert(feat_src) cls_pred model.cls_expert(feat_src) seg_loss dice_loss(seg_pred, seg_src) ce_loss(seg_pred, seg_src) cls_loss bce_with_logits(cls_pred, cls_src.float()) with torch.no_grad(): feat_tgt_teacher teacher.encoder(x_tgt_w) seg_tgt_teacher teacher.seg_expert(feat_tgt_teacher) feat_tgt_student model.encoder(x_tgt_s) seg_tgt_student model.seg_expert(feat_tgt_student) cons_loss torch.nn.functional.mse_loss( seg_tgt_student, seg_tgt_teacher.detach() ) if step % 2 0: d_src discriminator(feat_src.detach()) d_tgt discriminator(feat_tgt_student.detach()) disc_loss bce(d_src, torch.ones_like(d_src)) \ bce(d_tgt, torch.zeros_like(d_tgt)) disc_loss.backward() disc_opt.step() disc_opt.zero_grad() total_loss weighted_combine(seg_loss, cls_loss, cons_loss) total_loss.backward() opt.step() update_teacher(model, teacher, ema_decay)关键点是detach()的位置。判别器更新时Encoder特征要detach否则梯度会通过判别器回传到Encoder造成不稳定一致性loss里teacher输出要detach否则teacher就不是“慢速”模型。下面的参数表可以作为起点不用一上来就仿照论文设置参数推荐取值调整方向batch size8 8显存不足先降目标域初始学习率模型1e-4判别器1e-5源域loss不降就降为3e-5强增强强度空间变换灰度变换一致性loss震荡大就调弱ema_decay0.999目标域分布漂移大用0.99一致性loss权重0.1到0.5分类任务并重取0.1分割为主取0.5判别器更新频率每2个step判别器太强改每4个step另一个实用技巧是两阶段训练。第一阶段只用源域训练ExpertNet ResNet50的监督部分直到分割Dice在验证集上不再上升第二阶段再打开目标域无监督损失并把学习率降到原来的五分之一。这样可以避免多任务学习网络在早期被无监督噪声带偏。5. 验证与进阶从Dice指标到少样本站点迁移5.1 用Dice和Hausdorff距离评估医疗图像分割医疗图像分割不能用准确率评估类别不平衡会让准确率虚高。我一般用Monai提供的指标from monai.metrics import DiceMetric, HausdorffDistanceMetric dice_metric DiceMetric(include_backgroundFalse, reductionmean) hausdorff_metric HausdorffDistanceMetric( include_backgroundFalse, percentile95 ) dice_metric(y_predpreds, ylabels) hausdorff_metric(y_predpreds, ylabels) dice_score dice_metric.aggregate().item() hd95_score hausdorff_metric.aggregate().item()Dice大于0.8可以接受但Dice稳定不代表边界质量好所以必须同时看HD95。目标域上的HD95如果比源域高出一大截说明模型的边缘定位能力还没有被自适应模块拉齐这时优先调整一致性loss的权重其次再看判别器更新频率。5.2 少样本新站点迁移的扩展技巧无监督自适应在真实医疗场景里经常面对的不是一个固定目标域而是不断新增的站点。我通常训练完成后固定encoder只微调每个站点的BatchNorm参数和门控模块。因为ResNet50的底层特征具有通用性跨站点差异主要集中在BatchNorm统计量和顶层语义上。BatchNorm校准可以用下面这个方法不需要任何标签model.train() with torch.no_grad(): for x_tgt in tgt_loader: _ model.encoder(x_tgt)这一轮前向会重新计算目标域样本的均值和方差替换掉预训练累计值。模型权重不更新但BN层的running statistics已经被目标域数据校准。该方法在跨站点推理时通常能带来一到三个百分点的Dice提升而且几乎不过拟合。最后建议把目标域专家输出叠加在原图上做可视化并保存成多帧图像。只看Dice曲线很难定位空洞和过分割逐帧翻看目标域样本能最快发现自适应失败的原因。给每个站点建一个adapt_log文件夹把Dice、HD95和各任务loss按step记录成json后续做新站点迁移时直接参照这套模板。本文还有配套的精品资源点击获取