这是一篇从零梳理 UNet 的学习笔记。前半部分讲清基础架构、数据流向与我改写合一的源码注释;中间进阶到 Attention U-Net,结合论文、官方注意力源码、以及我自己简化改写的 AttentionGate2D;后半部分记录完整的 Baseline vs Attention 对照实验链——从 3-Seed 初步验证、train.py 规范化、30 Epoch 收敛检查、5-Fold 交叉验证,到原作者 Grid Attention 三模型对照,以及面向泡沫实例分割的下一步方向。所有手绘框架图、改写代码与中文注释均为我在学习过程中亲手整理。

前言

图像分割是计算机视觉里的核心任务——不仅要识别图里有什么,还要精确标出它在哪些像素位置。在医学影像、细胞壁提取这类样本少、目标边界细、背景干扰强的场景里,U-Net 凭借一个优雅的 U 型结构和”跳跃连接”长期封神。

这篇笔记按我自己的学习路径展开:

  1. 先补齐卷积/池化/上采样这些前置概念
  2. 拆解 U-Net 的架构与逐层数据流
  3. 把官方 milesial/Pytorch-UNet 分散在多文件里的网络定义改写合一成单文件,并逐行加注释
  4. 跑通 ISBI 细胞壁分割,设计 A–K 改进实验
  5. 进阶到 Attention U-Net:论文动机 → 注意力门控原理 → 官方源码 → 我的简化改写
  6. 做 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
2
3
4
图片的一块:        卷积核:        相乘得:          结果:
1 2 3 1 0 -1 1 0 -3
4 5 6 1 0 -1 4 0 -6 相加 = 1+0-3+4+0-6+7+0-9 = -6
7 8 9 1 0 -1 7 0 -9

如果图片是 5×5、卷积核是 3×3,卷积核就在图片上不断滑动,每到一个位置做一次”对应相乘再相加”。

卷积核为什么能找到边缘?

用一组 1 0 -1 的卷积核扫描一张左暗右亮的图:

1
2
3
4
数据矩阵          卷积核
10 10 200 1 0 -1
10 10 200 1 0 -1
10 10 200 1 0 -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 网络架构图与各组件含义

UNet 整体架构(论文原版)

对照图里每个标注:

图中标注 含义
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
2
3
4
5
572×572×1 →(conv×2)→ 568×568×64
→(maxpool)→ 284×284×64 →(conv×2)→ 280×280×128
→(maxpool)→ 140×140×128 →(conv×2)→ 136×136×256
→(maxpool)→ 68×68×256 →(conv×2)→ 64×64×512
→(maxpool)→ 32×32×512 →(conv×2)→ 28×28×1024 ← 瓶颈层

右半部分(解码器): 通过 up-conv 2×2 逐层放大,每一步先上采样、再 copy and crop 拼接、再两次卷积,最后用 1×1 卷积把 64 通道压成 2 通道:

1
2
3
4
5
28×28×1024 →(up-conv)→ 56×56×512 →(concat x8)→ 56×56×1024 →(conv×2)→ 52×52×512
→(up-conv)→ 104×104×256 →(concat x6)→ 104×104×512 →(conv×2)→ 100×100×256
→(up-conv)→ 200×200×128 →(concat x4)→ 200×200×256 →(conv×2)→ 196×196×128
→(up-conv)→ 392×392×64 →(concat x2)→ 392×392×128 →(conv×2)→ 388×388×64
→(conv 1×1)→ 388×388×2

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class DoubleConv(nn.Module):
"""(convolution => [BN] => ReLU) * 2"""

def __init__(self, in_channels, out_channels, mid_channels=None):
super().__init__()
if not mid_channels:
mid_channels = out_channels
self.double_conv = nn.Sequential(
# nn.Conv2d 定义卷积,已经包含了卷积操作的实现细节
nn.Conv2d(in_channels, mid_channels, kernel_size=3, padding=1, bias=False),
# 二维批归一化层,作用于第一个卷积的输出
# 目的:对每个批次的特征图做均值方差归一化,加速收敛,防止梯度消失/爆炸,起正则化作用
nn.BatchNorm2d(mid_channels),
nn.ReLU(inplace=True),
nn.Conv2d(mid_channels, out_channels, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True)
)

def forward(self, x):
return self.double_conv(x)
我在注释里强调的几个点
  • bias=False:卷积后接了 BN,BN 本身有偏置(偏移参数),所以卷积的 bias 可省去,减少冗余参数
  • BatchNorm2d:我的注释写明它”对每个批次的特征图做均值方差归一化”,作用是加速收敛 + 防梯度异常 + 正则化
  • inplace=True:ReLU 原地操作,省一点显存

