从零复现DANet:语义分割注意力机制实战与避坑指南
1. 从零复现DANet为什么值得花时间啃这块硬骨头如果你正在做语义分割相关的项目大概率绕不开DANet这个经典架构。它的全称是Dual Attention Network核心思路是在传统的FCN骨架上叠加位置注意力模块和通道注意力模块让网络同时从空间维度和通道维度捕捉长距离依赖关系。听起来不复杂但真正动手复现的时候你会发现从论文到能跑通的代码之间隔着一堆论文里不会写的细节。我自己第一次复现DANet是在一个图像分割的小项目里当时觉得论文写得挺清楚结构图也画得明明白白应该两三天就能搞定。结果前前后后折腾了将近两周踩的坑包括但不限于注意力模块的张量维度对不上、预训练权重加载后精度反而下降、训练loss震荡不收敛、单卡显存爆炸等等。所以这篇内容不是那种“照着论文抄一遍就完事”的教程而是把我实际复现过程中遇到的真实问题和解决思路完整拆开让你少走弯路。这篇文章适合谁看如果你已经有一定的深度学习基础了解基本的卷积网络和PyTorch操作想复现DANet但不确定从哪下手或者已经动手了但卡在某个环节那这篇内容应该能帮到你。如果你是完全的新手建议先把语义分割的基础概念和PyTorch的基本用法过一遍再来看不然可能会有些吃力。整个复现流程我会按照实际操作的顺序来组织先搞清楚DANet到底在做什么、核心模块的设计逻辑是什么然后搭建工程环境、写模型代码、准备数据、配置训练、跑通推理最后讲我在这个过程中踩过的坑和调参经验。每一步都会说清楚“为什么这么做”而不只是“怎么做”。2. DANet的核心机制拆解注意力模块到底在算什么2.1 位置注意力模块的矩阵运算逻辑DANet最核心的两个组件之一就是位置注意力模块Position Attention Module简称PAM。它的基本思路是对于特征图上的每一个像素位置计算它和其他所有像素位置之间的关联程度然后根据这个关联程度对其他位置的特征进行加权求和得到该位置的新特征。具体来说假设输入特征图X的维度是C×H×W其中C是通道数H和W是空间尺寸。PAM的处理流程是这样的首先用三个1×1卷积分别生成Q、K、V三个特征图维度都是C×H×WC通常小于C用于降维减少计算量。然后把Q和K reshape成C×N的矩阵其中NH×W。接着计算Q的转置和K的矩阵乘积得到一个N×N的注意力图再经过softmax归一化。最后把这个N×N的注意力图和Vreshape成C×N做矩阵乘法得到C×N的输出再reshape回C×H×W经过一个1×1卷积恢复到C×H×W和原始输入做残差连接。这里有个关键细节N×N的注意力图在H和W较大时会非常占显存。比如输入是128×128的特征图N就是16384N×N就是2.68亿个元素用float32存储就是1GB左右的显存。这也是为什么DANet在训练时通常不会用太大的输入尺寸或者在PAM之前会先做下采样。2.2 通道注意力模块与位置注意力的本质区别通道注意力模块Channel Attention Module简称CAM的思路和PAM类似但计算的方向不同。PAM是在空间维度上做注意力CAM是在通道维度上做注意力。具体来说CAM直接把输入特征图X reshape成C×N的矩阵然后计算X和X的转置的乘积得到一个C×C的通道注意力图经过softmax后再和X做矩阵乘法最后残差连接。CAM的计算量比PAM小很多因为C通常远小于N。比如C512、N16384的情况下CAM的注意力图是512×512而PAM是16384×16384差了将近1000倍。所以如果你的显存比较紧张可以考虑只在网络的深层使用PAM在浅层用CAM或者干脆不用。这两个模块的设计哲学其实反映了一个核心问题卷积操作本身只能捕捉局部感受野内的信息对于全局上下文的理解能力有限。PAM和CAM通过显式的矩阵运算来建立任意两个位置或通道之间的直接关联弥补了卷积的这个短板。这也是DANet在Cityscapes、PASCAL VOC等数据集上能取得不错效果的原因。2.3 两个模块的融合方式与残差连接的必要性DANet把PAM和CAM的输出做了元素级相加然后再经过一个卷积层得到最终的分割特征。这里有个容易忽略的点两个模块的输出都是经过残差连接的也就是说最终输出是原始特征加上注意力加权后的特征。这个残差连接非常关键因为注意力模块本质上是在原始特征上做了一次“信息重加权”如果没有残差连接网络很容易在训练初期就丢失原始特征中的有用信息。我在复现时试过去掉残差连接结果训练loss直接爆炸完全无法收敛。后来加上残差连接后训练就稳定多了。这个细节论文里虽然提了但没有强调它的重要性实际动手时才知道这是不可省略的。另外两个模块的融合方式论文里用的是元素级相加我也试过拼接concatenation的方式效果差不多但参数量会增加。如果显存不紧张拼接也是一种可选方案但相加更简洁高效。3. 复现环境搭建与工程结构设计3.1 依赖版本选择为什么我不建议用最新的PyTorch复现深度学习模型时环境版本的选择往往被低估。我一开始用的是最新版的PyTorch结果发现某些算子在不同版本间的行为有细微差异导致模型输出和论文报告的结果对不上。后来我固定用PyTorch 1.7.1 CUDA 11.0 cuDNN 8.0这个组合才和论文的复现结果基本对齐。具体依赖清单如下依赖项推荐版本说明Python3.83.7也能跑但3.8更稳定PyTorch1.7.1不建议用2.x部分API有变化torchvision0.8.2与PyTorch 1.7.1配套CUDA11.0根据显卡驱动选择numpy1.19.5避免用太新的版本opencv-python4.5.x用于数据增强和可视化tensorboard2.x训练可视化如果你用的是30系显卡可能需要CUDA 11.1以上那PyTorch版本也要相应调整。这种情况下建议用PyTorch 1.8.1 CUDA 11.1的组合我实测也能跑通只是需要微调一些代码。3.2 工程目录的组织方式与模块划分一个清晰的工程结构能帮你省下大量调试时间。我采用的目录组织方式是这样的DANet/ ├── configs/ │ └── default.yaml ├── datasets/ │ ├── __init__.py │ └── cityscapes.py ├── models/ │ ├── __init__.py │ ├── danet.py │ ├── backbone.py │ └── attention.py ├── losses/ │ ├── __init__.py │ └── cross_entropy.py ├── utils/ │ ├── __init__.py │ ├── metrics.py │ └── visualize.py ├── train.py ├── eval.py └── requirements.txt把注意力模块单独放在attention.py里骨干网络放在backbone.py里DANet的整体组装放在danet.py里。这样做的原因是注意力模块是DANet的核心创新点单独成文件方便你反复修改和测试骨干网络可以灵活替换比如从ResNet换成HRNet整体组装文件只负责把各个组件串起来逻辑清晰。配置文件用YAML格式把学习率、batch size、输入尺寸、数据集路径等参数都抽出来。这样你在做消融实验时只需要改配置文件不用动代码。3.3 预训练骨干网络的加载与常见陷阱DANet通常用ResNet-50或ResNet-101作为骨干网络并且会加载ImageNet上的预训练权重。这一步看似简单但有几个坑需要注意。第一个坑是权重加载时的key不匹配。PyTorch官方提供的ResNet预训练权重key的名称和你自己定义的ResNet类可能不完全一致。比如官方用的是layer1.0.conv1.weight而你自己写的可能是resnet.layer1.0.conv1.weight。解决办法是在加载时做一个key的映射或者直接用torchvision.models.resnet50(pretrainedTrue)来加载。第二个坑是加载预训练权重后骨干网络的某些层可能需要冻结。DANet论文里没有明确说是否冻结骨干网络但根据我的实验在训练初期冻结骨干网络的前几个stage只训练后面的stage和注意力模块能让训练更稳定。等loss降到一定程度后再解冻全部参数做微调。第三个坑是预训练权重的归一化参数。ImageNet预训练用的是mean[0.485, 0.456, 0.406]、std[0.229, 0.224, 0.225]的归一化你的数据预处理必须和这个一致否则预训练权重的效果会大打折扣。我一开始忘了改归一化参数结果mIoU比预期低了将近10个百分点。4. 模型代码实现中的关键细节4.1 位置注意力模块的代码实现与维度验证PAM的代码实现看起来不复杂但维度变换很容易出错。我先把核心代码贴出来然后逐行解释import torch import torch.nn as nn import torch.nn.functional as F class PositionAttentionModule(nn.Module): def __init__(self, in_channels, reduction8): super().__init__() self.in_channels in_channels self.inter_channels in_channels // reduction self.query_conv nn.Conv2d(in_channels, self.inter_channels, 1) self.key_conv nn.Conv2d(in_channels, self.inter_channels, 1) self.value_conv nn.Conv2d(in_channels, in_channels, 1) self.gamma nn.Parameter(torch.zeros(1)) self.softmax nn.Softmax(dim-1) def forward(self, x): batch_size, C, H, W x.size() N H * W # 生成Q, K, V query self.query_conv(x).view(batch_size, self.inter_channels, -1) query query.permute(0, 2, 1) # B x N x C key self.key_conv(x).view(batch_size, self.inter_channels, -1) # B x C x N value self.value_conv(x).view(batch_size, self.in_channels, -1) # B x C x N # 计算注意力图 attention torch.bmm(query, key) # B x N x N attention self.softmax(attention) # 加权求和 out torch.bmm(value, attention.permute(0, 2, 1)) # B x C x N out out.view(batch_size, C, H, W) # 残差连接 out self.gamma * out x return out这里有几个容易出错的点。第一query的permute操作view之后是B×C×N需要permute成B×N×C才能和key做矩阵乘法。第二attention的维度是B×N×N做softmax时dim-1表示对每一行做归一化也就是每个位置对所有位置的注意力权重之和为1。第三value和attention做矩阵乘法时attention需要permute成B×N×N再转置实际上attention.permute(0, 2, 1)就是转置操作。gamma参数初始化为0这是一个常见的技巧。初始为0意味着训练初期注意力模块的输出为0整个模块退化为恒等映射不会对骨干网络的预训练特征造成干扰。随着训练进行gamma逐渐增大注意力模块的作用才慢慢体现出来。4.2 通道注意力模块的实现差异与注意事项CAM的实现和PAM类似但维度变换不同class ChannelAttentionModule(nn.Module): def __init__(self, in_channels): super().__init__() self.gamma nn.Parameter(torch.zeros(1)) self.softmax nn.Softmax(dim-1) def forward(self, x): batch_size, C, H, W x.size() N H * W # 直接reshape query x.view(batch_size, C, -1) # B x C x N key x.view(batch_size, C, -1).permute(0, 2, 1) # B x N x C # 计算通道注意力图 attention torch.bmm(query, key) # B x C x C attention self.softmax(attention) # 加权求和 value x.view(batch_size, C, -1) # B x C x N out torch.bmm(attention, value) # B x C x N out out.view(batch_size, C, H, W) out self.gamma * out x return outCAM不需要额外的卷积层来生成Q、K、V直接用输入特征本身。这是CAM比PAM参数少的原因之一。另外CAM的注意力图是C×C的计算量远小于PAM的N×N。有个细节需要注意CAM的softmax也是dim-1但这里的维度含义和PAM不同。CAM的attention是B×C×Cdim-1表示对每一行每个通道对其他所有通道的注意力权重做归一化。4.3 DANet整体组装与特征融合策略把骨干网络、PAM、CAM组装成完整的DANetclass DANet(nn.Module): def __init__(self, num_classes, backboneresnet50): super().__init__() self.backbone build_backbone(backbone) in_channels 2048 # ResNet-50最后一层通道数 self.pam PositionAttentionModule(in_channels) self.cam ChannelAttentionModule(in_channels) self.conv_after nn.Sequential( nn.Conv2d(in_channels, in_channels, 3, padding1, biasFalse), nn.BatchNorm2d(in_channels), nn.ReLU(inplaceTrue) ) self.classifier nn.Conv2d(in_channels, num_classes, 1) def forward(self, x): feat self.backbone(x) pam_out self.pam(feat) cam_out self.cam(feat) fused pam_out cam_out fused self.conv_after(fused) out self.classifier(fused) out F.interpolate(out, sizex.shape[2:], modebilinear, align_cornersTrue) return out这里有几个设计选择需要解释。第一PAM和CAM是并行连接的不是串行。也就是说两个模块都接收同一个骨干网络输出各自处理后相加。第二融合后加了一个3×3卷积BNReLU这个卷积的作用是平滑两个注意力模块输出的融合结果减少相加带来的噪声。第三最后的分类器是1×1卷积输出通道数等于类别数然后双线性插值上采样到原始输入尺寸。关于上采样DANet论文里用的是双线性插值我也试过转置卷积但效果不如双线性插值稳定。转置卷积容易产生棋盘格伪影而且参数量更大。所以建议就用双线性插值简单可靠。5. 数据准备与训练配置的实战经验5.1 数据集预处理中的归一化与尺寸策略DANet原文主要在Cityscapes和PASCAL VOC上做实验。以Cityscapes为例原始图像尺寸是1024×2048直接输入网络显存肯定扛不住。常见的做法是随机裁剪到512×512或768×768或者缩放到0.5倍后再裁剪。我的做法是训练时随机缩放到[0.5, 2.0]之间的某个比例然后随机裁剪512×512。验证和测试时缩放到固定尺寸比如1024×512不做裁剪。这样既能保证训练时的数据多样性又能在验证时保持和论文一致的评估条件。归一化参数前面提过了必须和ImageNet预训练一致。另外标签图的处理也需要注意Cityscapes的标签有19个有效类别其余类别如天空、行人等在训练时通常忽略用ignore_index255来处理。5.2 学习率策略与优化器选择的对比实验DANet原文用的是SGD优化器初始学习率0.01动量0.9权重衰减0.0005。学习率策略是poly衰减即lr base_lr * (1 - iter/max_iter)^0.9。我试过Adam和SGD的对比结论是SGD确实更适合DANet。Adam在训练初期收敛更快但最终精度比SGD低1-2个百分点。可能的原因是Adam的自适应学习率在注意力模块这种参数分布不均匀的结构上表现不如SGD稳定。Batch size方面原文用的是8或16。我用单卡训练时batch size只能开到4512×512输入ResNet-50骨干显存就快满了。如果显存不够可以用梯度累积来模拟更大的batch size。比如累积4步等效batch size就是16。5.3 训练过程中的loss震荡与梯度裁剪训练DANet时我遇到的最大问题是loss震荡。具体表现是前几个epoch loss下降正常但到中间某个阶段突然飙升然后慢慢降下来反复几次。排查后发现原因有两个一是学习率太大二是注意力模块的梯度不稳定。解决办法第一把初始学习率从0.01降到0.005震荡明显减轻。第二加梯度裁剪把梯度的L2范数限制在10以内。第三在注意力模块的gamma参数上加权重衰减防止它增长过快。另外训练初期冻结骨干网络的前两个stage只训练后面的stage和注意力模块等loss稳定后再解冻全部参数。这个策略能让训练过程平滑很多。6. 推理评估与性能调优的实操要点6.1 mIoU计算中的常见错误与修正mIoU是语义分割最常用的评估指标但计算时容易出错。最常见的错误是忽略了ignore_index的像素或者把背景类也算进去了。正确的做法是只对有效类别计算IoU然后取平均。def compute_mIoU(pred, target, num_classes, ignore_index255): pred pred.argmax(dim1) ious [] for cls in range(num_classes): pred_mask (pred cls) target_mask (target cls) if target_mask.sum() 0: continue intersection (pred_mask target_mask).sum().item() union (pred_mask | target_mask).sum().item() ious.append(intersection / union) return sum(ious) / len(ious) if ious else 0.0注意如果某个类别在target中不存在应该跳过而不是算作IoU0否则会拉低整体mIoU。6.2 多尺度推理与翻转增强的效果验证DANet论文里用了多尺度推理multi-scale inference和水平翻转增强来提升性能。具体做法是把输入图像缩放到多个尺度如0.5、0.75、1.0、1.25、1.5分别推理后取平均再加上水平翻转的结果一起平均。我实测下来多尺度推理能提升1-2个百分点的mIoU翻转增强能再提升0.5-1个百分点。但推理时间会增加好几倍。如果对精度要求不是极致单尺度推理也够用。有个细节多尺度推理时每个尺度的输出需要先上采样到原始尺寸再平均否则尺寸不一致没法做平均。另外softmax后的概率图做平均比argmax后的标签图做平均效果更好因为概率图保留了更多信息。6.3 显存优化技巧从OOM到流畅训练显存不够是复现DANet时最常见的问题。我总结了几条实用的优化技巧第一用混合精度训练AMP。PyTorch的torch.cuda.amp能把显存占用降低30%-50%而且几乎不影响精度。我实测开启AMP后batch size能从4开到6。第二在PAM之前做下采样。比如把特征图从128×128降到64×64再输入PAMN从16384降到4096注意力图从2.68亿降到1677万显存占用直接降了一个数量级。当然这会损失一些空间细节需要根据任务权衡。第三用梯度检查点gradient checkpointing。这个技巧用计算时间换显存适合显存极度紧张的情况。PyTorch的torch.utils.checkpoint可以很方便地实现。第四及时释放不需要的中间变量。比如在PAM的forward里计算完attention后可以del query, key来释放显存。7. 复现过程中踩过的坑与排查思路7.1 注意力图全为同一值的诡异现象有一次训练时发现PAM的注意力图几乎全是同一个值相当于没有学到任何有意义的注意力权重。排查过程是这样的先检查输入特征发现骨干网络输出的特征值范围正常然后检查Q和K的卷积层权重发现初始化后权重值非常小导致Q和K的乘积接近0softmax后自然就均匀分布了。解决办法是调整Q和K卷积层的初始化方式用更大的初始方差。具体来说把默认的Kaiming初始化换成方差更大的初始化或者在训练初期给Q和K卷积层更高的学习率。另外gamma参数初始化为0虽然能稳定训练但也可能导致注意力模块学得太慢可以改成初始化为0.1。7.2 训练精度远低于论文报告值的排查链路我第一次跑通训练后mIoU只有论文报告值的70%左右。排查链路如下第一步检查数据预处理。发现验证集的归一化参数用错了改过来后mIoU提升了5个百分点。第二步检查学习率策略。发现poly衰减的指数设成了0.9但实际代码里写的是1.0改过来后提升了3个百分点。第三步检查损失函数。发现用了普通的交叉熵但没有设置类别权重Cityscapes里某些类别样本很少导致这些类别的IoU很低。加上类别权重后提升了4个百分点。第四步检查预训练权重加载。发现骨干网络的某些层没有正确加载预训练权重修复后提升了6个百分点。经过这四步排查mIoU终于接近论文报告值了。这个过程让我深刻体会到复现论文时细节决定成败。7.3 多卡训练时的同步BN问题与解决方案如果你用多卡训练同步BNSyncBN是一个绕不开的话题。普通BN在每张卡上独立计算均值和方差当batch size较小时统计量不准确。SyncBN跨卡同步计算能显著提升小batch size下的训练效果。PyTorch从1.5版本开始支持SyncBN用法是把nn.BatchNorm2d替换成nn.SyncBatchNorm。但要注意SyncBN需要NCCL后端支持而且会带来一定的通信开销。我实测下来用SyncBN后mIoU能提升2-3个百分点训练时间增加约10%。另一个多卡训练的坑是数据加载器的sampler。默认的DistributedSampler会把数据均匀分配到每张卡上但如果数据集大小不能被卡数整除最后几张卡可能会拿到空数据。解决办法是设置drop_lastTrue或者用DistributedSampler的pad选项。8. 关于DANet复现的个人体会与后续扩展方向复现DANet这件事让我对注意力机制在语义分割中的应用有了更具体的理解。论文里的公式和结构图看起来简洁优雅但真正落地时每一个维度变换、每一个初始化策略、每一个超参数的选择都会对最终结果产生实实在在的影响。我个人最大的体会是不要一上来就追求完全复现论文的所有细节。先把最核心的PAM和CAM跑通用一个小数据集比如PASCAL VOC的少量样本验证模型能正常前向和反向传播然后再逐步加上多尺度训练、数据增强、学习率策略等优化。这样出问题时容易定位不会一锅粥。后续如果想进一步扩展有几个方向可以考虑。一是把骨干网络从ResNet换成更高效的架构比如HRNet或EfficientNet看看注意力模块和不同骨干的兼容性。二是把DANet的思想迁移到其他任务上比如目标检测或实例分割注意力模块的设计思路是通用的。三是尝试更高效的注意力计算方式比如用线性注意力或稀疏注意力来降低PAM的显存和计算开销。最后分享一个小技巧在调试注意力模块时可以把注意力图可视化出来看看。如果注意力图呈现出有意义的空间结构比如集中在目标物体上说明模块学到了东西如果是一片均匀的灰色那大概率是没学好。可视化工具用matplotlib或opencv都行把注意力图归一化到0-255然后叠加到原图上一目了然。