简介:面向遥感卫星土地利用分类任务的ResNet系列改进实战代码包,覆盖resnet18/34/50/101/152五种主干。训练时可根据需要选择迁移学习或仅训练分类层,优化器集成Adam与SGD,损失函数采用多类别交叉熵,学习率使用余弦退火策略,方便进行对比消融实验。验证集在训练过程中同步评估,输出loss、准确率、混淆矩阵、recall、precision、F1 score与特异度等指标,并自动生成对应曲线图像;训练日志以JSON格式保存,便于按需绘制其他曲线。核心改进在ResNet每个layer后加入CBAM注意力模块,可只保留某一层后的CBAM,也可整体替换为其他注意力或模块,代码结构清晰易扩展。包内共2000个文件,以1994张JPEG土地分类图像为主,另有3个Python脚本、readme说明、JSON日志及txt配置,压缩包大小27.79MB,数据覆盖21种土地目标。目前已有71人学习下载,适合需要快速上手注意力机制增强图像分类模型的研究者与开发者。
1. 遥感土地利用分类为何要给 ResNet 每个 layer 后加 CBAM
一张 512×512 的遥感图里,农田边界和裸地经常只有色调深浅的差别,水体在大尺度下是一整片、切成小 patch 后又是零碎的沟渠,ResNet 这种靠卷积堆深度的骨干网络做土地利用分类时总显得“看不清重点”。给 ResNet 的每个 layer 后串一个 CBAM 注意力模块,是在不改主干、不换 Transformer 的前提下,用很少的参数量把“该看哪些通道、该聚焦哪个位置”直接教给网络。这个方案适合手里有几千到几万张标注图、希望快速在遥感卫星土地利用分类上提点的一线工程师和研究生。
2. 读懂 CBAM 的两个子模块:通道先行的注意力为什么适合遥感地物
2.1 通道注意力与空间注意力:CBAM 在计算什么
CBAM 全称是 Convolutional Block Attention Module,设计上很朴素:先做通道注意力,再做空间注意力,两个子模块串行,输出一个和输入形状完全相同的重标定特征。通道注意力部分对输入特征图分别做全局平均池化和全局最大池化,得到两个 1×1×C 的描述子,送进一个共享的两层 MLP,加和后再过 Sigmoid,得到 1×1×C 的通道权重。
空间注意力部分则是在通道维上对特征图做平均和最大压缩,拼成一个 2×H×W 的“双通道”描述,用一个 7×7 卷积降成 1×H×W,再过 Sigmoid 得到空间权重。整个过程可以写成:F1 = Mc(F) ⊗ F,F2 = Ms(F1) ⊗ F1。注意 CBAM 训练初期权重都接近 1,所以它不会像 BN 那样剧烈改变特征分布,这也是它能直接插进预训练模型的原因之一。
回到遥感土地利用场景:农田、草地、林地之间纹理差异大,但颜色相近的类别容易混淆,通道注意力会告诉网络“这个地块更依赖红光波段还是近红外波段的响应”;建筑和裸地的边界模糊,空间注意力则负责把注意力集中在“地块内部”而不是路网和阴影边缘。相比只有通道注意力的 SE 模块,CBAM 多出来的这一路空间注意力,恰好补上了遥感地物对“位置感”的需求。参数开销上,以 ResNet18 为例,完整 CBAM 插在四个 stage 后,新增参数约 0.4M,对比模型本身的 11.7M 几乎可以忽略。
2.2 “每个 layer 后加”到底是加在哪一级
标题里“每个 layer 后加入 CBAM”这个说法,在实操中其实有歧义。ResNet 的 layer 通常指 torchvision 实现里的 layer1 到 layer4,也就是模型结构上的 4 个 stage,每个 stage 里包含若干个 BasicBlock 或 Bottleneck。把 CBAM 加在 stage 尾部,是参数效率最高的做法;而如果理解为“每个 BasicBlock 的第二个卷积后再加”,参数量和显存都会明显上涨,训练时间也拉长。
我一般默认“每个 layer 后”就是 stage 后。下面是三种常见加法的对比。
| 加插位置 | 新增参数(ResNet18) | 显存影响 | 实测效果 | 适用场景 |
|---|---|---|---|---|
| 每个 stage 后(layer1~4 尾部) | 约 0.4M | 小 | 稳定提升 0.5%~2% | 图像分类,推荐首选 |
| 每个 BasicBlock 后 | 约 1.2M | 明显 | 容易过拟合,收敛不稳 | 小数据集不建议 |
| 只在 layer3、layer4 后 | 约 0.2M | 很小 | 提升幅度接近全加 | 计算资源紧张时 |
stage 后加还有一个好处:预训练权重完全不受影响。因为 CBAM 是额外 add_module 进去的,原始卷积和 BN 层的权重路径没有被改写,加载 ImageNet 权重时不会出现 missing key 或 shape 不匹配。这一点对遥感这种“预训练权重决定上限”的任务尤其重要。
2.3 为什么不是自注意力或 FPN:CBAM 的取舍
近两年图像分类模型的热点已经偏向 Transformer,自注意力机制确实擅长捕捉粗粒度到细粒度的长程依赖,但它在遥感土地利用任务上有一个现实门槛:数据量。ViT 类模型在 ImageNet 上至少需要几千万张图才能训出好权重,遥感切块数据往往只有几千到几万张,直接微调很容易过拟合,而且 patch 化会把地物的边界打碎,位置编码也要重新适应遥感图的分布。
FPN 是另一条思路,但它是检测框架里的多尺度特征融合结构,用于分类任务需要额外加分类头,改动远大于一个 CBAM。CBAM 的好处是即插即用,不改变数据流形状,训练策略和原来几乎一样,本质上是用局部注意力去替代全局自注意力的“大部分收益”。在样本有限、算力有限的前提下,先把 CBAM 加进 ResNet 是性价比最高的第一步。
3. 手写 ResNet+CBAM:三个代码块把注意力插进每个 layer 后
3.1 定义 CBAM 模块:通道注意力与空间注意力的最小实现
下面是基于 PyTorch 的标准 CBAM 实现,我习惯把通道注意力和空间注意力拆成两个子类,调试时可以直接单独看某一层输出。
import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, in_planes, ratio=16): super().__init__() self.avg_pool = nn.AdaptiveAvgPool2d(1) self.max_pool = nn.AdaptiveMaxPool2d(1) self.shared_mlp = nn.Sequential( nn.Conv2d(in_planes, in_planes // ratio, 1, bias=False), nn.ReLU(inplace=True), nn.Conv2d(in_planes // ratio, in_planes, 1, bias=False), ) 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)) return self.sigmoid(avg_out + max_out)in_planes 是输入特征图的通道数,ratio 决定中间瓶颈维度。ResNet18 的 layer1 输出 64 通道,64 // 16 = 4,不会出现 0 维;但如果你把 ratio 改成 64,64 // 64 = 1,也还成立,再小就会出问题。这里共享同一个 MLP 对 avg 和 max 两个分支做映射,是论文里的标准写法,我自己实践时发现 max_pool 分支在遥感图里更重要,因为农田边界和裸地往往是局部极值特征。
空间注意力模块代码如下:
class SpatialAttention(nn.Module): def __init__(self, kernel_size=7): super().__init__() padding = kernel_size // 2 self.conv = nn.Conv2d(2, 1, kernel_size, padding=padding, bias=False) self.sigmoid = nn.Sigmoid() def forward(self, x): avg_out = torch.mean(x, dim=1, keepdim=True) max_out, _ = torch.max(x, dim=1, keepdim=True) x_cat = torch.cat([avg_out, max_out], dim=1) return self.sigmoid(self.conv(x_cat))kernel_size=7 是论文默认值,7×7 卷积的感受野约等于 7×7 像素,对 224×224 输入来说足够覆盖常见地物。如果遥感切块比较小(比如 64×64 的 patch),7×7 就显得过大,我会改成 3,见第 6 章。
组合起来就是:
class CBAM(nn.Module): def __init__(self, in_planes, ratio=16, kernel_size=7): super().__init__() self.channel_attn = ChannelAttention(in_planes, ratio) self.spatial_attn = SpatialAttention(kernel_size) def forward(self, x): x = self.channel_attn(x) * x x = self.spatial_attn(x) * x return xforward 里的乘法和原特征做的是逐元素相乘,CBAM 的输出形状和输入完全一样,所以它能插入网络任何位置而不影响后续张量尺寸。
3.2 把 CBAM 插进 ResNet:先加载预训练权重再 add_module
这里有一个容易翻车的顺序问题:如果你先把 CBAM 写进 ResNet 结构里再去加载torchvision预训练权重,load_state_dict会因为新增模块报 missing key;正确做法是先用官方 API 拿到完整权重,再往模型上挂模块,这样 ResNet 本体的权重一条都不需要改动。
import torchvision.models as models # 这一步拿到的是标准 ResNet18 + ImageNet 预训练权重 model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) def add_cbam_to_resnet(model, add_after_layer=(True, True, True, True)): cbam_channels = [64, 128, 256, 512] # resnet18/34 的四个 stage 输出通道 layers = [model.layer1, model.layer2, model.layer3, model.layer4] for i, (layer, ch) in enumerate(zip(layers, cbam_channels)): if add_after_layer[i]: # 在 stage 的尾部追加 CBAM,不改变任何已有层的权重 layer.add_module(f"cbam_{i+1}", CBAM(in_planes=ch, ratio=16, kernel_size=7)) return model model = add_cbam_to_resnet(model)add_module的作用是往layer1这个Sequential容器里追加一个子模块。以 ResNet18 为例,原来layer1包含两个BasicBlock,追加后变成三个子模块,数据流会按顺序执行 block1 -> block2 -> cbam_1。cbam_1 的输入输出都是 64 通道,不改变 residual 结构。这样实现比修改torchvision源码里的BasicBlock干净得多,也方便随时通过add_after_layer开关只加后几个 stage。
对于 ResNet50,四个 stage 输出通道是 256、512、1024、2048,代码里只需把cbam_channels换掉,其他逻辑不变。参数初始化的部分不需要额外处理,add_module新增的模块会默认使用 PyTorch 的默认初始化,CBAM 初始输出接近 1,不会在第一个 epoch 就冲乱主干特征。
3.3 验证前向与参数量:跑通最小测试再进训练
模型改完先别急着训,用随机张量走一遍前向,同时对比参数量变化,这一步能拦截绝大多数“结构改错但没报错”的玄学问题。
# 前向验证 model.eval() with torch.no_grad(): out = model(torch.randn(1, 3, 224, 224)) print(out.shape) # 期望输出 torch.Size([1, 1000]) # 参数量对比 total_params = sum(p.numel() for p in model.parameters()) trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad) print(f"total: {total_params / 1e6:.2f}M, trainable: {trainable_params / 1e6:.2f}M")如果 output 的最后一维不是 1000,说明 FC 头被改动了;如果参数量和原始 ResNet18 的 11.7M 差出好几倍,说明 CBAM 被加到循环里重复堆叠了。正常加在四个 stage 后,ResNet18 总参数量约 12.1M,新增 0.4M 左右。这个测试数据也可以直接写进实验记录,后面对照“加 CBAM 到底贵了多少”就不用现算。
4. 遥感土地利用分类的训练配置:数据目录、增强与超参数一次调到位
4.1 数据目录与样本划分:按图幅切块是底线
土地利用分类常见类别包括农田、森林、草地、水体、建筑、裸地六类,数据按 ImageFolder 组织最省事。有一点要从一开始就注意:要按原始遥感图幅来划分训练集和验证集,而不是把一张大图切出的所有 patch 随机打散。否则同一个地块的纹理会被模型记进权重里,验证集虚高两三个点都不奇怪。
data/ train/ farmland/ forest/ grassland/ water/ building/ bareland/ val/ farmland/ forest/ ...每个类别下放经过筛选的切块,切块尺寸常用 224×224 或 256×256。多光谱数据输入通道不是 3 时,预训练权重不能直接用,要么训练时只取 RGB 三波段,要么把第一个卷积层单独处理,这部分细节放在第 5 章避坑清单里。数据集来源建议直接选用公开遥感分类数据集,网上图像分类数据集下载渠道很多,但自己抓图会引入标注不一致和传感器差异,这两个问题比模型结构更难处理。
4.2 数据增强:强一点的几何增强对注意力更友好
遥感图没有“上下颠倒”的概念,所以翻转可以放开用。增强策略表如下。
| 增强操作 | 参数建议 | 说明 |
|---|---|---|
| RandomResizedCrop | scale=(0.5, 1.0), size=224 | 强制模型从不同尺度学地物 |
| RandomHorizontalFlip | p=0.5 | 通用增强,稳定收敛 |
| RandomVerticalFlip | p=0.5 | 遥感图特有,不破坏语义 |
| ColorJitter | brightness=0.2, contrast=0.2 | 数值不宜过大,破坏光谱特征 |
| Normalize | ImageNet 均值/方差 | 搭配预训练权重的标配 |
ColorJitter 是双刃剑。遥感地物的光谱反射率是有物理意义的,调太多会让水体发绿、植被发黄,反而引入噪声。我一般把 brightness 和 contrast 都压在 0.2 以内,saturation 不动。加 CBAM 之后模型对空间位置更敏感,RandomResizedCrop 的尺度扰动可以适当调强一点,防止注意力被“地块边缘”这种固定特征骗走。
4.3 训练脚本与超参数:小学习率配 cosine 足够
ResNet + CBAM 的训练策略和纯 ResNet 几乎一致,唯一要注意的是 CBAM 是随机初始化的,初始学习率不宜过大。用预训练权重时我一般用 0.005,batch size 64,能在前 5 个 epoch 里让 CBAM 平稳进入工作状态。
import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms transform_train = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.5, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandomVerticalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_set = datasets.ImageFolder("data/train", transform=transform_train) val_set = datasets.ImageFolder("data/val", transform=transform_val) train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=8, pin_memory=True) val_loader = DataLoader(val_set, batch_size=64, shuffle=False, num_workers=8, pin_memory=True) criterion = nn.CrossEntropyLoss(label_smoothing=0.1) optimizer = torch.optim.SGD(model.parameters(), lr=0.005, momentum=0.9, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) model = model.cuda() for epoch in range(30): model.train() for x, y in train_loader: x, y = x.cuda(), y.cuda() out = model(x) loss = criterion(out, y) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() # 验证代码省略,记录 val acc 和各类别 F1label_smoothing=0.1对遥感粗标注特别有用,因为真实标注里混着很多过渡地类,硬标签很容易让模型变得过度自信。CosineAnnealingLR 的T_max=30对应训练轮数;如果你把调度改成按 iteration 更新,T_max需要改成 30×len(train_loader),两种写法不要混用,否则学习率会掉得太快。
4.4 验证指标:总体精度之外一定要看类别 F1
土地利用分类只报 overall accuracy 是不够的,水体、建筑这类类别样本往往偏少,大类准确率高会把小类的恶化掩盖掉。我在训练时每个 epoch 都记录宏平均 F1 和每类 F1,尤其关注 building 和 bareland 这对“易混淆对”。加了 CBAM 之后常见的变化是:总体 acc 提升 1% 左右,但 building 和 water 的 F1 拉开差距,这说明空间注意力开始把边界类地物和内部均质地物分开处理了。如果只是 acc 小幅涨而 F1 没动,那多半是数据划分泄露带来的虚涨,不是模型的功劳。
5. 加 CBAM 后训练与评估的避坑清单:从掉点到 OOM 的五个现象
5.1 加了 CBAM 准确率反而下跌
现象:完整加在四个 stage 后,val acc 比纯 ResNet 还低 1~2 个点,loss 曲线也不怎么降。
原因:最常见是 CBAM 加得太“满”。layer1 和 layer2 的浅层特征主要表达边缘、纹理,这些低层特征本身空间分辨率高,强注意力会把浅层特征过度调制,反而干扰后续层。另一个原因是学习率偏大,随机初始化的 CBAM 在初期被 SGD 大步长推偏,主干也被带歪。
解决:先只在 layer3、layer4 后加 CBAM,用第 3 章代码里的add_after_layer=(False, False, True, True);学习率从 0.005 降到 0.003,并加 3 个 epoch 的线性 warmup。绝大多数数据集上,只加后两个 stage 的表现不比四个 stage 差,训练还更快。
5.2 遥感数据“地点泄露”导致验证集虚高
现象:随机划分时 val acc 92%,看起来效果很好;一旦按图幅划划分,同一张原始大图的 patch 全进同一个集合,准确率掉到 78%。
原因:随机划分把同一个地块的相邻切块同时放进了训练集和验证集,模型记住的是“这张图的色调纹理”而不是“这类的通用特征”。这在遥感数据集里是普遍存在的黑匣子问题,很多公开数字好看,落地到新区域就翻车。
解决:划分数据时以图幅 ID 为最小单位。先把一张原始卫星图按滑动窗口切块,记录每个 patch 的父图编号,然后按“图幅”分组做 train/val split,保证同一个图幅的 patch 不会同时出现在两侧。这条和 CBAM 无关,但如果不先堵住,所有“CBAM 提升”的结论都是假的。
5.3 多光谱四通道输入怎么处理
现象:数据集是 RGB+NIR 四波段,把第一个卷积层改成输入 4 通道后,loss 卡住不降,训练几个 epoch 后 val acc 仍然接近随机。
原因:直接改model.conv1的in_channels=4后,这一层权重随机初始化,ImageNet 预训练权重的信息全部失效,等于让模型从零学第一个卷积层。CBAM 本身没问题,问题出在主干入口。
解决:常见的做法是用 3 通道预训练权重初始化 RGB 部分,新增加的第 4 个通道用 RGB 权重的均值去填。代码上可以这样处理:
old_conv1 = model.conv1.weight.data # shape [64, 3, 7, 7] new_conv1 = torch.zeros(64, 4, 7, 7) new_conv1[:, :3, :, :] = old_conv1 new_conv1[:, 3, :, :] = old_conv1.mean(dim=1) model.conv1 = nn.Conv2d(4, 64, 7, stride=2, padding=3, bias=False) model.conv1.weight.data = new_conv1也可以选择只训第一个 conv 层而冻结其余层跑几个 epoch,再全部解冻。这种“半冻结”策略能明显减少多光谱入口带来的训练震荡。既然用到了多光谱,数据增强里的 ColorJitter 更不建议开大,近红外通道的数值扰动会让植被类地物特征失效。
5.4 显存溢出:batch 64 训不动
现象:ResNet18 原本 batch 64 跑得好好的,加上 CBAM 后同一个 batch 直接 OOM。
原因:CBAM 里的 7×7 空间注意力卷积虽然参数量小,但它在每个 stage 结束后都保留了一份完整的 H×W 中间激活用于反向传播。四个 stage 的特征图分辨率逐级减半,浅层的 64 通道 56×56 特征图被多保留了一份,显存峰值就上去了。
解决:把kernel_size从 7 降到 3,能省一点计算但不会太多;更有效的是减少插入位置,只加(False, False, True, True),浅层不保留额外激活。还有一招是把 batch size 降到 32,并用梯度累积模拟 64 的等效 batch。如果显存还是很紧,可以考虑对 CBAM 的 forward 使用torch.utils.checkpoint,用计算换显存,但这个操作会拖慢训练。
5.5 水体精度特别差,森林却很高
现象:森林类别 F1 0.94,水体只有 0.61,训练曲线显示 water 的 loss 一直很高。
原因:水体在遥感图里往往是大面积均质区域,经过 4 次下采样后边界信息基本丢失,CBAM 空间注意力在深层拿到的已经是低分辨率特征,很难恢复完整的水体轮廓。森林纹理丰富,即使分辨率低也能靠纹理特征区分。
解决:对这类“大目标类别”,可以在 layer4 后、全局池化前再补一个 CBAM,让空间注意力在最高的语义特征层上再做一次区域加权。另一个更直接的办法是把训练切块改成多尺度:除了 224×224 的 patch,再抽一部分 448×448 的大 patch 下采样到 224 输入,让模型在训练时见过“整片水体”的样子。我的实践经验里,最后这种尺度策略对水体类别的 F1 提升比调 CBAM 参数更明显。
6. 验证 CBAM 是否真的有效:三组消融与热力图定位
6.1 先跑三组对照,别只盯最后一轮 acc
建议至少做三组实验:纯 ResNet18 基线、ResNet18 + CBAM(后两个 stage)、ResNet18 + CBAM(四个 stage)。下面是一个典型的记录表。
| 模型 | 参数量 | val acc | macro F1 | 单 epoch 耗时 |
|---|---|---|---|---|
| ResNet18 | 11.7M | 86.2% | 79.5% | 42s |
| ResNet18 + CBAM(后两 stage) | 11.9M | 87.4% | 81.0% | 47s |
| ResNet18 + CBAM(四 stage) | 12.1M | 87.1% | 80.6% | 53s |
后两个 stage 的版本往往比四个 stage 的更好,这个现象在很多遥感数据集上都能复现。判断 CBAM 有没有用,还要看看训练前 10 个 epoch 的 loss 曲线:CBAM 版本通常下降更快,这个“前期收敛加速”比最后一轮的 acc 更能说明模块真的在学习注意力。
6.2 用 Grad-CAM 看注意力落点
消融实验数值上去了,还想确认空间注意力到底看重哪里,可以用 Grad-CAM 做可视化。下面是被简化后的核心片段。
def grad_cam(model, x, target_class): model.eval() feature = None gradient = None def hook_f(module, input, output): nonlocal feature feature = output.detach() def hook_b(module, grad_input, grad_output): nonlocal gradient gradient = grad_output[0].detach() target_layer = model.layer4[-1] # 注意此时 layer4 的最后一个子模块是 cbam_4 handle_f = target_layer.register_forward_hook(hook_f) handle_b = target_layer.register_full_backward_hook(hook_b) out = model(x) model.zero_grad() out[0, target_class].backward() handle_f.remove() handle_b.remove() weight = gradient.mean(dim=(2, 3), keepdim=True) cam = (weight * feature).sum(dim=1, keepdim=True).relu() return cam在 torchvision 的 ResNet 里,layer4[-1]不再是一个 BasicBlock 的 conv,而是我们挂上去的 CBAM,这会让热力图直接反映 CBAM 输出前的梯度分布,反而很适合观察注意力给自己留下了哪些区域。对比纯 ResNet 的热力图,CBAM 版本的热力中心通常更集中在地块内部而不是全图弥散。
6.3 一个有用的调参习惯:先大后小
最后分享一个我自己的习惯:任何新数据集上做 CBAM 实验,都先把ratio固定 16、kernel_size固定 7、只放在层3和层4后跑通一轮,再根据可视化结果决定要不要加到浅层。理论上 CBAM 参数不多,但它们和数据集尺度、切块策略耦合在一起,网格搜索“四个 stage 是否都加”的成本远高于它的收益。希望这些经验能帮你少走一段弯路,祝调参顺利。
本文还有配套的精品资源,点击获取