3.3 Down:先池化再卷积

1
2
3
4
5
6
7
8
9
10
class Down(nn.Module):
"""Downscaling with maxpool then double conv"""
def __init__(self, in_channels, out_channels):
super().__init__()
self.maxpool_conv = nn.Sequential(
nn.MaxPool2d(2), # 内置最大池化,尺寸减半
DoubleConv(in_channels, out_channels) # 再做两次卷积
)
def forward(self, x):
return self.maxpool_conv(x)

MaxPool2d(2) 即 2×2 窗口、步长 2,高宽减半、通道不变;紧接 DoubleConv 提取特征并升维。

3.4 Up:上采样 + 对齐 + 拼接 + 卷积

这是 U-Net 最巧妙的一层,我在注释里花了不少笔墨。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Up(nn.Module):
"""Upscaling then double conv"""
def __init__(self, in_channels, out_channels, bilinear=True):
super().__init__()
# if bilinear, use the normal convolutions to reduce the number of channels
if bilinear:
self.up = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True) # 双线性插值放大 2 倍
self.conv = DoubleConv(in_channels, out_channels, in_channels // 2) # 中间通道数取输入一半
else:
# 不使用双线性插值,直接转置卷积上采样(官方默认)
self.up = nn.ConvTranspose2d(in_channels, in_channels // 2, kernel_size=2, stride=2)
self.conv = DoubleConv(in_channels, out_channels)

def forward(self, x1, x2):
x1 = self.up(x1) # 先上采样 x1
diffY = x2.size()[2] - x1.size()[2] # 高度差
diffX = x2.size()[3] - x1.size()[3] # 宽度差
# 补齐尺寸:对 x1 做 padding,使其与 x2 高宽对齐
x1 = F.pad(x1, [diffX // 2, diffX - diffX // 2, diffY // 2, diffY - diffY // 2])
# 跳跃连接:通道维拼接
x = torch.cat([x2, x1], dim=1) # dim=1 表示沿通道数拼接
return self.conv(x)
我标注的三个关键理解
  1. 两种上采样方式bilinear=True 用双线性插值(无可学习参数,省显存,官方会同时减半通道数补偿);bilinear=False 用转置卷积 ConvTranspose2d(可学习的放大)。我的代码默认走转置卷积分支
  2. F.pad 补齐:上采样后 x1 与跳跃特征 x2 可能差 1 像素,用 padding 对齐,避免拼接错位。注释里我贴了官方解决 padding 问题的两个 commit 链接
  3. torch.cat([x2, x1], dim=1):沿通道维拼接,高宽相同、通道相加——这就是”copy and crop”在代码里的真实模样

3.5 OutConv:1×1 卷积收尾

1
2
3
4
5
6
class OutConv(nn.Module):
def __init__(self, in_channels, out_channels):
super(OutConv, self).__init__()
self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1) # 1×1 卷积核
def forward(self, x):
return self.conv(x) # 得到最终分类结果

1×1 卷积把 64 通道压成 n_classes 通道(分割任务里通常是类别数),不改变高宽。

3.6 UNet 主体与前向传播

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class UNet(nn.Module):
def __init__(self, n_channels, n_classes, bilinear=False):
super(UNet, self).__init__()
self.n_channels = n_channels
self.n_classes = n_classes
self.bilinear = bilinear
# ===== 编码器 =====
self.inc = DoubleConv(n_channels, 64)
self.down1 = Down(64, 128)
self.down2 = Down(128, 256)
self.down3 = Down(256, 512)
factor = 2 if bilinear else 1
self.down4 = Down(512, 1024 // factor)
# ===== 解码器 =====
self.up1 = Up(1024, 512 // factor, bilinear)
self.up2 = Up(512, 256 // factor, bilinear)
self.up3 = Up(256, 128 // factor, bilinear)
self.up4 = Up(128, 64, bilinear)
# ===== 输出层 =====
self.outc = OutConv(64, n_classes)

def forward(self, x):
# Encoder
x1 = self.inc(x)
x2 = self.down1(x1)
x3 = self.down2(x2)
x4 = self.down3(x3)
x5 = self.down4(x4)
# Decoder(每步把上一步结果与对应编码器特征拼接)
x = self.up1(x5, x4)
x = self.up2(x, x3)
x = self.up3(x, x2)
x = self.up4(x, x1)
# Output
logits = self.outc(x)
return logits

前向传播里最容易踩的坑:不能用同一个变量名贯穿全程!因为 U-Net 需要 copy,下采样前必须用新变量(x1、x2、x3、x4)保存中间输出,供后面跳跃连接使用。如果全写成 x = ...,后面就取不到中间层特征了。

我在注释里特意写了这段理解:

Down 是处理规则,self.down1 是这套规则的一个具体实例,x1 是被处理的数据,x2 是处理结果。这里不用变量重名是因为后面要跳跃连接。

完整改写合一版 unet.py(含我的全部中文注释)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
""" Full U-Net network in a single file """
import torch
import torch.nn as nn
import torch.nn.functional as F

# 对于二维卷积 nn.Conv2d:
# H_out = ⌊(H_in + 2P - D×(K-1) - 1) / S⌋ + 1
# W_out = ⌊(W_in + 2P - D×(K-1) - 1) / S⌋ + 1
# K:卷积核大小,P:填充,S:步长,D:空洞率(默认 1)

# 在定义每一个方法时,先 __init__ 初始化,再用 forward 表示具体计算

class DoubleConv(nn.Module):
"""(convolution => [BN] => ReLU) * 2"""
def __init__(self, in_channels, out_channels, mid_channels=None):
super().__init__()
if not mid_channels:
mid_channels = out_channels
self.double_conv = nn.Sequential(
nn.Conv2d(in_channels, mid_channels, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(mid_channels),
nn.ReLU(inplace=True),
nn.Conv2d(mid_channels, out_channels, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True)
)
def forward(self, x):
return self.double_conv(x)

class Down(nn.Module):
"""Downscaling with maxpool then double conv"""
def __init__(self, in_channels, out_channels):
super().__init__()
self.maxpool_conv = nn.Sequential(
nn.MaxPool2d(2),
DoubleConv(in_channels, out_channels)
)
def forward(self, x):
return self.maxpool_conv(x)

class Up(nn.Module):
"""Upscaling then double conv"""
def __init__(self, in_channels, out_channels, bilinear=True):
super().__init__()
if bilinear:
self.up = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True)
self.conv = DoubleConv(in_channels, out_channels, in_channels // 2)
else:
self.up = nn.ConvTranspose2d(in_channels, in_channels // 2, kernel_size=2, stride=2)
self.conv = DoubleConv(in_channels, out_channels)
def forward(self, x1, x2):
x1 = self.up(x1)
diffY = x2.size()[2] - x1.size()[2]
diffX = x2.size()[3] - x1.size()[3]
x1 = F.pad(x1, [diffX // 2, diffX - diffX // 2, diffY // 2, diffY - diffY // 2])
x = torch.cat([x2, x1], dim=1)
return self.conv(x)

class OutConv(nn.Module):
def __init__(self, in_channels, out_channels):
super(OutConv, self).__init__()
self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1)
def forward(self, x):
return self.conv(x)

class UNet(nn.Module):
def __init__(self, n_channels, n_classes, bilinear=False):
super(UNet, self).__init__()
self.n_channels = n_channels
self.n_classes = n_classes
self.bilinear = bilinear
self.inc = DoubleConv(n_channels, 64)
self.down1 = Down(64, 128)
self.down2 = Down(128, 256)
self.down3 = Down(256, 512)
factor = 2 if bilinear else 1
self.down4 = Down(512, 1024 // factor)
self.up1 = Up(1024, 512 // factor, bilinear)
self.up2 = Up(512, 256 // factor, bilinear)
self.up3 = Up(256, 128 // factor, bilinear)
self.up4 = Up(128, 64, bilinear)
self.outc = OutConv(64, n_classes)
def forward(self, x):
x1 = self.inc(x)
x2 = self.down1(x1)
x3 = self.down2(x2)
x4 = self.down3(x3)
x5 = self.down4(x4)
x = self.up1(x5, x4)
x = self.up2(x, x3)
x = self.up3(x, x2)
x = self.up4(x, x1)
logits = self.outc(x)
return logits

四、ISBI 细胞壁分割实战

跑通基础流程后,我做了一整套对比实验。项目目录 F:\Pytorch-UNet-master,数据是 ISBI 灰度细胞图,所以模型输入通道为 1。

4.1 标准训练—预测—评估流程

1
2
3
4
5
6
cd "F:\Pytorch-UNet-master"
$env:WANDB_MODE="disabled" # 不公开数据,关闭 wandb 上传
Remove-Item "checkpoints\*.pth" -Force -ErrorAction SilentlyContinue # 训练前清空旧模型

python train.py --epochs 20 --batch-size 1 --learning-rate 1e-5 --scale 1
python predict.py --model checkpoints/checkpoint_epoch20.pth -i data/imgs/001.png -o output_cell_001.png --scale 1

几个我踩过坑后固化的经验:

通道数与 scale 必须前后一致
  • 通道数:灰度细胞图 n_channels=1,彩色 RGB 图 n_channels=3train.pypredict.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
2
3
4
intersection = np.logical_and(pred, true).sum()
union = np.logical_or(pred, true).sum()
dice = 2 * intersection / (pred.sum() + true.sum() + 1e-8)
iou = intersection / (union + 1e-8)
  • Dice:预测与真实重叠的两倍 / 两者面积和,衡量重合度
  • IoU:交集 / 并集,分割任务最常用指标

我写了 check_score_all.py 批量评估,输出每张图 + 平均 Dice/IoU + CSV 表格;又写了 compare_experiments.py 自动汇总所有实验并按平均 Dice 排序。

4.3 A–K 改进实验脉络

只改一个变量、每次存 checkpoint、用同一套评估脚本、不只看一张图——这是我做实验的核心原则。整体路线:

  1. 第一阶段·基础超参数:A baseline → B 加 epochs → C 调学习率 → D 改 batch size
  2. 第二阶段·训练策略:E 验证集比例 → G 开 AMP → H 双线性 vs 转置卷积
  3. 第三阶段·数据层面:I 数据增强(30 张 → 180 张)
  4. 第四阶段·损失与后处理: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 改进方法

注意力机制大概流程:原方法直接 concat vs 改进方法先 AG 筛选

上面这张是我画的对比图:

原方法:上采样后的 x5 直接与编码器特征 x4concat
改进方法:先用 Attention Gate 对 x4 做加权筛选,得到 x4',再与上采样后的 x5concat。门控信号 g 就是更深层特征 x5,它告诉门”目标大概在哪、该关注哪些区域”。

一句话总结图中底部的三行:

1
2
x4  →  Attention Gate  →  x4'
原来的编码器特征 更深层特征提供指导 筛选后的编码器特征

5.3 AG 原理详解

我手绘的 Attention Gate 具体原理图(也是本文封面)

上面这张原理图(也是本文封面)是我结合论文和 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-Networksmodels/layers/grid_attention_layer.py,并逐行加了中文注释。它定义了支持 2D/3D、支持三种模式(concatenation / concatenation_debug / concatenation_residual)的 _GridAttentionBlockND

核心组件与图中的对应关系(我的注释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Output transform:官方额外的输出变换层,对应图里的 W
self.W = nn.Sequential(
conv_nd(in_channels=self.in_channels, out_channels=self.in_channels,
kernel_size=1, stride=1, padding=0),
bn(self.in_channels),
)

# Theta^T * x_ij + Phi^T * gating_signal + bias
self.theta = conv_nd(in_channels=self.in_channels, out_channels=self.inter_channels,
kernel_size=self.sub_sample_kernel_size, stride=self.sub_sample_factor,
padding=0, bias=False) # 对应图中的 Wx
self.phi = conv_nd(in_channels=self.gating_channels, out_channels=self.inter_channels,
kernel_size=1, stride=1, padding=0, bias=True) # 对应图中的 Wg
self.psi = conv_nd(in_channels=self.inter_channels, out_channels=1,
kernel_size=1, stride=1, padding=0, bias=True) # 对应图中的 ReLU 后面的 φ

_concatenation 前向计算(带我的注释):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def _concatenation(self, x, g):
input_size = x.size()
theta_x = self.theta(x) # 对 x 做 Wx 卷积
theta_x_size = theta_x.size()
# 把 g 经 Wg 卷积后上采样到与 theta_x 同尺寸,再相加 + ReLU
phi_g = F.upsample(self.phi(g), size=theta_x_size[2:], mode=self.upsample_mode)
f = F.relu(theta_x + phi_g, inplace=True)
# ψ 卷积(1×1×1)压成 1 通道,Sigmoid 得注意力权重 α
sigm_psi_f = F.sigmoid(self.psi(f))
# 对应图中的 resampler:把 α 上采样回 x 尺寸,再与 x 相乘
sigm_psi_f = F.upsample(sigm_psi_f, size=input_size[2:], mode=self.upsample_mode)
y = sigm_psi_f.expand_as(x) * x
W_y = self.W(y)
return W_y, sigm_psi_f
三种模式有何不同
  • concatenation:ReLU 后 Sigmoid,最常用
  • concatenation_debug:把第一个激活换成 softplus
  • concatenation_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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class AttentionGate2D(nn.Module):
"""2D attention gate for filtering U-Net skip features."""
def __init__(self, in_channels, gating_channels, inter_channels=None):
super().__init__()
if inter_channels is None:
inter_channels = max(1, in_channels // 2)

self.theta = nn.Conv2d(in_channels, inter_channels, kernel_size=1, bias=False) # Wx
self.phi = nn.Conv2d(gating_channels, inter_channels, kernel_size=1, bias=True) # Wg
self.psi = nn.Conv2d(inter_channels, 1, kernel_size=1, bias=True) # φ
self.W = nn.Sequential(
nn.Conv2d(in_channels, in_channels, kernel_size=1, bias=False),
nn.BatchNorm2d(in_channels)
)

def forward(self, x, g):
theta_x = self.theta(x) # [B, C/2, Hx, Wx]
phi_g = self.phi(g) # [B, C/2, Hg, Wg]
# 判断 phi_g 与 theta_x 的空间尺寸是否相同
if phi_g.shape[2:] != theta_x.shape[2:]:
# 把 phi_g 的 H、W 调整成和 theta_x 一模一样
# 与论文把 x 往 g 上调整的方向相反,但本次操作能用更大分辨率得到更精确的注意力系数,
# 计算量相对较大,效果需具体判断
phi_g = F.interpolate(phi_g, size=theta_x.shape[2:], mode='bilinear', align_corners=True)
# theta_x + phi_g → ReLU → ψ(1×1) → Sigmoid 得注意力系数 α
attention = torch.sigmoid(self.psi(F.relu(theta_x + phi_g, inplace=True)))
return self.W(x * attention)

这是我最想标出来的改动:论文和官方实现是把 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。下面这张是我对应最终写好的完整代码画的流程图,把编码器、瓶颈层、三个注意力门、解码器拼接都串在了一起:

我对应完整 Attention U-Net 代码手绘的框架流程图

代码里我加了三个注意力门 att4 / att3 / att2,分别处理对应层的跳跃特征:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class UNet(nn.Module):
def __init__(self, n_channels, n_classes, bilinear=False):
super(UNet, self).__init__()
# 编码器
self.inc = DoubleConv(n_channels, 64)
self.down1 = Down(64, 128)
self.down2 = Down(128, 256)
self.down3 = Down(256, 512)
factor = 2 if bilinear else 1
self.down4 = Down(512, 1024 // factor)
# Attention Gates
self.att4 = AttentionGate2D(512, 1024 // factor)
self.att3 = AttentionGate2D(256, 512 // factor)
self.att2 = AttentionGate2D(128, 256 // factor)
# 解码器
self.up1 = Up(1024, 512 // factor, bilinear)
self.up2 = Up(512, 256 // factor, bilinear)
self.up3 = Up(256, 128 // factor, bilinear)
self.up4 = Up(128, 64, bilinear)
# 输出层
self.outc = OutConv(64, n_classes)

def forward(self, x):
# Encoder
x1 = self.inc(x)
x2 = self.down1(x1)
x3 = self.down2(x2)
x4 = self.down3(x3)
x5 = self.down4(x4)
# Decoder:每步先用 AG 筛选跳跃特征,再上采样拼接
x4_att = self.att4(x4, x5) # 用最深 x5 作门控筛选 x4
x = self.up1(x5, x4_att)
x3_att = self.att3(x3, x)
x = self.up2(x, x3_att)
x2_att = self.att2(x2, x)
x = self.up3(x, x2_att)
x = self.up4(x, x1)
# Output
logits = self.outc(x)
return logits

下面按图分小节把完整数据流走一遍,对应上面 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
2
x4_att = self.att4(x4, x5)   # g = x5(瓶颈),筛选 x4
x = self.up1(x5, x4_att) # x5 上采样 → 与 x4_att 拼接 → DoubleConv

att4(x4, x5) 里,被筛选的是跳跃特征 x4,门控信号 g = x5。AG 用最深的 x5 告诉网络”目标大概在哪”,给 x4 每个位置打注意力权重,背景被压低、目标被突出,得到 x4_att。然后 up1x5 上采样到 x4 的分辨率,与 x4_att 沿通道维拼接,再 DoubleConv,输出 x(512 通道、H/8 分辨率)。

解码第二段:att3 用 up1 输出筛选 x3,再 up2

1
2
x3_att = self.att3(x3, x)    # g = up1 的输出,筛选 x3
x = self.up2(x, x3_att)

这里门控信号 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
2
x2_att = self.att2(x2, x)    # g = up2 的输出,筛选 x2
x = self.up3(x, x2_att)

同样的模式:门控 g 用更深一段(up2 输出),筛选跳跃特征 x2(128 通道、H/2)。up3 上采样拼接后得到 128 通道、H/2 的 x

最浅层 + 输出:x1 不加注意力

1
2
x = self.up4(x, x1)          # x1 不经过 AG
logits = self.outc(x) # 1×1 卷积压到 n_classes

最浅层 x1(64 通道、原分辨率)不接注意力门,直接和 up4 上采样结果拼接。原因是 x1 已经是最细的位置细节,再筛反而可能丢边界信息。最后 outc 用 1×1 卷积把 64 通道压成 n_classes 通道,输出分割结果。

串起来看门控信号 g 的来源规律:att4 用瓶颈 x5att3up1 输出、att2up2 输出——总是用”更深一段”的解码输出去指导”更浅一层”的跳跃特征。越深层语义越强,用来筛浅层细节;越浅层细节越多,被筛后补回边界。这就是”理解是什么”与”恢复在哪里”在注意力加持下的二次协作。


六、初步对照实验: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
2
3
4
5
6
7
8
9
SPLIT_SEED = 2026

固定"哪 27 张训练、哪 3 张验证"(三个 seed 完全一样)

TRAIN_SEED = 1 / 2 / 3

改变模型初始化、训练随机状态、DataLoader shuffle 顺序

Baseline 和 Attention 在同一个 seed 下成对比较

这样 seed=1 的 Baseline 只和 seed=1 的 Attention 比,seed=2 和 seed=2 比。如果某次 Attention 赢了,可以排除”换了验证集”的干扰,判断到底是结构真的好,还是某次随机初始化碰巧好。

6.2 train.py 的关键改动

attention 版的 train.py 相比原版加了种子控制,核心改动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 新加:固定随机种子
def set_seed(seed):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False

# 数据划分用固定种子,保证三个 seed 下训练/验证集身份一致
split_generator = torch.Generator().manual_seed(2026)
train_set, val_set = random_split(dataset, [n_train, n_val], generator=split_generator)

# 训练 shuffle 用另一个种子,随 seed 参数变化
train_generator = torch.Generator().manual_seed(3) # 跑 seed=1/2 时改成 1/2
train_loader = DataLoader(train_set, shuffle=True, generator=train_generator, **loader_args)

跑 3 个 seed 时,把 set_seed(3)train_generator.manual_seed(3) 里的 3 换成 1、2、3 分别训练三次。from unet import UNet 导入的是带 AttentionGate2D 的版本。这里 cudnn.deterministic=Truebenchmark=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
2
3
4
5
6
7
F:\Pytorch-UNet-master\
├── train.py ← Baseline 与 Attention 完全相同
└── unet.py ← Baseline:原始 U-Net

F:\Pytorch-UNet-master-attention\ (Attention 工程)
├── train.py ← 与 Baseline 完全相同
└── unet.py ← Attention U-Net

只让网络结构不同,其余全部相同——数据、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
2
3
division_step = n_train // (5 * batch_size)
if global_step % division_step == 0:
val_score = evaluate(...)

这意味着 Epoch 1 内部会:训练几张 → 验证 → 再训练 → 验证 → …… 5 Epoch 实验实际上会产生大量 Validation Dice。如果从这些数字里直接挑最大值当 Best Dice,相当于在同一 3 张验证图上反复挑选最优时刻,不够规范。

7.3 新版训练核心代码

Seed 的完整传递路线:

1
2
3
4
5
6
7
python train.py --seed 1

args.seed → set_seed(args.seed) → 控制模型初始化

train_model(..., seed=args.seed)

train_generator.manual_seed(seed) → 控制 DataLoader shuffle

每个 Epoch 结束后的验证与保存逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 每个 epoch 训练完成后,只验证一次
val_score = float(evaluate(model, val_loader, device, amp))
scheduler.step(val_score)

avg_epoch_loss = epoch_loss / len(train_loader)
logging.info(
f'Epoch {epoch}/{epochs} finished - '
f'Train Loss: {avg_epoch_loss:.6f} - '
f'Validation Dice: {val_score:.6f}'
)

# 只保存验证集表现最好的模型
if save_checkpoint and val_score > best_dice:
best_dice = val_score
torch.save(state_dict, str(dir_checkpoint / 'best_model.pth'))

set_seed(args.seed) 必须在 model = UNet(...) 之前执行,否则模型随机初始化不受 Seed 控制。当前代码顺序已经正确:get_args()set_seed(args.seed) → 创建模型。

规范化后的训练命令:

1
2
3
4
5
6
7
8
9
cd "F:\Pytorch-UNet-master"
$env:WANDB_MODE="disabled"

# Baseline,30 Epoch,Seed=1
python train.py --seed 1 2>&1 | Tee-Object baseline_30epoch_seed1.log

# Attention 工程目录同理
cd "F:\Pytorch-UNet-master-attention"
python train.py --seed 1 2>&1 | Tee-Object attention_30epoch_seed1.log

八、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
2
Attention - Baseline = -0.001630
≈ Baseline 高约 0.163 个百分点

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_splitSubset + 5-Fold
新增参数 --fold(0~4)
模型保存 best_model.pthbest_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
2
3
4
5
6
7
8
9
10
11
12
n_splits = 5
fold_generator = torch.Generator().manual_seed(2026)
indices = torch.randperm(len(dataset), generator=fold_generator).tolist()

fold_size = len(dataset) // n_splits # 30 / 5 = 6
val_start = fold * fold_size
val_end = val_start + fold_size
val_indices = indices[val_start:val_end]
train_indices = indices[:val_start] + indices[val_end:]

train_set = Subset(dataset, train_indices) # 24 张
val_set = Subset(dataset, val_indices) # 6 张

manual_seed(2026) 固定 Fold 排列顺序,保证 Baseline 和 Attention 在同一个 Fold 中使用完全相同的 24 张训练图和 6 张验证图--seed 仍然只控制模型初始化和训练 shuffle,与 Fold 划分无关。

9.3 运行方式

正式对照实验一个一个 Fold 跑(不并行多个 GPU 进程,避免 OOM 和条件不统一):

1
2
3
4
5
6
# Baseline,Seed=1,依次跑 Fold 0~4
python train.py --seed 1 --fold 0 2>&1 | Tee-Object baseline_fold0.log
python train.py --seed 1 --fold 1 2>&1 | Tee-Object baseline_fold1.log
python train.py --seed 1 --fold 2 2>&1 | Tee-Object baseline_fold2.log
python train.py --seed 1 --fold 3 2>&1 | Tee-Object baseline_fold3.log
python train.py --seed 1 --fold 4 2>&1 | Tee-Object baseline_fold4.log

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
2
3
4
Baseline  Mean Best Dice = 0.944180 ± 0.008900
Attention Mean Best Dice = 0.944563 ± 0.009103

平均差 = +0.000383 Dice ≈ +0.038 个百分点

9.5 Fold 差异远大于 Attention 差异

1
2
3
4
Fold 1 ≈ 0.952    vs    Fold 4 ≈ 0.930
不同 Fold 之间可相差约 0.022 Dice(≈ 2.2 个百分点)

Attention 相对 Baseline 的平均变化只有约 0.00038 Dice(≈ 0.038 个百分点)

阶段性结论:在 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-Networksgrid_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
2
简化版:g 往 x 的高分辨率对齐
原作者:x 先降到 coarse grid → 低分辨率算 attention → alpha 再放大回来

差异 ②:gating signal 的来源

简化版 原作者
三个 AG 的 g att4 用 x5,att3 用 up1 输出,att2 用 up2 输出 共享同一个 bottleneck gating signal
实现 每层门控信号不同 gating = self.gating(x5),att4/att3/att2 共用

10.3 原作者机制版的最小改动

theta 改为下采样卷积:

1
2
3
4
self.theta = nn.Conv2d(
in_channels, inter_channels,
kernel_size=(2, 2), stride=(2, 2), padding=0, bias=False
)

shared gating block:

1
2
3
4
5
6
7
8
9
10
self.gating = nn.Sequential(
nn.Conv2d(1024 // factor, 512, kernel_size=1),
nn.BatchNorm2d(512),
nn.ReLU(inplace=True)
)
# forward 中:
gating = self.gating(x5)
x4_att, _ = self.att4(x4, gating)
x3_att, _ = self.att3(x3, gating)
x2_att, _ = self.att2(x2, gating)

coarse grid 上算 alpha,再放大回 x:

1
2
3
4
5
f = F.relu(theta_x + phi_g, inplace=True)
attention = torch.sigmoid(self.psi(f))
attention = F.interpolate(attention, size=x.shape[2:], mode='bilinear', align_corners=True)
y = attention.expand_as(x) * x
return self.W(y), attention

inter_channels 按原作者设置(不再默认 in_channels // 2):

1
2
3
att4: in=512,  gating=512, inter=512
att3: in=256, gating=512, inter=256
att2: in=128, gating=512, inter=128

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
2
3
Simplified Attention:+0.000383 Dice(+0.038 pp)
Author Attention: −0.000402 Dice(−0.040 pp)
Author vs Simplified: −0.000785 Dice(−0.079 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
2
3
4
5
6
7
8
9
5 Epoch + 3 Seed → Simplified 3/3 胜 → 怀疑 Attention 有效

30 Epoch + 固定 27/3 → Baseline 略高 → 短训练优势 ≠ 最终优势

30 Epoch + 5-Fold → 0.94418 vs 0.94456 → 几乎一样

补做 Author Attention → 0.94378 → 仍与 Baseline 基本相同

继续改 theta / 换 CBAM / SE 的实验价值开始下降

十一、下一阶段:从语义 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
2
真实:      模型 A:     模型 B:
○ ○ ○ ○ ○ ○ ○○ ○ ← B 把两个相邻泡沫粘成一块

模型 A 正确识别 3 个泡沫,模型 B 只识别成 2 个——Semantic Dice 很高 ≠ 实例分离已经做好。

11.2 建议增加的评价指标

1
2
3
4
5
Dice / IoU / Precision / Recall
Bubble Count Error(泡沫数量误差)
Instance Precision / Recall / F1
Merge Error / Split Error
Boundary 指标

模型选择不要简单”谁 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
2
3
4
5
6
make_instance_gt.py          ← 生成实例级 Ground Truth
instance_predict_step1.py ← 语义 mask + 连通域
instance_predict_step2.py ← + Distance Transform + Watershed
instance_predict_step3.py ← 完整实例预测与 debug 输出
evaluate_instances.py ← 实例级指标评估
unet_boundary.py ← Boundary 分支网络(探索中)

推荐下一步:拿 best_model_foldX.pth 实际预测几张泡沫图,同时查看原图 / GT / Predicted Mask,先观察真实失败模式(漏检?背景误检?边界偏移?相邻粘连?一个切成多个?),再决定走 Watershed、Boundary 还是实例分割路线。


十二、总结

把整条学习线收个尾:

  1. 概念:图片是数字矩阵,卷积/池化/上采样是三大基础操作,上采样找不回池化丢的细节——这正是跳跃连接存在的理由
  2. 架构:U-Net = 编码器(理解是什么)+ 解码器(恢复在哪里)+ 跳跃连接(细节补给),逐层数据流把 572×572×1 最终映射到 388×388×2
  3. 代码:把官方多文件改写合一,padding=1 的 same 卷积让尺寸守恒;前向传播里变量不能重名,否则取不到中间层特征
  4. 实战:ISBI 细胞壁分割,”只改一个变量”的 A–K 实验脉络,数据增强和后处理是低成本的稳定涨点手段
  5. 进阶:Attention Gate 在跳跃连接前筛选特征,用更深层的 g 指导、ReLU+Sigmoid 得注意力系数 α;我的简化版把 phi_g 上采样到 x 尺寸,原作者版则先降 coarse grid 再放大
  6. 对照实验链: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,三者基本持平)
  7. 下一步:Attention 结构对照已较完整,继续堆模块价值下降;应从语义 Dice 转向泡沫实例分离——Bubble Count Error、Instance F1、Watershed/Boundary 才是当前瓶颈

从”能跑通 U-Net”到”理解注意力为什么这样设计”再到”用严格对照实验证明 Attention 对本任务语义 Dice 提升有限”,这条线的核心始终是同一个问题——怎么把细节精准地送到需要它的地方,以及怎么用正确的指标判断模型对最终任务有没有用。U-Net 用跳跃连接回答了一次,Attention U-Net 又用门控筛选回答了第二次;而对泡沫编号任务,答案可能不在 Attention,而在实例分离。


参考资料