UNet图像分割学习笔记:从基础架构到Attention U-Net注意力机制进阶
这是一篇从零梳理 UNet 的学习笔记。前半部分讲清基础架构、数据流向与我改写合一的源码注释;中间进阶到 Attention U-Net,结合论文、官方注意力源码、以及我自己简化改写的 AttentionGate2D;后半部分记录完整的 Baseline vs Attention 对照实验链——从 3-Seed 初步验证、train.py 规范化、30 Epoch 收敛检查、5-Fold 交叉验证,到原作者 Grid Attention 三模型对照,以及面向泡沫实例分割的下一步方向。所有手绘框架图、改写代码与中文注释均为我在学习过程中亲手整理。
前言
图像分割是计算机视觉里的核心任务——不仅要识别图里有什么,还要精确标出它在哪些像素位置。在医学影像、细胞壁提取这类样本少、目标边界细、背景干扰强的场景里,U-Net 凭借一个优雅的 U 型结构和”跳跃连接”长期封神。
这篇笔记按我自己的学习路径展开:
- 先补齐卷积/池化/上采样这些前置概念
- 拆解 U-Net 的架构与逐层数据流
- 把官方
milesial/Pytorch-UNet分散在多文件里的网络定义改写合一成单文件,并逐行加注释 - 跑通 ISBI 细胞壁分割,设计 A–K 改进实验
- 进阶到 Attention U-Net:论文动机 → 注意力门控原理 → 官方源码 → 我的简化改写
- 做 Baseline vs Attention 的完整对照实验链:3-Seed 初步验证 →
train.py规范化 → 30 Epoch 收敛检查 → 5-Fold 交叉验证 → 原作者 Grid Attention 三模型对照
一、计算机视觉基础铺垫
在碰 U-Net 之前,先把几个底层概念钉死。
1.1 图片就是数字矩阵
人看到的是具体画面,计算机看到的是数字矩阵。一张 1920×1080 的图,就是 1920 列 × 1080 行个像素,每个像素是一组数字。
- 像素:矩阵里的一个格子
- 通道数:一个像素里有几个数字。灰度图是 1 通道,彩色 RGB 是 3 通道
- 分辨率:矩阵的行列数,比如 1920×1080
1.2 卷积:用一个窗口扫描局部
卷积是一个覆盖矩阵一部分的滑动窗口,用来观察局部特征。窗口里装着卷积核。
一次卷积运算 = 把卷积核与图片对应位置逐元素相乘,再全部相加,得到一个数。
1 | 图片的一块: 卷积核: 相乘得: 结果: |
如果图片是 5×5、卷积核是 3×3,卷积核就在图片上不断滑动,每到一个位置做一次”对应相乘再相加”。
卷积核为什么能找到边缘?
用一组 1 0 -1 的卷积核扫描一张左暗右亮的图:
1 | 数据矩阵 卷积核 |
相乘相加得到一个很大的负数(-570),说明左右差异巨大,这里很可能存在边缘。不同卷积核学会检测边缘、纹理、形状等不同特征。
1.3 三个关键操作
| 操作 | 作用 | 我的理解 |
|---|---|---|
| ReLU | 负数变 0,正数不变 | 引入非线性,让网络能学复杂特征 |
| Max Pool 2×2 | 窗口内只保留最大值 | 下采样:缩小尺寸、保留最显著特征、扩大感受野 |
| 上采样 | 把小特征图重新放大 | 恢复分辨率;但找不回 Max Pool 丢掉的细节,所以要靠跳跃连接补 |
二、UNet 基础
2.1 核心思想:U 型 + 跳跃连接
U-Net 是用于图像分割的卷积神经网络,因结构呈 U 型得名。三条主线:
- 编码器(左半,下采样):逐步提取特征、降低空间分辨率,负责”理解这是什么”
- 解码器(右半,上采样):逐步恢复分辨率、生成分割结果,负责”恢复在哪里”
- 跳跃连接(中间横线):把左边保存的细节补给右边——这是 U-Net 最关键的创新
左边负责”理解是什么”,右边负责”恢复在哪里”,中间的跳跃连接把左边保存的细节补给右边。
2.2 优缺点与适用场景
UNet 的优缺点
适合:小样本、类别不平衡、需要保留细节的任务
优点:强大的分割能力、少样本即可学习、结构可扩展
缺点:计算资源需求较高、对高频类别仍有数据不平衡倾向、大尺寸图像处理吃力
应用:医学图像、遥感图像、自然场景图像、工业图像
2.3 网络架构图与各组件含义
对照图里每个标注:
| 图中标注 | 含义 |
|---|---|
conv 3x3, ReLU |
3×3 卷积核 + ReLU 激活 |
copy and crop |
复制并裁剪:把左边特征复制给右边,裁剪到尺寸对齐后拼接 |
max pool 2x2 |
2×2 最大池化,步长推断为 2(不重叠) |
up-conv 2x2 |
转置卷积(反卷积),一种可学习的上采样 |
conv 1x1 |
1×1 卷积,把 64 通道压成 2 通道做最终分类 |
| 顶部数字 | 通道数(1 = 灰度图,通道数 = 每像素包含的信息数) |
copy and crop 的本质:复制的是左边网络中间产生的数据;拼接时高宽对齐、通道数相加。把前面更精确的位置信息保存下来给后面用。
2.4 数据流向:论文原版的逐层尺寸流
我手绘的那张”加了注意力机制的完整代码”流程图,对应第五章 5.6 节的最终代码,放到那边和代码一起讲。下面先按论文原版尺寸走基础 U-Net 的数据流。
注意:下面这套尺寸是论文原版(padding=0 的 valid 卷积),所以每卷一次尺寸都会变小(572→570→568)。后面我改写合一的代码改成了 padding=1 的 same 卷积,输入输出尺寸一致,这是两套不同的实现,别混了。
左半部分(编码器):
1 | 572×572×1 →(conv×2)→ 568×568×64 |
右半部分(解码器): 通过 up-conv 2×2 逐层放大,每一步先上采样、再 copy and crop 拼接、再两次卷积,最后用 1×1 卷积把 64 通道压成 2 通道:
1 | 28×28×1024 →(up-conv)→ 56×56×512 →(concat x8)→ 56×56×1024 →(conv×2)→ 52×52×512 |
2.5 四个关键问题
我在学习时反复琢磨过这几个问题,这里把答案固化下来。
Q1:为什么最开始要连续两次卷积之后才 max pool?
先连续做两次卷积,把当前尺寸下的信息多提取一些,再进行 Max Pool。次数可以调整(一次或三次都行),看需要。
Q2:为什么高和宽越来越小,通道数反而越来越多?
高和宽表示”有多少个位置”,通道表示”这个位置有多少个信息”。尺寸缩小是丢掉空间位置精度,通道增多是每个位置的信息变得更精准、更详细。这是一种用空间分辨率换特征丰富度的权衡。
Q3:为什么缩放到 28×28×1024 就停?
寻找一个平衡点,便于后面 up 和拼接。继续往下深入会丢失更多数据,层数是人为决定的。
Q4:卷积核里的数字怎么来的?
刚开始建立 U-Net 时,卷积核里的数字由程序按一定规则随机初始化,一开始没有特殊意义;之后通过训练不断调整,最终让不同卷积核学会提取边缘、纹理、形状等有用信息。
三、代码实现:把官方 unet 文件夹改写合一
3.1 改写思路
官方 milesial/Pytorch-UNet 把网络拆成了多个文件(unet_parts.py 放 DoubleConv/Down/Up/OutConv,unet_model.py 放 UNet 主体)。为了学习时一眼看全,我把它改写合并到单文件 unet.py,并逐行加了中文注释。
与论文原版的关键差异:我改写的版本用 padding=1 的 same 卷积,所以输入输出尺寸一致(例如输入 1×572×572,输出尺寸不变);论文原版是 padding=0 的 valid 卷积,每卷一次尺寸缩小。代码里所有中文注释均为我在改写时添加的理解,英文注释为原版所有。
二维卷积尺寸公式(我在文件开头记的):
其中 K 是卷积核大小、P 是 padding、S 是步长、D 是空洞率(默认 1)。padding=1, kernel=3, stride=1 时 $H{out}=H{in}$,这就是 same 卷积。
3.2 DoubleConv:两次”卷积→BN→ReLU”
1 | class DoubleConv(nn.Module): |
我在注释里强调的几个点
bias=False:卷积后接了 BN,BN 本身有偏置(偏移参数),所以卷积的 bias 可省去,减少冗余参数BatchNorm2d:我的注释写明它”对每个批次的特征图做均值方差归一化”,作用是加速收敛 + 防梯度异常 + 正则化inplace=True:ReLU 原地操作,省一点显存
3.3 Down:先池化再卷积
1 | class Down(nn.Module): |
MaxPool2d(2) 即 2×2 窗口、步长 2,高宽减半、通道不变;紧接 DoubleConv 提取特征并升维。
3.4 Up:上采样 + 对齐 + 拼接 + 卷积
这是 U-Net 最巧妙的一层,我在注释里花了不少笔墨。
1 | class Up(nn.Module): |
我标注的三个关键理解
- 两种上采样方式:
bilinear=True用双线性插值(无可学习参数,省显存,官方会同时减半通道数补偿);bilinear=False用转置卷积ConvTranspose2d(可学习的放大)。我的代码默认走转置卷积分支 F.pad补齐:上采样后 x1 与跳跃特征 x2 可能差 1 像素,用 padding 对齐,避免拼接错位。注释里我贴了官方解决 padding 问题的两个 commit 链接torch.cat([x2, x1], dim=1):沿通道维拼接,高宽相同、通道相加——这就是”copy and crop”在代码里的真实模样
3.5 OutConv:1×1 卷积收尾
1 | class OutConv(nn.Module): |
1×1 卷积把 64 通道压成 n_classes 通道(分割任务里通常是类别数),不改变高宽。
3.6 UNet 主体与前向传播
1 | class UNet(nn.Module): |
前向传播里最容易踩的坑:不能用同一个变量名贯穿全程!因为 U-Net 需要 copy,下采样前必须用新变量(x1、x2、x3、x4)保存中间输出,供后面跳跃连接使用。如果全写成 x = ...,后面就取不到中间层特征了。
我在注释里特意写了这段理解:
Down 是处理规则,
self.down1是这套规则的一个具体实例,x1是被处理的数据,x2是处理结果。这里不用变量重名是因为后面要跳跃连接。
完整改写合一版 unet.py(含我的全部中文注释)
1 | """ Full U-Net network in a single file """ |
四、ISBI 细胞壁分割实战
跑通基础流程后,我做了一整套对比实验。项目目录 F:\Pytorch-UNet-master,数据是 ISBI 灰度细胞图,所以模型输入通道为 1。
4.1 标准训练—预测—评估流程
1 | cd "F:\Pytorch-UNet-master" |
几个我踩过坑后固化的经验:
通道数与 scale 必须前后一致
- 通道数:灰度细胞图
n_channels=1,彩色 RGB 图n_channels=3。train.py和predict.py里的n_channels必须一致,predict.py读取图片建议.convert('L') - scale:训练用
--scale 1,预测和评估也必须--scale 1;训练--scale 0.5则全程 0.5。细胞壁是细边界任务,推荐先--scale 1 - W&B 提示
wandb: W&B syncing is set to offline不是错误,是正常的
4.2 评估指标:Dice 与 IoU
1 | intersection = np.logical_and(pred, true).sum() |
- Dice:预测与真实重叠的两倍 / 两者面积和,衡量重合度
- IoU:交集 / 并集,分割任务最常用指标
我写了 check_score_all.py 批量评估,输出每张图 + 平均 Dice/IoU + CSV 表格;又写了 compare_experiments.py 自动汇总所有实验并按平均 Dice 排序。
4.3 A–K 改进实验脉络
只改一个变量、每次存 checkpoint、用同一套评估脚本、不只看一张图——这是我做实验的核心原则。整体路线:
- 第一阶段·基础超参数:A baseline → B 加 epochs → C 调学习率 → D 改 batch size
- 第二阶段·训练策略:E 验证集比例 → G 开 AMP → H 双线性 vs 转置卷积
- 第三阶段·数据层面:I 数据增强(30 张 → 180 张)
- 第四阶段·损失与后处理:J 调 Dice/CE 权重 → K 预测后处理(去小噪点、填小洞)
判断实验是否改进不要只看 loss,也不要只看一张预测图。优先看:平均 Dice 是否提高、平均 IoU 是否提高、预测边界是否更连续、是否减少了全黑/全白/噪点/小洞。
数据增强是其中收益最稳的改进之一,把 30 张通过翻转和旋转扩到 180 张;后处理脚本 postprocess_mask.py 用连通域分析去小噪点、填小洞,不改模型也能涨点。
五、Attention U-Net 进阶
基础 U-Net 跑通后,我接着研究了 Attention U-Net。参考资料是论文 Attention U-Net: Learning Where to Look for the Pancreas(Oktay et al., MIDL 2018)和官方实现 ozan-oktay/Attention-Gated-Networks。
5.1 动机:跳跃连接的副作用
U-Net 的跳跃连接把编码器特征原封不动地拼给解码器。问题在于:编码器浅层特征里既有目标,也有大量背景噪声,尤其当目标小、背景复杂时(比如胰腺在 CT 里只占很小一块),这些噪声会被一路带到输出,冲淡边界精度。
Attention Gate 的目标:让网络自动学会”该看哪里”,在跳跃连接送出特征之前先做一次筛选,抑制背景区域、突出目标区域。
5.2 AG 插在哪:原方法 vs 改进方法
上面这张是我画的对比图:
原方法:上采样后的 x5 直接与编码器特征 x4 做 concat。
改进方法:先用 Attention Gate 对 x4 做加权筛选,得到 x4',再与上采样后的 x5 做 concat。门控信号 g 就是更深层特征 x5,它告诉门”目标大概在哪、该关注哪些区域”。
一句话总结图中底部的三行:
1 | x4 → Attention Gate → x4' |
5.3 AG 原理详解
上面这张原理图(也是本文封面)是我结合论文和 AI 辅助理解后画的。按数据流向逐步拆解:
输入两路信号:
- 门控信号
g:来自更深层(decoder 高层)的特征,语义强、上下文感知好。图里我标的是来自 decoder 路线、上采样后的512×32×32 - 跳跃特征
x^l(即前面的x4):来自编码器对应层,分辨率较高、细节多,是要被”筛选”的对象
两路 1×1 卷积变换对齐通道:
W_g(1×1 卷积):把g变换到D_g个中间通道W_x/θ(1×1 卷积):把x^l变换到D_ψ个中间通道
变换后两者通道数一致,才能逐元素相加。
对齐空间尺寸 → 相加 → ReLU:
g 的空间分辨率通常比 x^l 小(深层下采样过),所以要先上采样/下采样对齐,然后:
再 1×1 卷积 ψ + Sigmoid → 注意力系数 α:
ψ 是把多通道压成 1 通道的 1×1 卷积,Sigmoid 把每个位置压到 [0,1],这就是逐像素的注意力权重。
α 与原特征相乘 → 筛选后特征:
再加一个输出变换 W(1×1 卷积 + BN)做一次精修。注意力系数 α 大的位置被保留、小的位置被抑制,背景噪声就此被压低。
Attention Gate 完整公式
- $\sigma_1$ 是 ReLU,$\sigma_2$ 是 Sigmoid
- $W_x, W_g, \psi$ 都是 1×1 卷积(2D)或 1×1×1(3D)
- 最终输出 $\hat{x}$ 与输入 $x^l$ 形状一致,可直接替换原跳跃连接的特征
5.4 官方源码对照:grid_attention_layer.py
我精读了 ozan-oktay/Attention-Gated-Networks 里 models/layers/grid_attention_layer.py,并逐行加了中文注释。它定义了支持 2D/3D、支持三种模式(concatenation / concatenation_debug / concatenation_residual)的 _GridAttentionBlockND。
核心组件与图中的对应关系(我的注释):
1 | # Output transform:官方额外的输出变换层,对应图里的 W |
_concatenation 前向计算(带我的注释):
1 | def _concatenation(self, x, g): |
三种模式有何不同
concatenation:ReLU 后 Sigmoid,最常用concatenation_debug:把第一个激活换成 softplusconcatenation_residual:用 softmax 代替 sigmoid,且在通道维展平后做归一化
另外 _GridAttentionBlockND_TORR 是一个更灵活的变体,多了 concatenation_softmax/concatenation_mean/concatenation_range_normalise 等归一化方式,并可开关 W/phi/theta/psi 各模块做消融。
5.5 我的简化改写:AttentionGate2D
官方实现通用性强但偏重(支持 3D、子采样、Kaiming 初始化依赖、多模式分支)。我在 F:\Pytorch-UNet-master - 副本\unet.py 里把它简化改写成只服务 2D U-Net 的 AttentionGate2D,并保留了自己的关键改动说明。
1 | class AttentionGate2D(nn.Module): |
这是我最想标出来的改动:论文和官方实现是把 x(跳跃特征)下采样到 g 的尺寸做相加;我的改写反过来,把 phi_g(门控信号)上采样到 x 的尺寸再相加。代价是计算量更大,但换来的好处是在更大的分辨率上得到更精确的注意力系数。这是一个有取舍的改动,效果需要结合具体任务判断——我在注释里如实写明了这一点。
相比官方版本,我的简化版还做了几处裁剪:
- 去掉
theta的子采样卷积,统一用 1×1 卷积,inter_channels默认取in_channels // 2 - 只保留
concatenation(ReLU+Sigmoid)这一条主路径,去掉 debug/residual/softmax 等分支 - 不依赖
models.networks_other.init_weights,初始化交给 PyTorch 默认
5.6 注入 UNet 的完整前向传播
把 AttentionGate2D 插到跳跃连接前,就得到了 Attention U-Net。下面这张是我对应最终写好的完整代码画的流程图,把编码器、瓶颈层、三个注意力门、解码器拼接都串在了一起:
代码里我加了三个注意力门 att4 / att3 / att2,分别处理对应层的跳跃特征:
1 | class UNet(nn.Module): |
下面按图分小节把完整数据流走一遍,对应上面 forward 的每一行。
编码器:逐层下采样,保留中间特征
从 x1 = self.inc(x) 开始,输入图像先经一层 DoubleConv 得到 x1(64 通道),之后每过一个 Down 就 MaxPool 减半 + DoubleConv:
x1 = inc(x):[64, H, W]x2 = down1(x1):[128, H/2, W/2]x3 = down2(x2):[256, H/4, W/4]x4 = down3(x3):[512, H/8, W/8]x5 = down4(x4):[1024, H/16, W/16] ← 瓶颈层
关键点:每一层的中间输出 x1~x4 都要单独留变量保存,因为它们后面要进跳跃连接。这就是我前面强调的”前向传播变量不能重名”的原因。
瓶颈层 x5:最深语义 + 第一个门控信号
x5 空间最小、通道最多(1024),语义最抽象。它身兼两职:既要进 up1 做上采样,又要作为第一个注意力门 att4 的门控信号 g,去指导对 x4 的筛选。
解码第一段:att4 用 x5 筛选 x4,再 up1
1 | x4_att = self.att4(x4, x5) # g = x5(瓶颈),筛选 x4 |
att4(x4, x5) 里,被筛选的是跳跃特征 x4,门控信号 g = x5。AG 用最深的 x5 告诉网络”目标大概在哪”,给 x4 每个位置打注意力权重,背景被压低、目标被突出,得到 x4_att。然后 up1 把 x5 上采样到 x4 的分辨率,与 x4_att 沿通道维拼接,再 DoubleConv,输出 x(512 通道、H/8 分辨率)。
解码第二段:att3 用 up1 输出筛选 x3,再 up2
1 | x3_att = self.att3(x3, x) # g = up1 的输出,筛选 x3 |
这里门控信号 g 换成了上一段解码器的输出 x(不再是 x5)。x3 是 256 通道、H/4 分辨率,g 是 512 通道、H/8 分辨率——在我的 AttentionGate2D 改写里,AG 会把 phi_g 上采样到 x3 的分辨率再相加,也就是那张原理图里”调整尺寸”这一步。up2 再上采样 + 拼接 + DoubleConv,得到 256 通道、H/4 的 x。
解码第三段:att2 用 up2 输出筛选 x2,再 up3
1 | x2_att = self.att2(x2, x) # g = up2 的输出,筛选 x2 |
同样的模式:门控 g 用更深一段(up2 输出),筛选跳跃特征 x2(128 通道、H/2)。up3 上采样拼接后得到 128 通道、H/2 的 x。
最浅层 + 输出:x1 不加注意力
1 | x = self.up4(x, x1) # x1 不经过 AG |
最浅层 x1(64 通道、原分辨率)不接注意力门,直接和 up4 上采样结果拼接。原因是 x1 已经是最细的位置细节,再筛反而可能丢边界信息。最后 outc 用 1×1 卷积把 64 通道压成 n_classes 通道,输出分割结果。
串起来看门控信号 g 的来源规律:att4 用瓶颈 x5、att3 用 up1 输出、att2 用 up2 输出——总是用”更深一段”的解码输出去指导”更浅一层”的跳跃特征。越深层语义越强,用来筛浅层细节;越浅层细节越多,被筛后补回边界。这就是”理解是什么”与”恢复在哪里”在注意力加持下的二次协作。
六、初步对照实验:Attention 真的比 Baseline 好吗?
把 Attention Gate 加进 U-Net 之后,下一步自然是问:它到底有没有用?我用一轮固定数据划分 + 多随机种子的对照实验来回答。这部分是初步验证,目的是看趋势,不是最终性能评测。
6.1 实验设计:固定划分 + 多种子
两种模型:Baseline U-Net(原始)和 Attention U-Net(带 Attention Gate)。为了保证对照公平,关键参数完全一致:
| 项目 | 设置 |
|---|---|
| 数据总量 | 30 张 |
| 训练集 / 验证集 | 27 / 3(90%/10%) |
| Epoch | 5 |
| Batch size | 1 |
| Learning rate | 1e-5 |
| Image scale | 0.5 |
| 上采样 | 转置卷积 |
| 输入通道 / 输出类别 | 1 / 2 |
| 数据划分种子 | SPLIT_SEED = 2026 |
| 训练种子 | 1 / 2 / 3 |
设计核心是两层种子分离:
1 | SPLIT_SEED = 2026 |
这样 seed=1 的 Baseline 只和 seed=1 的 Attention 比,seed=2 和 seed=2 比。如果某次 Attention 赢了,可以排除”换了验证集”的干扰,判断到底是结构真的好,还是某次随机初始化碰巧好。
6.2 train.py 的关键改动
attention 版的 train.py 相比原版加了种子控制,核心改动:
1 | # 新加:固定随机种子 |
跑 3 个 seed 时,把 set_seed(3) 和 train_generator.manual_seed(3) 里的 3 换成 1、2、3 分别训练三次。from unet import UNet 导入的是带 AttentionGate2D 的版本。这里 cudnn.deterministic=True、benchmark=False 是为了尽量让同一种子下结果可复现。
6.3 三组对照结果
每个 seed 下取训练过程中的最高验证 Dice:
| Seed | Baseline | Attention | 提升(百分点) | 本组 |
|---|---|---|---|---|
| 1 | 0.931129 | 0.933897 | +0.277 | Attention 胜 |
| 2 | 0.917950 | 0.935131 | +1.718 | Attention 胜 |
| 3 | 0.925838 | 0.930023 | +0.419 | Attention 胜 |
| 平均 | 0.924973 | 0.933017 | +0.804 | Attention 平均更高 |
三个 seed 下 Attention 全部胜出。标准差上 Baseline ≈ 0.0066、Attention ≈ 0.0027,Attention 的三次结果更集中。
注意:Dice 0.933 不等于”93.3% 像素分类正确率”。 Dice 衡量的是预测前景区域与真实前景区域的重叠程度(2TP/(2TP+FP+FN)),不是分类准确率。
6.4 这些数字分别代表什么
Seed(随机种子) 影响三件事:模型参数初始化、训练中的随机过程、DataLoader 的 shuffle 顺序。改 seed=1→2→3 等于让模型从另一套随机条件重新训练。而 SPLIT_SEED=2026 始终不变,所以三个 seed 的训练集和验证集身份一致,改的是训练随机性而不是换验证图。
Validation Dice 是验证集上的 Dice 系数,衡量预测分割区域与真实标注的重叠程度。
Best Validation Dice 是一次训练过程中出现过的最高验证 Dice。训练中验证指标会波动(比如 0.925→0.930→0.934→0.928→0.919),最后一次不一定最好,所以取过程中的最高值。正式实验应该保存 best_model.pth(验证指标刷新最高时存模型),而不是默认 checkpoint_epoch5.pth。
平均值回答”在不同随机初始化下总体怎么样”,比单次训练可靠。标准差粗略反映不同种子下的波动大小—目前 Attention 的标准差更小,说明三次结果更集中,但只有 3 个 seed,只能称为”稳定性上的正向迹象”。
6.5 能得到什么结论
可以比较稳妥地写:
在固定数据划分、3 个训练种子的对照实验中,Attention U-Net 的最佳验证 Dice 在 3 次实验里均高于 Baseline U-Net。Baseline 平均 0.92497、Attention 平均 0.93302,平均提高约 0.00805 Dice(约 0.80 个百分点),且 seed 间波动更小。
简化成一句话:3/3 个 seed Attention 胜,平均提升约 +0.80 个百分点。当前实验支持”Attention Gate 对这个数据集有正向作用的趋势”。
6.6 还不能下什么结论
不能直接写”Attention 已被充分证明具有显著提升”,原因有三:
三个限制
① 验证集只有 3 张。30 张按 90%/10% 划分,验证只有 3 张。某一张困难图的表现变化就能明显影响平均 Dice。这是当前最大限制,不是 seed 数量。
② 只训练 5 个 epoch。5 epoch 适合检查代码能跑、做结构筛选、判断有无正向趋势,但不一定充分收敛。所以叫”初步结构对照实验”而非最终性能实验。
③ 一个 epoch 内验证多次。现有 train.py 在一个 epoch 内多次计算 Validation Dice,所以”Best Val Dice”实际是从一次训练里的多次验证中取最高。正式实验建议改成每 epoch 验证一次、刷新最高就存 best_model.pth,方便画训练曲线和比较。
上述三个限制,在第七章的 train.py 规范化、第八章的 30 Epoch 对照、第九章的 5-Fold 中已逐步解决。第六章的结论需要在后续章节中修正和收紧。
6.7 初步实验后的方向预告
这轮 3-Seed 对照验证了 Attention 的初步趋势,但下一阶段的最终目标是泡沫图像分割(每个泡沫单独识别、编号)。对于这种任务,不能只看 Dice——两个相邻泡沫被粘成一块时,Semantic Dice 仍然可能很高,但泡沫计数和实例分离已经错了。
泡沫专用指标(Bubble Count Error、Instance F1、Merge/Split Error)和后续路线(连通域 / Watershed / Boundary / 实例分割)在第十一章展开。下面先把实验流程补全:规范 train.py → 30 Epoch 收敛检查 → 5-Fold → 原作者 Attention 三模型对照。
七、Train.py 规范化:让对照实验可重复、可解释
3 组 Seed 初步对照跑完以后,我没有继续盲目加 Seed,也没有立刻改 unet.py,而是先把 train.py 规范化。原因很简单:旧版训练流程本身就不够严谨,如果带着”一个 Epoch 内验证多次”的旧逻辑去下结论,后面的数字都不可信。
7.1 两个项目的关系
Baseline 和 Attention 的工程结构始终遵循同一条原则:
1 | F:\Pytorch-UNet-master\ |
只让网络结构不同,其余全部相同——数据、Epoch、Seed、Optimizer、Loss、Scheduler、Validation 方式、train.py 代码,Baseline 和 Attention 必须完全一致。这是后续所有对照实验的底线。
7.2 第一轮改动:改了什么、为什么改
| 修改项 | 旧版 | 新版 | 原因 |
|---|---|---|---|
| 默认 Epoch | 5 | 30 | 5 Epoch 只适合快速筛结构,不能判断收敛后的最终性能 |
| Validation 频率 | 一个 Epoch 内多次 | 每 Epoch 一次 | 旧逻辑相当于在小验证集上反复”看答案” |
| 模型保存 | 每 Epoch 存 checkpoint_epochN.pth |
只存 best_model.pth |
取验证集表现最好的模型,而不是最后一个 Epoch |
| Seed 控制 | 手动改源码里的数字 | --seed 命令行参数 |
切换 Seed 不用进文件改多处 |
验证集 drop_last |
True |
False |
验证阶段不能丢掉最后几个样本 |
| Epoch 日志 | 只有 batch loss | Train Loss + Val Dice | 便于看收敛速度和过拟合 |
| Scheduler | 中途多次验证时更新 | 每 Epoch 验证后更新 | patience=5 现在表示”连续 5 个完整 Epoch 没改善” |
旧版"一个 Epoch 内验证多次"的问题
旧代码里有类似逻辑:
1 | division_step = n_train // (5 * batch_size) |
这意味着 Epoch 1 内部会:训练几张 → 验证 → 再训练 → 验证 → …… 5 Epoch 实验实际上会产生大量 Validation Dice。如果从这些数字里直接挑最大值当 Best Dice,相当于在同一 3 张验证图上反复挑选最优时刻,不够规范。
7.3 新版训练核心代码
Seed 的完整传递路线:
1 | python train.py --seed 1 |
每个 Epoch 结束后的验证与保存逻辑:
1 | # 每个 epoch 训练完成后,只验证一次 |
set_seed(args.seed) 必须在 model = UNet(...) 之前执行,否则模型随机初始化不受 Seed 控制。当前代码顺序已经正确:get_args() → set_seed(args.seed) → 创建模型。
规范化后的训练命令:
1 | cd "F:\Pytorch-UNet-master" |
八、30 Epoch 收敛对照:充分训练后 Attention 还领先吗?
train.py 规范化完成后,我没有立刻做 5-Fold,而是先用固定 27/3 划分 + 30 Epoch + Seed=1 跑一轮,检验第六章 5 Epoch 时看到的 Attention 优势是否在充分训练后仍然成立。
8.1 实验设置
与 3-Seed 实验相同的数据划分(SPLIT_SEED=2026,27 训练 / 3 验证),但 Epoch 从 5 提升到 30,验证方式改为每 Epoch 一次、保存 best_model.pth。
8.2 结果
| 模型 | Best Validation Dice | Best Epoch | Epoch 30 Dice |
|---|---|---|---|
| Baseline | 0.938300 | 18 | 0.930852 |
| Attention | 0.936670 | 10 | 0.931765 |
Best Dice 差值:
1 | Attention - Baseline = -0.001630 |
8.3 判断变化:短训练优势 ≠ 最终优势
这次实验修正了第六章的结论方向。5 Epoch + 3 Seed 时 Attention 3/3 全胜,但 30 Epoch 充分训练后 Baseline 追平并略微反超。不能再写”Attention 一定提高最终 Dice”,而应写:短训练条件下 Attention 有初步正向趋势,但充分训练后这种优势并不稳定。
同时观察到:
- Baseline 最佳出现在 Epoch 18,Attention 最佳出现在 Epoch 10
- 两者在最佳 Epoch 之后都没有持续大幅增长
- 30 Epoch 对当前结构比较基本足够,不必立刻全部重跑到 50 Epoch
此时最大的限制已不再是”Seed 1 是否偶然”,而是:只有 3 张 Validation,这 3 张能否代表整个 30 张数据集? 实验重点从”随机初始化稳定性”转向”数据划分稳定性”——于是进入 5-Fold。
九、5-Fold Cross Validation:验证集从 3 张扩到全覆盖
9.1 为什么升级到 5-Fold
固定 27/3 划分的核心问题是验证集只有 3 张,某一张困难图的表现变化就能明显拉动平均 Dice。5-Fold 让每张图都当过一次验证样本,Baseline 和 Attention 在完全相同的 Fold 划分下比较,五个验证 Fold 互不重复、合起来覆盖全部 30 张。
9.2 train.py 第二轮改动
| 改动 | 内容 |
|---|---|
| 数据划分 | random_split → Subset + 5-Fold |
| 新增参数 | --fold(0~4) |
| 模型保存 | best_model.pth → best_model_fold{fold}.pth |
| Fold 日志 | 启动时打印 Fold X/4 - Train size: 24, Validation size: 6 |
| 保留不变 | 30 Epoch、每 Epoch 验证一次、--seed、Fold 排列 Seed=2026 |
5-Fold 划分核心代码(F:\Pytorch-UNet-master\train.py):
1 | n_splits = 5 |
manual_seed(2026) 固定 Fold 排列顺序,保证 Baseline 和 Attention 在同一个 Fold 中使用完全相同的 24 张训练图和 6 张验证图。--seed 仍然只控制模型初始化和训练 shuffle,与 Fold 划分无关。
9.3 运行方式
正式对照实验一个一个 Fold 跑(不并行多个 GPU 进程,避免 OOM 和条件不统一):
1 | # Baseline,Seed=1,依次跑 Fold 0~4 |
Attention 工程目录用相同命令,共 10 次训练(Baseline 5 Fold + Attention 5 Fold)。
9.4 完整 5-Fold 结果
| Fold | Baseline Best | Baseline Best Epoch | Attention Best | Attention Best Epoch | Δ = A − B |
|---|---|---|---|---|---|
| 0 | 0.948187 | 25 | 0.949413 | 22 | +0.001226 |
| 1 | 0.952715 | 27 | 0.951543 | 30 | −0.001172 |
| 2 | 0.948218 | 29 | 0.950042 | 28 | +0.001824 |
| 3 | 0.941981 | 29 | 0.942210 | 29 | +0.000229 |
| 4 | 0.929799 | 13 | 0.929608 | 13 | −0.000191 |
胜负:Attention 3 胜(Fold 0/2/3),Baseline 2 胜(Fold 1/4)。但不能只看 3:2,必须看平均值和变化幅度。
Mean ± SD:
1 | Baseline Mean Best Dice = 0.944180 ± 0.008900 |
9.5 Fold 差异远大于 Attention 差异
1 | Fold 1 ≈ 0.952 vs Fold 4 ≈ 0.930 |
阶段性结论:在 5-Fold 交叉验证中,Attention U-Net 在 3 个 Fold 上 Best Dice 更高、2 个 Fold 上更低。两者平均 Best Dice 分别约为 0.94418 和 0.94456,Attention 平均仅高约 0.00038 Dice——该变化幅度远小于不同 Fold 之间的波动,当前结果不支持”Attention Gate 能稳定、明显提高最终语义分割 Dice”。
9.6 三个实验阶段的统一理解
整个过程不是互相矛盾,而是实验逐渐变严格:
| 阶段 | 设置 | 关键结果 | 说明 |
|---|---|---|---|
| A | 5 Epoch + 3 Seed + 固定 27/3 | Attention 3/3 胜,平均 +0.80 pp | 短训练条件下有正向趋势 |
| B | 30 Epoch + 固定 27/3 + Seed 1 | Baseline 0.9383 > Attention 0.9367 | 充分训练后早期优势不一定保留 |
| C | 30 Epoch + 5-Fold + Seed 1 | Baseline ≈ 0.94418,Attention ≈ 0.94456 | 换验证集后两者基本同一水平 |
1 | 初步看起来有效 → 检查是否真正收敛 → 发现优势不稳定 → 换不同验证集 → 确认平均 Dice 几乎相同 |
十、原作者 Grid Attention 验证:三模型 5-Fold 对照
5-Fold 结果显示简化版 Attention 与 Baseline 几乎持平后,我还有一个关键疑问:会不会不是 Attention 本身没用,而是我的简化实现偏离了原作者机制? 于是补做了第三个模型——更接近 Ozan Oktay 原作者 Grid Attention 的二维适配版。
10.1 补做动机
5-Fold 时简化版 Attention 相对 Baseline 只高 +0.000383 Dice,几乎可以忽略。但不能直接说”原版 Attention U-Net 对本任务无效”——更准确的说法是”当前简化 Attention 与 Baseline 基本相当“。需要排除”实现过度简化”这一可能性。
参考来源:ozan-oktay/Attention-Gated-Networks 的 grid_attention_layer.py,在保持当前 2D U-Net 主干不变的前提下,仅把 Attention 部分按原作者核心机制做最小适配。
10.2 简化版 vs 原作者机制:两个核心差异
差异 ①:attention 在哪个分辨率上算
| 简化版(我的改写) | 原作者 Grid Attention | |
|---|---|---|
theta(x) |
1×1 卷积,保持原分辨率 | 2×2 stride=2 卷积,先降到 coarse grid |
phi(g) |
1×1 卷积后上采样到 x 的尺寸 | 1×1 卷积后对齐到 coarse grid |
alpha |
直接在高分辨率上算 | 在低分辨率上算,再上采样回 x |
1 | 简化版:g 往 x 的高分辨率对齐 |
差异 ②:gating signal 的来源
| 简化版 | 原作者 | |
|---|---|---|
| 三个 AG 的 g | att4 用 x5,att3 用 up1 输出,att2 用 up2 输出 | 共享同一个 bottleneck gating signal |
| 实现 | 每层门控信号不同 | gating = self.gating(x5),att4/att3/att2 共用 |
10.3 原作者机制版的最小改动
theta 改为下采样卷积:
1 | self.theta = nn.Conv2d( |
shared gating block:
1 | self.gating = nn.Sequential( |
coarse grid 上算 alpha,再放大回 x:
1 | f = F.relu(theta_x + phi_g, inplace=True) |
inter_channels 按原作者设置(不再默认 in_channels // 2):
1 | att4: in=512, gating=512, inter=512 |
x1 仍然不加 Attention,与原作者设计一致。train.py、Loss、Optimizer、5-Fold 划分等全部不动,变量只有 Attention 机制本身。
10.4 三模型完整 5-Fold 结果
| Fold | Baseline | Simplified Attention | Author Attention |
|---|---|---|---|
| 0 | 0.948187 | 0.949413 | 0.947896 |
| 1 | 0.952715 | 0.951543 | 0.951689 |
| 2 | 0.948218 | 0.950042 | 0.949690 |
| 3 | 0.941981 | 0.942210 | 0.941819 |
| 4 | 0.929799 | 0.929608 | 0.927797 |
Mean ± SD:
| 模型 | Mean Best Dice | SD |
|---|---|---|
| Baseline | 0.944180 | 0.008900 |
| Simplified Attention | 0.944563 | 0.009103 |
| Author Attention | 0.943778 | 0.009666 |
相对 Baseline 的变化:
1 | Simplified Attention:+0.000383 Dice(+0.038 pp) |
三者差距都在 0.001 以内,基本处于同一水平。
10.5 为什么三者几乎一样
五个原因
① Baseline 本身已经很强(Mean Dice ≈ 0.9442),处于高性能/接近饱和区。Attention 即使改善了部分边界像素或背景区域,对整图 Dice 的影响也可能只有 0.000x。
② Attention 解决的问题可能不是当前任务的主要瓶颈。 Attention Gate 擅长”哪些区域更重要、该看哪里”,但泡沫任务真正的困难可能是相邻泡沫粘连、实例分离——找到泡沫不等于把相邻泡沫正确分成独立实例。
③ Dice 对实例粘连不够敏感。 两个泡沫被预测成一个连通区域,从实例角度错误严重,但从像素角度大部分内部像素仍然正确,Dice 仍然可以很高。
④ 数据量很小(每 Fold 仅 24 张训练图),Attention 额外增加了 theta/phi/psi/W/gating 等参数,表达能力增强不等于泛化能力增强。
⑤ Attention 不是”加上必然提高 Dice”。 若网络学到 α ≈ 1,则 α·x ≈ x,Attention 退化为普通 skip connection;若错误压低有用特征,反而可能略降性能。
10.6 当前能下的正式结论
不能写”Attention U-Net 没用”,也不能写”完全复现原论文后证明 Attention 无效”——本实验仍是当前 2D Pytorch-UNet backbone + Grid Attention 核心机制的二维适配。
在当前 30 张泡沫图像数据、当前 2D U-Net 主干、相同训练协议和 5-Fold 划分条件下,无论使用普通 U-Net、简化 Attention Gate,还是更接近 Ozan Oktay 原作者 Grid Attention 的二维适配版本,最终 Best Validation Dice 都非常接近(0.94418 / 0.94456 / 0.94378)。当前实验不支持 Attention Gate 能稳定、明显提高该任务的最终语义分割 Dice。 也不支持”之前 Attention 没提升主要是因为代码过度简化”的解释。
完整 Attention 证据链:
1 | 5 Epoch + 3 Seed → Simplified 3/3 胜 → 怀疑 Attention 有效 |
十一、下一阶段:从语义 Dice 转向泡沫实例分割
Attention 结构对照已经比较完整(Baseline ✅、Simplified Attention ✅、Author Attention ✅,全部 5-Fold + 30 Epoch + 相同 Seed/Fold/训练流程)。此时继续盲目叠加 SE、CBAM、Transformer 等模块,价值开始下降——真正值得研究的不是 0.94418 vs 0.94456 vs 0.94378 之间的 0.000x 差距,而是泡沫有没有粘在一起、数量对不对、边界准不准。
11.1 为什么不能只看 Semantic Dice
用户最终任务是每个泡沫单独识别、编号。下面两种结果 Dice 可能非常接近,但对编号任务完全不同:
1 | 真实: 模型 A: 模型 B: |
模型 A 正确识别 3 个泡沫,模型 B 只识别成 2 个——Semantic Dice 很高 ≠ 实例分离已经做好。
11.2 建议增加的评价指标
1 | Dice / IoU / Precision / Recall |
模型选择不要简单”谁 Dice 高选谁”:
| 情形 | 判断 | 选择 |
|---|---|---|
| Attention Dice↑ 且计数误差↓、Instance F1↑ | 全面更好 | Attention U-Net |
| Attention Dice↑ 但 Bubble Count Error↑、Merge↑ | 粘连更严重 | 实例分离更重要,不选 Attention |
| Baseline ≈ Attention 差距极小 | 收益极小 | 原始 U-Net 更实用 |
| 多 fold/seed 持续领先且实例指标也更好 | 强证据 | Attention 作为专用模型 |
11.3 后续模型路线与项目进展
按泡沫接触程度分三种情况(与 6.7 节一致):
情况 A:泡沫基本分开
Attention U-Net + 连通域分析即可。流程:原图 → U-Net → 二值 mask → 连通域 → 逐个泡沫编号。
情况 B:泡沫经常接触
需要 U-Net + 距离变换 + Watershed(分水岭),或增加 Boundary/Edge 分支让网络专门学泡沫边界。
情况 C:大量泡沫紧密接触且必须逐个编号
任务升级为实例分割,考虑 Mask R-CNN、Cellpose、StarDist,或 U-Net + Boundary prediction + Watershed。
F:\Pytorch-UNet-master 里我已经开始搭建实例分割评估管线:
1 | make_instance_gt.py ← 生成实例级 Ground Truth |
推荐下一步:拿 best_model_foldX.pth 实际预测几张泡沫图,同时查看原图 / GT / Predicted Mask,先观察真实失败模式(漏检?背景误检?边界偏移?相邻粘连?一个切成多个?),再决定走 Watershed、Boundary 还是实例分割路线。
十二、总结
把整条学习线收个尾:
- 概念:图片是数字矩阵,卷积/池化/上采样是三大基础操作,上采样找不回池化丢的细节——这正是跳跃连接存在的理由
- 架构:U-Net = 编码器(理解是什么)+ 解码器(恢复在哪里)+ 跳跃连接(细节补给),逐层数据流把 572×572×1 最终映射到 388×388×2
- 代码:把官方多文件改写合一,
padding=1的 same 卷积让尺寸守恒;前向传播里变量不能重名,否则取不到中间层特征 - 实战:ISBI 细胞壁分割,”只改一个变量”的 A–K 实验脉络,数据增强和后处理是低成本的稳定涨点手段
- 进阶:Attention Gate 在跳跃连接前筛选特征,用更深层的
g指导、ReLU+Sigmoid 得注意力系数 α;我的简化版把phi_g上采样到x尺寸,原作者版则先降 coarse grid 再放大 - 对照实验链:3-Seed 初步验证(Attention 3/3 胜)→
train.py规范化(30 Epoch、每 Epoch 验证、best_model、—seed/—fold)→ 30 Epoch 收敛检查(Baseline 反超)→ 5-Fold(两者 Mean Dice 仅差 0.00038)→ 原作者 Grid Attention 三模型对照(0.94418 / 0.94456 / 0.94378,三者基本持平) - 下一步:Attention 结构对照已较完整,继续堆模块价值下降;应从语义 Dice 转向泡沫实例分离——Bubble Count Error、Instance F1、Watershed/Boundary 才是当前瓶颈
从”能跑通 U-Net”到”理解注意力为什么这样设计”再到”用严格对照实验证明 Attention 对本任务语义 Dice 提升有限”,这条线的核心始终是同一个问题——怎么把细节精准地送到需要它的地方,以及怎么用正确的指标判断模型对最终任务有没有用。U-Net 用跳跃连接回答了一次,Attention U-Net 又用门控筛选回答了第二次;而对泡沫编号任务,答案可能不在 Attention,而在实例分离。
参考资料
- Ronneberger O. et al. U-Net: Convolutional Networks for Biomedical Image Segmentation (MICCAI 2015)
- Oktay O. et al. Attention U-Net: Learning Where to Look for the Pancreas (MIDL 2018)
- milesial/Pytorch-UNet:https://github.com/milesial/Pytorch-UNet
- ozan-oktay/Attention-Gated-Networks:https://github.com/ozan-oktay/Attention-Gated-Networks
- 学习笔记来源(CSDN):https://blog.csdn.net/knighthood2001/article/details/138075554







