
简介面向遥感卫星土地利用分类与注意力机制改进需求这份实战资源以ResNet为基线网络实现了在resnet18/34/50/101/152等系列每个layer后插入CBAM模块的完整流程也支持只保留特定层或替换为其他注意力模块方便开展对比消融实验。压缩包共2000个文件约27.79MB其中1994张JPG为21种土地类型样本3个Python脚本涵盖训练、验证与推理另有README数据摆放说明和JSON训练日志整体结构清晰。已有71人学习下载。资源亮点在于训练环节集成了迁移学习、Adam/SGD优化器切换、交叉熵损失与余弦退火调度验证阶段同时输出loss、准确率、混淆矩阵、召回率、精确率、F1和特异度等指标并自动绘图推理时只需将图片放入指定目录即可若要更换数据集按README整理数据即可运行适合从事遥感分类或注意力机制改进的开发者快速上手和复现。1. 遥感图像分类里加CBAM要解决的从来不是“更深的网络”而是“看哪里”去年做一个遥感卫星土地利用分类项目时ResNet预训练模型已经跑到92%准确率但真正的验收卡点是一批具体误判云影下的林地和灌丛被分成两类含水土层和浅水体被混为一谈机场跑道和裸土地在低分辨率影像上几乎无法区分。这类问题不是网络深度不够而是特征在全局平均池化之后被“平均”掉了——模型知道哪里有树却没注意该看哪一片树。给ResNet的每个layer后接一个CBAM模块是当时投入产出比最高的一次改动用通道注意力挑出对地物敏感的波段组合用空间注意力把特征图上的关键地块点亮。本文按原理、数据、代码、训练到验证的顺序完整走一遍这条改进路线适合已经跑熟ResNet分类、正被细粒度类别混淆卡住的工程人员和研究生。2. 看懂ResNet的layer与CBAM的匹配关系4个stage输出与两种注意力的分工2.1 ResNet的4个layer不是4个卷积层而是4个stage很多人第一次看torchvision源码时会以为layer1到layer4是四个卷积层实际上它们对应残差网络里的四个stage每个stage内部由多个Bottleneck堆叠而成。以ResNet50为例224×224输入经过初始卷积和maxpool后layer1输出56×56×256layer2输出28×28×512layer3输出14×14×1024layer4输出7×7×2048。每个layer的入口处有一个stride2的下采样所以特征图分辨率逐层减半通道数逐层翻倍。stage输出尺寸224输入输出通道Bottleneck数量layer156×562563layer228×285124layer314×1410246layer47×720483“在layer后加CBAM”指的就是在每个stage整体输出之后接一个注意力模块而不是塞进某个Bottleneck内部。这两条路线的差别很大。把CBAM插进Bottleneck内部等于在残差分支上额外加了一条门控路径会改变原shortcut的数据流训出来的涨点很难说清是注意力起效还是残差结构本身被改动带来的副作用而插在每个layer输出后相当于把整个stage当成一个特征提取单元注意力只负责对单元产出的特征做加权筛选基准行为清晰、可解释性也更强。从特征传播角度看四个layer就像一条四跳链路每一跳负责把粗糙的像素纹理抽象成高级语义CBAM加在每跳出口相当于在链路层做一次可靠交接检查保证下一跳拿到的特征没有被噪声通道带偏。2.2 通道注意力与空间注意力为什么通道先行、空间后行CBAM由两个子模块串联组成前一个是通道注意力后一个是空间注意力。通道注意力对输入特征图分别做全局平均池化和全局最大池化得到两个1×1×C的向量送入一个共享MLP输出相加再过sigmoid。这里的核心是“共享”两个字——同一个MLP同时处理平均池化和最大池化两条路径目的是让网络自己决定哪种统计量对当前任务更可靠。MLP的中间层通道数通常设为C/rr是压缩比默认取16r越大参数量越少但信息瓶颈也越明显后续调参时可以尝试r8对比。空间注意力在通道注意力之后操作先在通道维上分别取均值和最大值拼接成两通道特征图再过一个7×7卷积压缩成单通道最后sigmoid后乘回原特征。为什么通道注意必须先做因为通道维度决定“看什么”空间注意力只负责“看哪里”。如果先做空间注意力通道响应还没被筛选最大池化结果很容易被某个高噪声通道污染空间定位反而更不稳定。CBAM论文里也做了消融实验通道在前、空间在后的组合效果最好这里直接采用论文顺序。这个模块的参数量很容易估算。以2048通道的layer4为例通道注意力MLP是2048→128→2048两层1×1卷积单个Bottleneck参数为2×2048×128再加空间注意力的7×7×2×1卷积总共约52.7万参数。四个layer全部加上大约0.7M参数相比ResNet50本身的25M参数只占不到百分之三可以理解成用很小的代价换一次特征修正机会。2.3 为什么说CBAM正好命中土地利用分类的痛点遥感土地利用分类和ImageNet分类有个本质区别前者非常依赖地块的空间形态。机场跑道是一条长条形的平滑区域农田是规则矩形纹理森林是离散冠层斑块这些信息在单像素上几乎看不出来要靠局部范围内的空间关系判定。ResNet的高层卷积虽然感受野足够大但最后一层特征图会直接送进全局平均池化空间位置信息在那个节点被压缩成一个1×1向量。CBAM插在每个layer之后等于在信息被池化抹平之前先把分类头最关心的位置激活保留下来。这也是它比单纯加深网络、增加宽度更对症的原因——地物分类问题往往不是特征不够抽象而是特征没有被放到正确的位置上解读。3. 卫星影像变成可训练Patch归一化、滑窗裁剪与类别均衡3.1 先确认影像位深再做百分位截断归一化普通照片是8bit数据范围0到255但光学遥感卫星影像常见的是12bit或16bit动态范围0到65535。直接把16bit影像除以255再喂给预训练ResNet不仅会压缩动态范围还会让模型看到的“亮度”分布和ImageNet完全不在一个尺度上。常见的做法是先按波段做百分位截断归一化我一般取2%到98%分位值做线性拉伸把主体地物的对比度拉满同时裁掉云层和高反射建筑带来的极端亮值。from osgeo import gdal import numpy as np def load_geotiff(path, band_indices): ds gdal.Open(path) arr ds.ReadAsArray() arr arr[band_indices].astype(np.float32) for b in range(arr.shape[0]): p2, p98 np.percentile(arr[b], [2, 98]) arr[b] np.clip((arr[b] - p2) / (p98 - p2 1e-6), 0, 1) return arrband_indices选择哪些波段取决于数据源常见做法是取R、G、B、NIR四个波段NIR对植被和农田非常敏感能在后续分类中显著区分森林和草地。percentile用2%-98%而不是min-max是因为航空影像中偶尔会出现大面积过曝或云阴影min-max会被这几个异常像素拉偏整体对比度。最后加的1e-6只是防止除零。如果影像文件非常大一次ReadAsArray可能把内存占满这时可以用gdal的ReadAsArray(xoff, yoff, xsize, ysize)按块读取本文不再展开。3.2 滑窗裁剪成256×256 patch重叠与边界处理遥感影像动辄几万像素宽不能整幅直接进网络。常规做法是用一个固定尺寸的patch按步长滑窗裁剪。patch尺寸一般选256×256或512×512分辨率越高patch可以越大在光学卫星数据上256是一个比较稳的起点既有足够的上下文信息又不会让batch size被迫调小。def sliding_window(img, patch_size256, stride128): h, w img.shape[1:] for y in range(0, h - patch_size 1, stride): for x in range(0, w - patch_size 1, stride): yield img[:, y:y patch_size, x:x patch_size]stride128时相邻patch有50%重叠一个地块在多个patch的不同位置出现模型对边界目标的鲁棒性会更好缺点是样本数量翻倍、训练时长增加。如果影像本身很充足也可以直接stride256做无重叠裁剪。我的习惯是先在少量影像上跑一次快速预实验用无重叠裁剪确认baseline再切成重叠patch看涨点幅度避免一上来就把训练时间浪费在冗余数据上。需要注意patch边缘的地物可能被截断这种样本在验证时容易造成误判因此验证集我一般使用无重叠裁剪保证评估时每个像素只出现一次。3.3 类别不均衡与数据增强森林样本太多时怎么办土地利用分类的类别分布天然是长尾的森林往往占大片面积每个patch里几乎全是树冠纹理农田、裸地、不透水面则呈现细碎斑块。如果直接把所有patch按顺序参加训练模型会严重偏向多数类别这也是森林图像分类任务里最常见的翻车原因——整体准确率很高但灌木、湿地等少数类别几乎全被吃掉。解决思路有两个维度损失函数加权和采样器加权。加权交叉熵比较简单直接给少数类别更大的权重采样器则在数据加载阶段控制每个类别的出现概率。from torch.utils.data import WeightedRandomSampler import torch all_labels torch.tensor(dataset_labels) class_counts torch.bincount(all_labels) class_weights 1.0 / class_counts.float() sample_weights class_weights[all_labels] sampler WeightedRandomSampler( sample_weights, num_sampleslen(sample_weights), replacementTrue )这里class_weights用样本数的倒数哪个类别少单个样本权重就越高。replacementTrue表示同一个patch可以在一轮中被重复抽到这在小类别样本极少时很有用。与普通随机采样不同WeightedRandomSampler保证每个batch里不同类别出现的期望比例被拉平而不是完全随机。数据增强方面遥感影像不适合用普通的随机裁剪因为地块边缘一旦被切掉语义就可能不完整。我一般只做随机水平翻转、垂直翻转、90°倍数旋转最多加一点颜色抖动模拟不同季节和光照。旋转角度限制在90°倍数可以保持地块的固定方向特征也避免插值带来的伪纹理。4. 在layer1到layer4后插入CBAMPyTorch实现与结构验证4.1 先写CBAM两个子模块通道注意力与空间注意力的最小实现CBAM的代码在网上能搜到很多版本但有些实现把通道注意力的输出直接当成特征图返回有些则把空间注意力放到了前面顺序不对。这里给出一个结构清晰且和论文一致的最小实现。import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, in_planes, ratio16): 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, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(in_planes // ratio, 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)) return self.sigmoid(avg_out max_out) class SpatialAttention(nn.Module): def __init__(self, kernel_size7): super().__init__() self.conv nn.Conv2d(2, 1, kernel_size, paddingkernel_size // 2, biasFalse) self.sigmoid nn.Sigmoid() def forward(self, x): avg_out torch.mean(x, dim1, keepdimTrue) max_out, _ torch.max(x, dim1, keepdimTrue) out torch.cat([avg_out, max_out], dim1) return self.sigmoid(self.conv(out)) class CBAM(nn.Module): def __init__(self, in_planes, ratio16, kernel_size7): super().__init__() self.ca ChannelAttention(in_planes, ratio) self.sa SpatialAttention(kernel_size) def forward(self, x): x x * self.ca(x) x x * self.sa(x) return x这个实现里我特意让ChannelAttention只返回sigmoid后的注意力权重在CBAM.forward里再乘回原图。这样做的好处是子模块职责单一单独调试时能直接看到注意力图的数值分布。shared_mlp用1×1卷积实现而不是Linear输入输出都保持四维张量省去了手动展平。空间注意力拼接的是通道维上的均值和最大值7×7卷积核的感受野决定了注意力图能考虑多大范围的邻域关系kernel_size默认7是论文消融实验的结果。4.2 修改ResNet前向逻辑在四个layer输出后各接一个CBAM有了CBAM模块接下来要做的不是重写一个ResNet而是复用torchvision的resnet50把backbone拆开在每个layer之间插入CBAM。from torchvision.models import resnet50, ResNet50_Weights class ResNet50_CBAM(nn.Module): def __init__(self, num_classes21, pretrainedTrue): super().__init__() weights ResNet50_Weights.IMAGENET1K_V2 if pretrained else None backbone resnet50(weightsweights) self.conv1 backbone.conv1 self.bn1 backbone.bn1 self.relu backbone.relu self.maxpool backbone.maxpool self.layer1 backbone.layer1 self.layer2 backbone.layer2 self.layer3 backbone.layer3 self.layer4 backbone.layer4 self.cbam1 CBAM(256) self.cbam2 CBAM(512) self.cbam3 CBAM(1024) self.cbam4 CBAM(2048) self.avgpool backbone.avgpool self.fc nn.Linear(2048, num_classes) def forward(self, x): x self.conv1(x) x self.bn1(x) x self.relu(x) x self.maxpool(x) x self.layer1(x) x self.cbam1(x) x self.layer2(x) x self.cbam2(x) x self.layer3(x) x self.cbam3(x) x self.layer4(x) x self.cbam4(x) x self.avgpool(x) x torch.flatten(x, 1) x self.fc(x) return x四个CBAM输入通道必须分别与layer1到layer4的输出通道对应这是最容易踩错的地方layer1输出256通道如果写成512运行时就会报维度不匹配。我常通过查看backbone.layer1[-1].conv3.out_channels来核对这些通道数。forward里把CBAM放在每个layer之后、下一个layer之前注意力先修正当前stage的特征再送入下一stage做下采样提取。新加的分类头fc被替换成num_classes遥感数据集的类别数从21到上百都有可能。pretrained权重只覆盖backbone部分fc和CBAM都是随机初始化的这一点后面训练时要重点处理。4.3 结构自检前向一次、统计参数量、确认梯度通路代码写完不要直接开训先跑一次结构自检确认输入输出shape和参数量符合预期。import torch model ResNet50_CBAM(num_classes21, pretrainedFalse) dummy torch.randn(2, 3, 256, 256) out model(dummy) print(output shape:, out.shape) # 预期 torch.Size([2, 21]) total_params sum(p.numel() for p in model.parameters()) cbam_params sum(p.numel() for n, p in model.named_parameters() if cbam in n) print(ftotal params: {total_params / 1e6:.2f}M, cbam params: {cbam_params / 1e6:.2f}M)如果输入尺寸是256经过4次下采样后特征图是8×8ResNet的适配性很好只要输入是32的倍数都能跑。output shape确认是(2, 21)后再用一个带标签的batch做反向传播观察梯度是否正常传到cbam参数上。常见问题是backbone参数被冻结时连CBAM的梯度也被截断检查一下param.requires_grad和param.grad是否为None就能定位。5. 训练调参与避坑记录让CBAM-ResNet真正涨点的配置5.1 优化器、学习率与损失基于resnet预训练模型的参数基线插入CBAM后会遇到一个实际问题backbone有现成的resnet预训练模型初始化但CBAM和fc是随机初始化的如果所有层用同一个学习率随机初始化的模块学得太慢等backbone已经收敛时注意力还没找到正确的方向。我常用的做法是给backbone较小学习率给CBAM和fc较大学习率或者先冻结backbone训练几个epoch再统一解冻。optimizer torch.optim.AdamW([ {params: model.fc.parameters(), lr: 1e-3}, {params: [p for n, p in model.named_parameters() if cbam in n], lr: 1e-3}, {params: [p for n, p in model.named_parameters() if cbam not in n and fc not in n], lr: 1e-4}, ], weight_decay5e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) criterion nn.CrossEntropyLoss(weightclass_weights.to(device))AdamW加余弦退火是我在遥感分类任务里的默认组合学习率从1e-4开始最后降到接近0。相比SGDAdamW在新模块随机初始化、backbone预训练这种混合状态下更容易稳定收敛。损失函数用带类别权重的CrossEntropyLoss权重来自第3章算出的class_weights。如果少数类依然学不动再换成FocalLoss。评估指标不要只看整体准确率一定要额外看Kappa系数和各类别F1否则长尾分布会掩盖CBAM的真实收益。训练轮次30到50轮足够遥感数据量通常不大过拟合风险比ImageNet更高。5.2 避坑记录5个必须先知道的问题坑1加了CBAM之后验证集不涨反降现象和baseline对比模型加CBAM后整体准确率反而掉了零点几个百分点甚至某些类别F1明显下降。原因CBAM参数随机初始化backbone又是预训练状态两者在训练初期步调不一致注意力模块在错误的方向上干扰了已经稳定的特征。解决先冻结backbone全部参数只训练CBAM和fc三五个epoch再用较小学习率解冻backbone整体微调。这个“先局部后全体”的策略几乎每次都能让曲线从第一步开始就高于baseline。坑2训练损失正常下降验证集指标剧烈震荡现象损失曲线很漂亮但验证准确率每几个epoch就上下跳2到3个百分点。原因遥感patch之间类别分布极不均匀相邻patch内容高度相似验证集如果没做无重叠裁剪同一地块会以不同位置反复出现导致指标虚高且不稳定。解决验证集用stride256的无重叠裁剪固定随机种子同时用WeightedRandomSampler保证训练batch里类别比例稳定。没有固定随机种子就对比两个模型这是很多对比实验翻车的根源。坑3显存直接OOMbatch size降到4都扛不住现象把CBAM加到ResNet后显存占用飙升之前能跑16的batch现在只能跑4。原因如果误把CBAM插到每个Bottleneck内部而不是layer后几十个注意力模块的中间激活同时驻留显存即使每个模块参数很少激活值的开销也会叠得很高。解决先确认代码结构是把CBAM放在了layer之间其次检查输入patch是否太大、DataLoader的num_workers是否过高。CBAM在layer后接时额外显存开销很小正常来说不应该引起OOM。坑4Docker封装训练环境时镜像卡在pulling fs layer现象docker pull一个包含CUDA和PyTorch的镜像时日志长时间停在pulling fs layer反复重试还是卡住。原因这个提示表示正在拉取镜像的某个文件系统分层分层越多、文件越大越容易受镜像源拥堵和网络连接不稳定影响和代码本身没有关系。解决换用分层更少的基础镜像或者给Docker配置更稳定的镜像源如果服务器上已有同版本镜像可以直接把镜像导出成tar包再离线导入绕开逐层拉取的过程。这个问题看起来和学习无关但在部署训练环境时非常浪费时间值得提前处理。坑5类别不均衡时CBAM把注意力全给了多数类现象森林样本占比过高时SAM空间注意力热图几乎只在森林区域高亮农田边界的小块地块完全不激活。原因加权交叉熵虽然调整了损失权重但CBAM的空间注意力是在整个patch上计算的多数类的大量样本让注意力图学会了“只看大面积纹理”。解决配合FocalLoss降低易分样本的贡献同时对少数类patch做额外过采样训练后单独输出几组热图检查CBAM在少数类patch上是否真的激活了对应区域而不是凭端到端指标判断效果。6. 用混淆矩阵和注意力热图验证收益一个能说明白CBAM增量在哪里的收尾验证CBAM有没有用不能只看整体准确率。我的固定做法是把ResNet50和ResNet50_CBAM放在同一个验证集上跑除了是否插入CBAM之外不改变任何超参数然后分别输出混淆矩阵和分类报告。重点看容易混淆的几对类别——森林和灌木、裸地和农田、水体和湿地——如果这些成对错误被明显压下去说明注意力确实在空间定位上起了作用如果整体涨了但混淆类别没变那涨的点大概率来自随机波动或训练噪声。from sklearn.metrics import confusion_matrix, classification_report y_true, y_pred [], [] for images, labels in val_loader: with torch.no_grad(): out model(images.to(device)) y_pred.extend(out.argmax(dim1).cpu().numpy()) y_true.extend(labels.cpu().numpy()) print(classification_report(y_true, y_pred, target_namesclass_names)) cm confusion_matrix(y_true, y_pred)除了指标更应该花时间看注意力图。用简单的forward hook把layer4后CBAM的输出特征取出来对每个通道做均值得到一个和输入等大的响应热图叠加到原图上。这一步能看到模型到底在关注什么区域。我自己实验里最典型的结果是baseline在分类云影林地时依赖的是整片绿色纹理加了CBAM之后热图会集中到少数几个树冠密集区域说明空间注意力确实改变了决策依据。更进一步可以做逐层消融只保留layer4后的CBAM、去掉layer1后的或者单独测试每层收益。遥感影像分辨率高、地物尺度差异大有时候layer1或layer2的低层空间注意力反而比高层更有效。逐层开关几次比盲目堆模块更能定位问题。即便现在视觉Transformer、ConvNeXt这些最新的图像分类模型性能更强CBAM这种即插即用的注意力设计仍然可以作为轻量组件叠加在它们之上用来快速检验“空间注意力对该数据集是否敏感”。我自己的习惯是每次实验都固定好随机种子和验证集把四层CBAM分别开关一次记录每层的独立贡献再决定最终结构。这个习惯帮我避免了不少“加了模块就以为有效”的盲目自信。希望帮到你。本文还有配套的精品资源点击获取