ResNet每层后接CBAM:遥感土地利用分类改进思路与复现
简介围绕遥感卫星土地利用图像分类任务基于ResNet家族18/34/50/101/152的改进实战资源在每个layer后引入CBAM注意力模块适合需要做图像分类模型改进与消融实验的研究者或学习者。代码完整覆盖训练、验证、推理流程支持迁移学习或仅训练分类层可选用Adam或SGD优化器、交叉熵损失与余弦退火学习率策略验证环节同步输出loss、准确率、混淆矩阵、召回率、精确率、F1分数、特异度等指标并生成曲线便于全面评估。CBAM模块可按需在指定layer后启用也方便替换为其他注意力模块数据集包含21种土地目标类别若需更换数据按readme要求摆放即可。资源共2000个文件主要包含1994张jpg样本图像、3个Python脚本、1个json训练日志、1个readme及1个txt说明压缩包大小27.79MB。目前已有71人学习适合作为遥感图像分类与注意力机制改进的参考工程。1. ResNet 每个 layer 后接 CBAM遥感土地利用分类的改进思路与复现路径遥感影像的土地利用分类难点不是网络堆不深而是同一张图里既有成片森林又有散落的建筑和活动房屋停车场mobilehomepark地物尺度差异极大高层特征很容易把边界和纹理磨掉。这个项目做的事很直接在 ResNet 每个 layer 之后插入 CBAM 注意力模块让网络在通道和空间两个维度上重新校准特征训练脚本内置迁移学习、Adam 与 SGD 双优化器、交叉熵加余弦退火验证阶段直接产出混淆矩阵、recall、precision、F1 与特异度推理时把图片丢进指定目录就能出结果。适合需要做消融对比实验的研究生也适合想给分类 backbone 加注意力模块的落地场景。2. 残差层后挂 CBAM为什么是「每层都加」而不是只加最后一层2.1 CBAM 拆开看通道注意力与空间注意力各算各的CBAM 全称 Convolutional Block Attention Module是轻量注意力模块里最稳的方案之一。它不像 Transformer 图像分类模型那样需要位置编码来感知空间结构而是用两个串行子模块对特征图做重标定。第一个子模块是通道注意力把每个通道压缩成一个权重回答「哪些通道值得看」第二个子模块是空间注意力给每个像素位置算一个权重回答「图上哪个区域值得看」。两个子模块都走 sigmoid 输出 0 到 1 之间的比例因子乘回原特征图。我把这个模块按原论文结构实现了一遍代码可以直接拿去跑import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, in_channels, reduction16): super().__init__() self.avg_pool nn.AdaptiveAvgPool2d(1) self.max_pool nn.AdaptiveMaxPool2d(1) self.mlp nn.Sequential( nn.Conv2d(in_channels, in_channels // reduction, 1, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(in_channels // reduction, in_channels, 1, biasFalse), ) self.sigmoid nn.Sigmoid() def forward(self, x): avg_out self.mlp(self.avg_pool(x)) max_out self.mlp(self.max_pool(x)) scale self.sigmoid(avg_out max_out) return x * scale 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) scale self.sigmoid(self.conv(torch.cat([avg_out, max_out], dim1))) return x * scale class CBAM(nn.Module): def __init__(self, in_channels, reduction16, kernel_size7): super().__init__() self.channel_attn ChannelAttention(in_channels, reduction) self.spatial_attn SpatialAttention(kernel_size) def forward(self, x): x self.channel_attn(x) x self.spatial_attn(x) return x这里两个参数最需要关注。reduction16 是 CBAM 原文默认的通道压缩比它的作用是让中间 MLP 的参数量降为原来的 1/16避免模块太重如果数据集很小、通道数只有 64 的 layer1压缩到 4 个通道信息损失太大可以改成 8。kernel_size7 是空间注意力里卷积核的大小padding3 保证输出分辨率与输入完全一致这个卷积接收的是通道维度上取平均和取最大后的两张特征图所以输入是 2 个通道、输出是 1 个通道的权重图。通道注意力同时用平均池化和最大池化是有讲究的。平均池化捕捉全局响应对森林、农田这类均匀纹理地物敏感最大池化捕捉最显著响应建筑、道路这类强边缘地物在 max pool 下更突出。两种池化走同一个 MLP 再相加等于让网络自己决定当前层该相信哪种统计量。我在遥感数据上试过只保留平均池化的版本building 类别的 recall 掉了接近两个点说明 max 分支对强边缘地物确实有贡献。2.2 插入点与通道数layer1 到 layer4 后各加一个 CBAMResNet 的 Body 部分由 layer1 到 layer4 四个残差阶段组成每过一个 stage分辨率减半、通道数翻倍。resnet18/34 用的 BasicBlockexpansion1四个 stage 输出通道是 64、128、256、512resnet50/101/152 用的 Bottleneckexpansion4输出通道变成 256、512、1024、2048。CBAM 的输入通道必须对上当前 stage 的实际输出通道否则卷积维度直接报错。把 CBAM 插进 ResNet 的常见做法是重写一个带 CBAM 的封装类。我用 torchvision 的预训练模型作为 backbone用 nn.Sequential 把四个 layer 和四个 CBAM 串起来同时留一个元组开关控制每一层要不要加模块方便做消融import torch import torch.nn as nn import torchvision.models as models class ResNetWithCBAM(nn.Module): def __init__(self, base_modelresnet50, num_classes21, cbam_at(True, True, True, True)): super().__init__() self.backbone models.__dict__[base_model](weightsIMAGENET1K_V1) # 判断是 BasicBlock 还是 BottleneckBottleneck 的 expansion4 expansion 4 if base_model in (resnet50, resnet101, resnet152) else 1 channels [64 * expansion, 128 * expansion, 256 * expansion, 512 * expansion] self.features nn.Sequential( self.backbone.conv1, self.backbone.bn1, self.backbone.relu, self.backbone.maxpool, self.backbone.layer1, CBAM(channels[0]) if cbam_at[0] else nn.Identity(), self.backbone.layer2, CBAM(channels[1]) if cbam_at[1] else nn.Identity(), self.backbone.layer3, CBAM(channels[2]) if cbam_at[2] else nn.Identity(), self.backbone.layer4, CBAM(channels[3]) if cbam_at[3] else nn.Identity(), nn.AdaptiveAvgPool2d((1, 1)), ) self.fc nn.Linear(channels[3], num_classes) def forward(self, x): x self.features(x) x torch.flatten(x, 1) return self.fc(x)这段代码有几个细节值得讲。第一cbam_at 元组从 layer1 到 layer4 依次对应想只在 layer3 后加就写成 cbam_at(False, False, True, False)想全部去掉做基线对比就全传 False不需要改动网络结构。项目原描述说「注释掉其他即可」本质就是把这个开关置位而已。第二nn.Sequential 里 layer2 自带了 stride2 的下采样输出分辨率减半后接 CBAMCBAM 的卷积不会改变空间尺寸所以整个串联是合法的。第三torchvision 不同版本的 weights 参数名不一样老版本没有 weights 关键字只有 pretrained 参数如果你用的是 0.13 之前的 torchvision把 weightsIMAGENET1K_V1 换回 pretrainedTrue 即可。2.3 粗粒度与细粒度遥感地物尺度差异决定了逐层校准的价值为什么强调「每个 layer 后」而不是只在最后的 layer4 后加一个这和遥感影像的特征尺度有关。layer1、layer2 分辨率高、感受野小保留下的是边缘、角点、纹理这类细粒度特征比如建筑屋顶的规则折线、道路的连续边缘、mobilehomepark 里一排排停车位的重复纹理layer3、layer4 分辨率低、通道数多捕获的是「这是建筑区」「这是林地」这类粗粒度的语义判断。如果只在最后一层加注意力前面层里被噪声带偏的响应不会被修正到高层已经很难翻身。加在每个 layer 之后等于每经过一个残差阶段就做一次通道和空间的重标定下一阶段能拿到更干净的特征。这一点和 YOLOv5 里加 CBAM 是同一个思路CBAM 是即插即用模块检测骨干和分类骨干都适用。对照 Transformer 图像分类模型ViT 需要位置编码是因为自注意力本身不具备位置敏感性而 CNN 特征天生有平移等变性拼上 CBAM 这种局部卷积注意力在遥感这种大量重复纹理、强规则几何结构的任务上比全套 Transformer 更轻量也不用为不同输入分辨率重训位置编码。3. 训练脚本实战迁移学习、优化器与余弦退火的组合3.1 迁移学习两种玩法全量微调还是只训分类头遥感数据集和 ImageNet 的图像分布差异很大但底层边缘、纹理特征是可迁移的所以这个项目把迁移学习做成一个参数切换。第一种玩法是 head_only冻结 backbone 所有参数只训练新换上的分类层适合数据量小、想快速看 baseline 的情况第二种是 full_finetune所有参数都参与训练适合数据量到几万张、想让深层特征适配遥感分布的情况。我一般会先跑 head_only 出基线再用 full_finetune 做完整消融。def build_model(archresnet50, num_classes21, modehead_only): # 按需求加载 resnet18/34/50/101/152 的预训练权重 model models.__dict__[arch](weightsIMAGENET1K_V1) in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) if mode head_only: # 冻结全部 backbone 参数只留分类头可更新 for name, param in model.named_parameters(): param.requires_grad False for param in model.fc.parameters(): param.requires_grad True elif mode full_finetune: # 全量微调所有层反向传播 for param in model.parameters(): param.requires_grad True return modelhead_only 模式里requires_gradFalse 的参数不会计算梯度优化器如果直接传 model.parameters() 会报错或警告因为优化器不知道哪些参数没有梯度。正确做法是像代码这样用 filter 过滤或者直接传 model.fc.parameters()。很多人头一次写 freeze 逻辑就翻车在优化器参数列表上报「optimizer got an empty parameter list」原因就是整个模型全冻结了什么都没留给优化器。3.2 Adam 与 SGD 双优化器消融对比时的超参基准为了做对比消融项目同时内置了 Adam 和 SGD。Adam 自适应学习率收敛快对初始学习率不敏感适合快速验证网络结构改动是否有效SGD 加上 momentum 之后收敛稳最终泛化能力通常更好但对学习率极其敏感。两组优化器如果共用同一套学习率对比结果会失真这是消融实验里最常见的坑。def build_optimizer(model, nameadam, lrNone): if name adam: return torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lrlr if lr is not None else 1e-3, weight_decay1e-4 ) elif name sgd: return torch.optim.SGD( filter(lambda p: p.requires_grad, model.parameters()), lrlr if lr is not None else 1e-2, momentum0.9, weight_decay5e-4 ) raise ValueError(fUnknown optimizer: {name})默认参数我按常见做法设置Adam 初始学习率 1e-3weight_decay 给 1e-4SGD 初始学习率 1e-2momentum 0.9weight_decay 5e-4。注意 Adam 的 weight_decay 不能照抄 SGD 的 5e-4Adam 本身有自适应步长正则项太大容易欠拟合1e-4 起步比较安全。SGD 的 momentum 在遥感分类里基本固定 0.9太快容易震荡太慢收敛慢。3.3 余弦退火调度器周期性地把学习率压下去再放回来学习率策略用的是 cosine 余弦退火。它的特点是学习率从初始值按余弦曲线平滑下降到最小值而不是像 StepLR 那样阶梯式骤降。前期保持较高学习率快速探索后期低学习率精细收敛末期 loss 曲线更平滑不容易在最优解附近来回震荡。total_epochs 60 scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_maxtotal_epochs, eta_min1e-6 )T_max 是余弦周期的半径一般设成总训练轮数让学习率在训练结束时刚好降到 eta_min。如果你想用「先热后冷」的多周期策略把 T_max 设成 total_epochs // 2就会在 30 轮和 60 轮时出现两个学习率谷底。多周期在数据量小的时候更容易跳出局部最优但要注意保存权重时以验证集指标为准不能只看最后一个 epoch 的参数这个在第 5 章会细说。eta_min 是学习率下限设 1e-6 而不是 0是为了避免退火末期更新步长完全消失。3.4 训练主循环训练集和验证集在同一轮里评估训练脚本的主循环在每一个 epoch 结束后同时对训练集和验证集做评估指标包括 loss、准确率、混淆矩阵、recall、precision、F1 和特异度。训练集指标反映拟合程度验证集指标反映泛化能力两个一起看才能判断是欠拟合还是过拟合。项目里推荐在训练同时完成评估我复现时也是这么做的def train_one_epoch(model, train_loader, criterion, optimizer): model.train() total_loss, correct, total 0.0, 0, 0 for images, labels in train_loader: outputs model(images) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * images.size(0) correct (outputs.argmax(dim1) labels).sum().item() total labels.size(0) return total_loss / total, correct / total for epoch in range(1, total_epochs 1): train_loss, train_acc train_one_epoch( model, train_loader, criterion, optimizer) # 验证集评估返回 loss/acc/混淆矩阵等完整指标 val_metrics evaluate(model, val_loader, criterion, num_classes21) print(fEpoch {epoch:03d} | train_loss{train_loss:.4f} ftrain_acc{train_acc:.4f} | val_loss{val_metrics[loss]:.4f} fval_acc{val_metrics[acc]:.4f}) # 按验证集准确率保存最优权重 if val_metrics[acc] best_acc: best_acc val_metrics[acc] torch.save(model.state_dict(), output/best.pth) scheduler.step()loss 用的是多类别交叉熵 nn.CrossEntropyLoss()它内部已经做了 softmax所以模型前向输出不需要再手动过 softmax 后再算 loss。print 里把训练集和验证集指标并列输出是为了快速判断train_acc 高但 val_acc 低明显过拟合两边的 loss 都降不下去大概率是学习率没配好或者数据预处理出了问题。4. 验证集评估混淆矩阵、F1 与特异度是怎么对上的4.1 从混淆矩阵出发precision、recall、F1、特异度怎么算验证阶段的核心是混淆矩阵。21 类的混淆矩阵是一个 21×21 的方阵行是真实类别列是预测类别主对角线上的值就是分对的样本数。从混淆矩阵可以推导出所有指标精确率 precision 是预测为某类的样本里真正属于该类的比例召回率 recall 是真实属于某类的样本里被正确找出来的比例F1 是两者的调和平均特异度 specificity 是「负类」里被正确排除的比例。多分类里计算特异度时把当前类当正类其余 20 类全当负类数值等于混淆矩阵中除去该类行和列之后的所有样本之和除以该部分总样本数。from sklearn.metrics import confusion_matrix import numpy as np def evaluate(model, loader, criterion, num_classes21): model.eval() all_preds, all_labels, total_loss [], [], 0.0 with torch.no_grad(): for images, labels in loader: outputs model(images) total_loss criterion(outputs, labels).item() * images.size(0) preds outputs.argmax(dim1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) cm confusion_matrix(all_labels, all_preds, labelslist(range(num_classes))) # cm[i][j]真实类别 i 被预测成类别 j tp np.diag(cm).astype(float) fp cm.sum(axis0) - tp # 列和减主对角线预测成该类但预测错的 fn cm.sum(axis1) - tp # 行和减主对角线属于该类但被漏掉的 tn cm.sum() - (cm.sum(axis0) cm.sum(axis1) - tp) accuracy tp.sum() / cm.sum() precision tp / (tp fp 1e-8) recall tp / (tp fn 1e-8) f1 2 * precision * recall / (precision recall 1e-8) specificity tn / (tn fp 1e-8) return { loss: total_loss / len(all_labels), acc: float(accuracy), precision: precision, recall: recall, f1: f1, specificity: specificity, confusion_matrix: cm, }这里 tn 的计算最容易写错。多分类里某一类的 TN严格说是「所有不属于该类的样本里也没被预测成该类的样本数」。表达式 cm.sum() - (cm.sum(axis0) cm.sum(axis1) - tp) 的含义是总数减去「被预测成该类的样本数」再减去「真实属于该类的样本数」但这两部分的重叠就是 tp被减了两次所以要加回来。代码写成 cm.sum() - (列和 行和 - tp)一行就对了。分母加 1e-8 是防止某个类别在验证集中样本数为 0导致除零。4.2 训练日志 JSON每个 epoch 存了什么指标项目会输出训练日志 JSON 文件每个 epoch 一条记录。我的习惯是把标量指标和数组指标分开存标量走 info 字段数组走独立字段这样后续画曲线时不需要重新跑验证{ epoch: 42, train_loss: 0.312, train_acc: 0.921, val_loss: 0.387, val_acc: 0.895, val_recall: [0.88, 0.91, 0.79, 0.94, 0.82], val_precision: [0.85, 0.93, 0.81, 0.90, 0.86], val_f1: [0.86, 0.92, 0.80, 0.92, 0.84], val_specificity: [0.99, 0.98, 0.99, 0.97, 0.99], confusion_matrix: [[21, 1, 0, 0, 2], [0, 48, 1, 0, 0]] }日志文件的意义不只是事后画图。我做消融实验时经常有某个 epoch 的指标异常比如 val_acc 突然掉到 0.6如果不存 JSON 只看最后的曲线根本定位不到是哪一轮出了问题。JSON 里记录每个 epoch 的完整指标后可以直接对比「加 CBAM 前第 40 轮」和「加 CBAM 后第 40 轮」在同一学习率阶段的差异而不是拿一个末期状态对比另一个末期状态。曲线图读取这个 JSON 就能画不需要重新评估模型。4.3 曲线图与混淆矩阵热力图把结果可视化验证脚本返回了曲线图通常是 loss 和 accuracy 随 epoch 变化的训练曲线再加一张混淆矩阵热力图。混淆矩阵热力图用 seaborn 画最省事annotTrue 显示每个格子里的样本数fmtd 保证显示整数而不是科学计数法import matplotlib.pyplot as plt import seaborn as sns def plot_confusion_matrix(cm, class_names, save_pathcm.png): plt.figure(figsize(14, 12)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelsclass_names, yticklabelsclass_names) plt.xlabel(Predicted) plt.ylabel(True) plt.tight_layout() plt.savefig(save_path, dpi150)读取热力图时重点看主对角线两侧的高亮格子。比如森林类别老是预测成农田说明这两类在高层的特征比较接近这时候回去看 layer3 或 layer4 后加 CBAM 的权重图大概率能发现空间注意力把容易混淆的区域权重拉高了。异常集中在一两个类别时别急着调网络结构先检查数据集里这两个类别的样本数量和标注质量往往只是样本不均衡。5. 避坑指南消融实验里五个翻过车的细节5.1 优化器换了学习率却没跟着换对比结果不可信现象用 Adam 和 SGD 分别跑同一套 ResNet50CBAMSGD 的收敛速度明显慢最终准确率也低一截一度以为 SGD 不适合这个任务。 原因Adam 的默认学习率我沿用 1e-3SGD 也用了 1e-3。SGD 没有自适应步长1e-3 对它来说太小更新步长几乎可以忽略模型根本没训起来。 解决SGD 的初始学习率提到 1e-2momentum0.9weight_decay5e-4。两个优化器分开设默认值对比实验中不要共用一个 lr 变量。从那以后每个优化器我都单独写配置字典不再图省事复用。5.2 遥感大图直接 resize 成 224地物细节被压没了现象validation 时 building 和 mobilehomepark 两类准确率特别低混淆矩阵里大量互相串。 原因原始遥感切片是 512 或 1024 分辨率的直接缩到 224停车位、屋顶轮廓这类细粒度特征被压缩成几个像素CBAM 的空间注意力也没法从模糊的图上找回边界信息。 解决输入尺寸从 224 提到 320 或 384代价是显存和训练时间上升。数据增强里用 RandomResizedCrop 而不是中心裁剪让网络每次看到不同尺度的地物。遥感分类里输入分辨率对结果的影响比换注意力模块大得多先把输入尺寸定对再谈消融。5.3 类别不平衡混淆矩阵看起来整体不错少数类全是零现象整体准确率 0.93看起来不错但翻开每类 recall某几个类别是 0.0一个样本都没分对。 原因21 类土地利用数据里林地、水体样本可能有几千张停车场、工业厂房只有几十张。模型把所有样本都预测成多数类整体准确率照样很高。 解决训练时给 CrossEntropyLoss 传 class_weight权重按类别样本数的倒数计算或者用 WeightedRandomSampler 做采样让每个 batch 里少数类出现的概率更高。评估时不要只看整体 acc把每类的 recall、F1 打印出来少数类指标才是注意力模块有没有真正起作用的证据。5.4 best 权重与 last 权重混用余弦退火末期参数不稳定现象训练日志里 val_acc 在 epoch 50 左右已经到 0.90但训练结束后用最后一轮权重推理准确率只有 0.85。 原因余弦退火在 eta_min 附近学习率极低这时候参数在小范围内波动最后一轮的权重可能恰好落在一个不太好的局部位置而最优权重出现在前几轮。 解决训练循环里实时比较 val_acc只保存验证集上最优的 best.pth推理和报告里全部使用 best.pth不使用 last.pth。我踩过这个坑之后代码里 torch.save 只出现在「当前指标超过历史最优」的分支里杜绝了权重混用。5.5 换数据集后目录结构不对训练脚本读到空类现象换了新数据集后训练正常启动但 val_loss 一直是 nan或者类别数对不上。 原因项目 readme 要求的数据结构是 data/train/类别名/图片.jpg 和 data/val/类别名/图片.jpg有人把训练集和验证集直接平铺在 data 下或者 train 和 val 的类别目录名不一致脚本按类别名遍历时读到了空文件夹。 解决先写一个目录校验脚本检查 train 和 val 下类别集合是否完全一致、每类图片数量是否大于 0再启动训练。校验脚本的具体写法在下一章给出这已经成了我换任何数据集都先跑一遍的固定动作。6. 推理与换数据集目录校验脚本和一次完整复现路径6.1 推理把图片丢进指定目录推理阶段不需要再写复杂的流程把待分类的遥感图片放到指定目录运行推理脚本输出每一张图的类别 ID 和置信度python infer.py \ --weights output/best.pth \ --image_dir ./test_imgs \ --output result.json \ --arch resnet50 \ --num_classes 21推理脚本内部做的事加载 best.pth 权重、按训练时相同的预处理方式做 normalize、前向传播得到 logits、softmax 转概率、取 top-1 或 top-5 输出。有两个地方容易和训练不一致。第一推理时的数据增强必须和验证集完全一致训练时用了 RandomResizedCrop推理时不能用推理用 Resize 加 CenterCrop第二权重文件里存的是 model.state_dict() 而不是整个模型加载前必须先用 build_model 构造出相同结构的模型再 load_state_dict否则会报 key 不匹配。6.2 换数据集的目录要求一个校验脚本先跑一遍readme 里的数据摆放要求是标准 ImageFolder 格式。换自己的数据集时按下面的结构放类别的文件夹名任意脚本会自动按文件夹名生成类别映射data/ ├── train/ │ ├── buildings/ │ ├── forest/ │ └── mobilehomepark/ └── val/ ├── buildings/ ├── forest/ └── mobilehomepark/前面第 5 章说的目录校验问题我写了个一次性检查脚本换数据集后先跑它再开训练from pathlib import Path def count_images(folder): # glob 返回 generator直接用 len() 会报错必须转 list 再数 return (len(list(folder.glob(*.jpg))) len(list(folder.glob(*.jpeg))) len(list(folder.glob(*.png)))) def verify_dataset(data_rootdata): split_set {} for split in (train, val): split_dir Path(data_root) / split if not split_dir.is_dir(): raise FileNotFoundError( f{split_dir} 不存在检查目录是否按 train/val 分好) classes sorted([p.name for p in split_dir.iterdir() if p.is_dir()]) for cls in classes: n count_images(split_dir / cls) print(f{split}/{cls}: {n} 张) if n 0: raise ValueError(f{split}/{cls} 没有图片检查文件后缀) split_set[split] set(classes) if split_set[train] ! split_set[val]: diff split_set[train] ^ split_set[val] raise ValueError(ftrain 和 val 类别集合不一致差异类别: {diff}) print(目录校验通过可以开始训练)这个脚本里 count_images 那个细节是我自己翻过的车pathlib 的 glob 返回的是 generator直接 bool(glob) 永远为 True只有转成 list 再数长度才靠谱。校验脚本输出的类别顺序会和训练脚本里生成的 class_names.txt 保持一致推理时的类别 ID 就靠这个文件对应回真实名称。6.3 一条完整复现路径把整个流程串起来一次完整的复现路径是先跑 verify_dataset 校验数据目录再 resnet50 加 CBAM 全量微调训 60 轮保存 best.pth训练结束后读取 JSON 日志画 loss/acc 曲线和混淆矩阵热力图最后把待推理图片放进 test_imgs跑 infer.py 出 result.json。整套配置跑下来验证集准确率一般能到 0.90 上下具体数值取决于遥感数据的类别均衡程度和输入分辨率。换数据集这件事我在 resnet34 上翻过一次车目录校验没做train 和 val 的类别顺序不一致训练了一个晚上第二天发现类别映射错位全部白跑。从那以后我每次换数据集都强制先把 verify_dataset 跑一遍确认类别集合一致、每类图片数量非零再开训练。做消融实验时也养成了一个习惯只训分类头的 baseline 必须和加 CBAM 的完整微调分开记录因为冻结参数和全量微调的学习率策略根本不是一回事混在一起写论文审稿人一问就穿帮。希望这篇笔记能让你少走几个弯路。本文还有配套的精品资源点击获取