ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ResNet结合CBAM注意力机制:遥感图像分类消融实验与工程避坑指南

ResNet结合CBAM注意力机制:遥感图像分类消融实验与工程避坑指南 简介面向遥感卫星土地利用图像分类的改进残差网络实战资源以21类土地目标数据为基础适合想提升分类精度并入门注意力机制融合的深度学习学习者。代码涵盖残差网络常见版本可在每个残差层后灵活插入CBAM模块注释掉不需要的层即可也支持替换为其他注意力模块训练部分提供迁移学习、仅训练分类层、Adam与SGD对比、交叉熵损失以及Cosine余弦退火等配置方便开展消融实验。资源包为zip压缩包共2000个文件以1994张土地影像为主另含3个Python训练与推理脚本、1个readme说明、1个txt提示和1个json日志整体大小27.79MB影像与代码分离目录结构清晰。训练过程中同步评估训练集和验证集输出loss、准确率、混淆矩阵、召回率、精确率、F1分数、特异度等指标并生成对应曲线推理时只需将图片放入指定目录更换数据集按readme重新摆放即可。目前已有71人学习下载适合需要完整基准流程和模块化改进范式的图像分类项目开发者。1. 遥感图像分类为什么要给 ResNet 加 CBAM从 layer 语义混淆说起遥感卫星影像的土地利用分类里一个很常见的翻车现场是普通 ResNet 在 21 类目标上跑到第 60 个 epoch验证集准确率就卡住不涨了尤其是 mobilehomepark 这类低密度住宅和 buildings 这类密集建筑区两个类别在深层的特征图几乎长得一样。把 CBAM 加在每一个 layer 之后用通道注意力告诉网络“该看什么”用空间注意力告诉网络“该往哪看”可以在不显著增加参数量和推理时间的前提下把类别边界拉得更开。这份项目资源就是把 ResNet18/34/50/101/152 全部包好 CBAM 的实战代码训练、验证、推理闭环齐全适合正在做图像分类消融实验、遥感分类论文的人直接拿去做对比。2. 把 CBAM 挂进每个 ResNet layer模块结构、挂载点与替换接口2.1 ResNet 家族选型18/34/50/101/152 的深度和显存代价项目里把 ResNet18、34、50、101、152 都列了可用选项这个设计很实用。遥感土地利用分类的输入通常是 224×224 或 256×25621 个类别在语义上并不算特别多的分类任务真正难的是类别间相似度高。比如 barren 和 scrubland 在可见光影像上经常混在一起靠浅层纹理分不开。选型时我一般按显存和训练时间来决定ResNet18 只有约 11M 参数V100 上 batch size 64 训练 200 epoch 很快适合先做代码调试ResNet50 是最常用的 baselinePyTorch 官方预训练权重质量也最好ResNet101 在遥感场景下往往比 ResNet50 高 12 个点但显存占用和训练时间会翻一倍。ResNet152 在这种数据量下几乎没有收益反而更容易过拟合除非你手上的遥感影像超过十万张。这里有个容易忽略的点ResNet 的每一个 layer 输出的特征图分辨率和通道数都不一样。layer1 输出 56×56×64语义是低层边缘纹理layer4 输出 7×7×2048语义是类别级别的抽象特征。CBAM 加在每个 layer 之后本质上是对当前尺度的特征做重标定所以不同 layer 上的注意力权重含义是不同的。2.2 CBAM 内部结构通道注意力在前还是空间注意力在前CBAM 的完整名称是 Convolutional Block Attention Module它由两个子模块组成通道注意力Channel Attention和空间注意力Spatial Attention。通道注意力先判断“哪个特征通道值得强调”空间注意力再判断“哪个位置的像素值得强调”。顺序必须先通道后空间因为空间注意力要拿通道注意力重标定之后的结果作为输入如果倒过来空间注意力会被原始的噪声通道带偏。通道注意力的实现用了两个池化分支平均池化和最大池化。平均池化保留背景和整体响应最大池化突出响应最强的目标区域两个分支共享同一个多层感知机输出通道权重后再相加。这样做比单独用平均池化的 SE 模块更能捕捉突出目标。空间注意力则是把通道维做均值压缩和最大值压缩拼成两个通道后过一个 7×7 卷积得到空间权重。这两个子模块的代码实现如下。import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, in_planes, ratio16): super(ChannelAttention, self).__init__() self.avg_pool nn.AdaptiveAvgPool2d(1) self.max_pool nn.AdaptiveMaxPool2d(1) # 共享 MLP先用 1x1 卷积压缩通道再用 1x1 卷积还原 self.shared_mlp nn.Sequential( nn.Conv2d(in_planes, in_planes // ratio, 1, biasFalse), nn.ReLU(), nn.Conv2d(in_planes, in_planes, 1, biasFalse) ) self.sigmoid nn.Sigmoid() def forward(self, x): avg_out self.shared_mlp(self.avg_pool(x)) max_out self.shared_mlp(self.max_pool(x)) out self.sigmoid(avg_out max_out) return out这段代码里in_planes是输入特征图的通道数ratio是压缩比例。ratio16意味着in_planes // 16如果原来是 2048 通道中间层只有 128 通道参数量很小。注意shared_mlp的名字叫 shared是因为两个池化分支用的是同一个卷积层对象梯度会同时更新这也是从 SE 模块继承来的做法。如果in_planes不是 16 的倍数比如某些自定义 backbone 里通道数是 100这里会抛错需要临时把ratio调小或者改为max(in_planes // ratio, 1)。class SpatialAttention(nn.Module): def __init__(self, kernel_size7): super(SpatialAttention, self).__init__() assert kernel_size in (3, 7), kernel size must be 3 or 7 padding 3 if kernel_size 7 else 1 self.conv nn.Conv2d(2, 1, kernel_size, paddingpadding, biasFalse) self.sigmoid nn.Sigmoid() def forward(self, x): # 沿通道维分别计算平均和最大压缩成 2 通道 avg_out torch.mean(x, dim1, keepdimTrue) max_out, _ torch.max(x, dim1, keepdimTrue) x_cat torch.cat([avg_out, max_out], dim1) attn self.sigmoid(self.conv(x_cat)) return attn空间注意力使用torch.mean和torch.max而不是池化层是因为我们要对 H×W 的每一个位置保留坐标信息。dim1是在通道维上压缩keepdimTrue保持形状为 N×1×H×W方便后续拼接。7×7 卷积的感受野较大适合遥感影像里地物边界比较长的场景如果你处理的是小目标密集图像改成 3×3 卷积会好一些。这里有个细节值得注意torch.max返回两个值第二个是索引所以必须用_占位接住。很多新手在这里直接写max_out torch.max(x, dim1)拿到的是一个元组而不是张量后续torch.cat就会报维度错误。CBAM 的总装模块非常简单。class CBAM(nn.Module): def __init__(self, in_planes, ratio16, kernel_size7): super(CBAM, self).__init__() self.channel_attention ChannelAttention(in_planes, ratio) self.spatial_attention SpatialAttention(kernel_size) def forward(self, x): x self.channel_attention(x) * x x self.spatial_attention(x) * x return xself.channel_attention(x) * x是广播乘法通道注意力的输出形状是 N×C×1×1会和特征图的每个空间位置相乘相当于给每个通道一个全局权重。后面self.spatial_attention(x) * x的权重形状是 N×1×H×W会作用到所有通道上。2.3 挂载代码在每个 layer 后调用 CBAM 的 PyTorch 实现最直接的方式是重写 ResNet 的 forward在每个 layer 输出后插入 CBAM。torchvision 的 ResNet 实现里layer1 到 layer4 分别对应四个残差阶段修改如下。import torchvision.models as models from torchvision.models.resnet import Bottleneck, BasicBlock class ResNetWithCBAM(nn.Module): def __init__(self, model_nameresnet50, num_classes21, cbam_layers(True, True, True, True)): super(ResNetWithCBAM, self).__init__() # 按名字加载预训练模型提取除全连接层以外的部分 self.backbone models.__dict__[model_name](pretrainedTrue) self.num_classes num_classes # 记录四个 stage 的输出维度 self.stage_dims [64, 128, 256, 512] if model_name in [resnet18, resnet34] else \ [256, 512, 1024, 2048] # 创建四个 CBAM对应 layer1 到 layer4 self.cbam1 CBAM(self.stage_dims[0]) if cbam_layers[0] else nn.Identity() self.cbam2 CBAM(self.stage_dims[1]) if cbam_layers[1] else nn.Identity() self.cbam3 CBAM(self.stage_dims[2]) if cbam_layers[2] else nn.Identity() self.cbam4 CBAM(self.stage_dims[3]) if cbam_layers[3] else nn.Identity() # 把原模型的分类头替换成 21 类 in_features self.backbone.fc.in_features self.backbone.fc nn.Linear(in_features, num_classes) def forward(self, x): # 使用 backbone 的 features 接口从第一层到 layer4 逐层提取 f self.backbone.conv1(x) f self.backbone.bn1(f) f self.backbone.relu(f) f self.backbone.maxpool(f) f self.backbone.layer1(f) f self.cbam1(f) f self.backbone.layer2(f) f self.cbam2(f) f self.backbone.layer3(f) f self.cbam3(f) f self.backbone.layer4(f) f self.cbam4(f) # 全局平均池化与全连接分类 f self.backbone.avgpool(f) f torch.flatten(f, 1) f self.backbone.fc(f) return f这里的cbam_layers参数是关键如果想只在 layer2 之后加 CBAM就传(False, True, False, False)其他位置会变成nn.Identity()即直接跳过。我通常会先用这种配置跑一遍确认模块可收敛再逐步打开所有位置。注意self.stage_dims在 resnet18/34 里第一个 stage 输出是 64resnet50 及以上的 Bottleneck 第一个 stage 输出是 256这是由 BasicBlock 和 Bottleneck 的通道扩展规则决定的写错会直接导致 CBAM 的卷积输入通道不匹配。这种硬编码 forward 的方式优点是清晰、可控缺点是你换用自定义 backbone 时又要改。更通用的做法是注册 forward hook对layer1、layer2、layer3、layer4分别挂 hook在 hook 里调用 CBAM。hook 方式不会改原有 forward但要注意 hook 是在 layer 输出之后立即执行不会影响后续 forward 的输入因为 CBAM 返回的是新张量需要手动写进容器。实际项目中我倾向于用重写 forward 的方式调试时打印中间维度更方便。3. 训练配置与消融实验Adam/SGD、余弦退火和迁移学习怎么配3.1 迁移学习还是只训练分类层按数据集规模决定冻结方式项目里训练脚本支持两种模式迁移学习和只训练分类层。我复现的时候发现这俩模式面对的数据规模完全不是一回事。如果你的遥感数据集有几千张图21 类里有些类别可能只有一百多张此时全量微调深层网络非常容易过拟合。常见做法是加载 ImageNet 预训练权重然后把 backbone 的requires_grad置为 False只让最后的全连接层可训练。这样可以快速验证 CBAM 模块是否真的有效因为 CBAM 挂在 backbone 内部而 backbone 被冻结所以只有 CBAM 和分类头在更新。如果这种情况下精度也涨说明注意力的确在起作用。当数据集有十万量级比如整个区域的多期影像切图就值得全量微调。ImageNet 的权重在遥感影像上其实很“偏”因为自然图像的纹理分布和遥感 top-down 视角完全不同浅层卷积核未必适航拍纹理。所以我的习惯是先跑一版只训练分类层的实验再跑一版全量微调两者都保留这样消融表里可以同时写“backbone frozen CBAM”和“full finetune CBAM”。3.2 Adam 与 SGD 双优化器的统一入口做消融实验最怕的是优化器不一致。项目里同时支持 Adam 和 SGD这很关键。我见过太多文章里模型改进前后用不同优化器最后说不清楚收益是谁带来的。工程上统一入口的方式很多最常见的是在训练脚本里用字典映射def get_optimizer(model, name, lr, weight_decay, momentum0.9): if name.lower() adam: return torch.optim.Adam(model.parameters(), lrlr, weight_decayweight_decay) elif name.lower() sgd: return torch.optim.SGD(model.parameters(), lrlr, momentummomentum, weight_decayweight_decay, nesterovTrue) else: raise ValueError(fUnsupported optimizer: {name})这里 Adam 的默认学习率我习惯设1e-4SGD 设0.01或0.005。很多人在这一点翻车拿着 Adam 的1e-4直接给 SGD 用导致前 30 个 epoch 精度几乎没有变化原地踏步。SGD 因为更新方向依赖全部样本的均值学习率通常要比自适应优化器高一个数量级而且动量加 Nesterov 在 ResNet 上有明显的收敛加速效果。代码里的nesterovTrue是加速收敛的常规设置PyTorch 的 SGD 实现在momentum 0时允许开启。如果你跑遥感数据集我建议把weight_decay从1e-4加到5e-4尤其当数据量不大时正则化能压住高层特征的抖动。3.3 损失函数与余弦退火学习率策略让收敛更稳的周期参数21 类土地分类用的是多分类交叉熵在 PyTorch 里就是nn.CrossEntropyLoss。个别类别在数据集里数量严重偏少时要给 loss 传入weight。计算weight的常见做法是用训练集里每个类别的样本数求倒数再做归一化或者开根号否则数量最少的类会被赋上过大的权重导致模型震荡。import torch import torch.nn as nn from torch.optim.lr_scheduler import CosineAnnealingLR, CosineAnnealingWarmRestarts def build_criterion(class_countsNone): if class_counts is None: return nn.CrossEntropyLoss() # 类别权重与样本数成反比做平滑 weights 1.0 / torch.tensor(class_counts, dtypetorch.float32) weights weights / weights.sum() return nn.CrossEntropyLoss(weightweights) def build_scheduler(optimizer, epochs, warm_restartFalse): if warm_restart: # T_0 是第一个周期的长度T_mult 是后续周期倍率 return CosineAnnealingWarmRestarts(optimizer, T_020, T_mult2, eta_min1e-6) return CosineAnnealingLR(optimizer, T_maxepochs, eta_min1e-6)余弦退火的核心思想是让学习率先保持在一个较高水平再按余弦曲线平滑降到接近 0。CosineAnnealingLR的T_max一般等于总 epoch 数比如 200那么学习率会在第 0 个 epoch 从初始值开始到第 200 个 epoch 降到eta_min。这样的曲线比较单调适合训练流程固定的实验。如果使用CosineAnnealingWarmRestarts学习率会周期性上升常用于让模型跳出局部最优。做消融实验时我建议Adam 余弦退火的初始学习率保持在1e-3到1e-4之间SGD 余弦退火初始学习率从0.005开始最多上调到0.02。注意在训练日志里把当前学习率每个 epoch 写出来方便后面复盘是不是学习率太低导致精度停住。4. 验证集同步评估混淆矩阵、F1 与训练日志 JSON 的完整读取路径4.1 训练循环中边训练边评估的实现位置很多训练脚本只在训练结束后跑一次验证但项目里强调训练同时会对训练集和验证集评估。边训练边评估在工程上不是一个高深的事关键是把评估代码封装成函数避免在训练循环里堆一堆指标计算逻辑。import json import torch from sklearn.metrics import confusion_matrix, precision_recall_fscore_support def evaluate(model, dataloader, criterion, device): model.eval() all_preds [] all_labels [] total_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in dataloader: images images.to(device) labels labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) preds torch.argmax(outputs, dim1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) correct (preds labels).sum().item() total labels.size(0) avg_loss total_loss / total acc correct / total return avg_loss, acc, all_preds, all_labels这段代码返回了预测值和真实标签后面的混淆矩阵和各类别指标都要基于这两个数组计算。注意total_loss累加时乘了images.size(0)防止不同 batch 大小不均导致 loss 平均失实。model.eval()和torch.no_grad()缺一不可尤其是 ResNet 里的 BatchNorm 层在训练和eval模式下的统计量行为完全不同如果漏掉model.eval()验证集的 BN 会沿用训练批次的统计量结果会虚高。调用evaluate的时机我一般写在每个 epoch 结束后并把指标写入日志文件。4.2 从 JSON 日志还原评价曲线与混淆矩阵项目里要求训练日志存成 JSON。这样做的好处是方便后续绘制曲线也方便把 ResNet18 和 ResNet101 的实验结果摆在同一张图里对比。我的写法是把每个 epoch 的指标追加到一个列表最后统一输出成 JSON 文件。def log_metrics(epoch, train_info, val_info, log_path): entry { epoch: epoch, train_loss: train_info[loss], train_acc: train_info[acc], val_loss: val_info[loss], val_acc: val_info[acc], val_confusion_matrix: val_info[confusion_matrix], val_precision: val_info[precision], val_recall: val_info[recall], val_f1: val_info[f1], val_specificity: val_info[specificity], lr: val_info[lr] } with open(log_path, a, encodingutf-8) as f: f.write(json.dumps(entry) \n)每个条目单独一行是 JSON Lines 格式读取时逐行解析不容易因为一次损坏丢失整份文件。如果你要用 TensorBoard也可以在这个函数里同步写入。4.3 指标计算里的特异度与 macro/micro 差异多分类情况下特异度不是直接把二分类公式套上去需要为每个类别做 One-vs-Rest 计算。对类别 i 而言i 为正类其余 20 个类别都归为负类然后按 TN / (TN FP) 计算。代码里通常会写一个循环import numpy as np def compute_specificity(conf_matrix): n_classes conf_matrix.shape[0] specificity_list [] for i in range(n_classes): tn conf_matrix.sum() - conf_matrix[i, :].sum() - conf_matrix[:, i].sum() conf_matrix[i, i] fp conf_matrix[:, i].sum() - conf_matrix[i, i] specificity_list.append(tn / (tn fp) if (tn fp) 0 else 0.0) return np.mean(specificity_list)这里的tn含义是真实属于其他类别且预测也不等于类别 i 的样本数。conf_matrix.sum() - conf_matrix[i, :].sum()是用全体总数减去第 i 行所有样本剩下的就是真实非 i 的样本再减去第 i 列中真实非 i 的样本才是不属于 i 且正确排除的部分。很多人在这一步直接拿 confusion_matrix 对角线求平均算出来的特异度明显偏高因为这个类别的负样本实在太多了。F1 和 recall、precision 用sklearn.metrics.precision_recall_fscore_support计算注意参数average选择。对 21 类地物我一般同时输出macro和weighted两个值macro 直接对 21 个类别取平均每个类权重相同weighted 按类别样本数加权更能反映整体表现。做消融实验时优先看 macro F1因为它不会被占比大的类别稀释。5. 复现避坑遥感二十一分类里被我踩过的几个细节5.1 现象验证集准确率比训练集还高我最早复现的一版里验证集 acc 比训练集高出 5 个百分点训练 loss 一直降但验证 loss 保持平稳。原因不是模型玄学而是代码里在训练集评估时也用了model.eval()没有在评估完切回train模式。BatchNorm 在eval模式下用了全局统计量自然比训练模式下更稳定但这样失去了 BN 在训练阶段的随机正则化效果所以训练 acc 偏低。解决方法是训练循环里每跑完一个 batch 就显式调用model.train()确保下一次迭代的 BN 统计量是真实的批内统计量。5.2 现象冻结 backbone 后 loss 波动剧烈精度不上升只训练分类层时我把backbone.requires_grad_(False)但漏掉了 ResNet 原始分类头之外新加的 CBAM 模块。CBAM 内部的卷积层初始权重是随机的backbone 冻结后它强行改变 layer 输出的特征分布导致全连接层接收的输入一直在变。解决方法是把四个 CBAM 模块的requires_grad单独设置为 True同时确保 backbone 所有的 BN 层也冻结特别是 BN 的 running_mean 和 running_var 不能更新否则即使requires_gradFalseBN 统计量在训练阶段仍会继续变化间接影响特征分布。5.3 现象最后一个周期 loss 突然升高F1 反而下降余弦退火在末尾会把学习率降到接近 0这时模型开始在极小步长下“磨”局部最优理论上 loss 应该平缓下降。我遇到过一次 epoch 199 的 loss 比 epoch 190 高查日志发现是eta_min1e-9设得太小学习率已经低于浮点精度能表达的有效步长模型参数几乎不更新但 BN 的滑动平均还在动导致统计量和当前 batch 不匹配。解决方法是把eta_min设为1e-6并且最后一个 10 个 epoch 用固定学习率1e-5做微调这一步对小数据集帮助很大。5.4 现象推理精度比验证低 4%错的全是边缘像素密集的类验证集在训练流程里走的是预处理 pipeline包括随机裁剪、翻转、归一化推理时我图省事只做了 resize 和归一化结果精度掉了。尤其像mobilehomepark这类由规则小方块组成的类别对几何变形敏感训练时随机裁剪让它见过略变形的特征推理时如果直接拉伸长宽比就会扰乱空间注意力。解决方法是推理阶段严格使用中心裁剪保持和训练时一致的 resize 尺寸和归一化均值方差不要用简单的cv2.resize一把梭。5.5 现象特异度 99%但其实有一类类别完全没预测出来21 类里如果有某类只占训练集 3%按 One-vs-Rest 计算特异度时真实负样本占比 97%即使模型把所有样本都预测为其他类别特异度依然很高。只看特异度会得到“改进有效”的错觉。解决方法是把 validation 结果里的类别级 F1 打印出来特别关注样本数最少的 5 个类。如果发现某个类别的 recall 为 0需要给这个类别单独增加采样权重或者在数据读取时开启WeightedRandomSampler。6. 从 CBAM 到 SE/ECA 的替换接口与最终验收方法6.1 统一注意力模块的替换接口项目里为了方便做对比把 CBAM 设计成了可替换模块。我的做法是在ResNetWithCBAM里额外加一个attn_module参数传入构造好的注意力类而不是直接复制四个 CBAM 定义。class ResNetWithAttn(nn.Module): def __init__(self, model_nameresnet50, num_classes21, attn_factoryCBAM, attn_layers(True, True, True, True)): super().__init__() self.backbone models.__dict__[model_name](pretrainedTrue) self.stage_dims [256, 512, 1024, 2048] if 50 in model_name or 101 in model_name else [64, 128, 256, 512] self.attn1 attn_factory(self.stage_dims[0]) if attn_layers[0] else nn.Identity() self.attn2 attn_factory(self.stage_dims[1]) if attn_layers[1] else nn.Identity() self.attn3 attn_factory(self.stage_dims[2]) if attn_layers[2] else nn.Identity() self.attn4 attn_factory(self.stage_dims[3]) if attn_layers[3] else nn.Identity()这样把 CBAM 换成 SE 时只需要写一个SE类再传入attn_factorySE训练脚本和评估流程完全不用改。我在 SE 和 ECA 上都试过一轮CBAM 在这个 21 类遥感数据集上 macro F1 最高ECA 的收益最小原因是遥感地物的空间结构比通道结构更重要CBAM 的多尺度空间注意力正好补上了这一点。6.2 最终验收方法同随机种子下的消融对比做注意力模块对比实验时很多人只跑一次实验就得出结论这不够稳。我现在的习惯是每个模块跑三次固定相同的随机种子取均值作为最终指标。具体做法是在炼丹前设置torch.manual_seed(42)和np.random.seed(42)同时固定 DataLoader 的shuffle顺序。这样 baseline、ResNetCBAM、ResNetSE 三组结果可以直接对比差异超过 0.5 个点才说明改动有效低于这个阈值基本是随机波动。从那以后我每次做注意力改进都会强制走一遍同样的流程先跑 baseline再跑 CBAM最后换 SE三组实验共用一套优化器、学习率退火策略和数据增强参数。这个方法让我在遥感图像分类的论文实验里少走了很多弯路。希望这些踩坑记录能帮到你祝你复现顺利。本文还有配套的精品资源点击获取
返回列表