简介:围绕遥感卫星土地利用图像分类任务,基于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, reduction=16): 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, bias=False), nn.ReLU(inplace=True), nn.Conv2d(in_channels // reduction, in_channels, 1, bias=False), ) 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_size=7): super().__init__() self.conv = nn.Conv2d(2, 1, kernel_size, padding=kernel_size // 2, 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) scale = self.sigmoid(self.conv(torch.cat([avg_out, max_out], dim=1))) return x * scale class CBAM(nn.Module): def __init__(self, in_channels, reduction=16, kernel_size=7): 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这里两个参数最需要关注。reduction=16 是 CBAM 原文默认的通道压缩比,它的作用是让中间 MLP 的参数量降为原来的 1/16,避免模块太重;如果数据集很小、通道数只有 64 的 layer1,压缩到 4 个通道信息损失太大,可以改成 8。kernel_size=7 是空间注意力里卷积核的大小,padding=3 保证输出分辨率与输入完全一致,这个卷积接收的是通道维度上取平均和取最大后的两张特征图,所以输入是 2 个通道、输出是 1 个通道的权重图。
通道注意力同时用平均池化和最大池化是有讲究的。平均池化捕捉全局响应,对森林、农田这类均匀纹理地物敏感;最大池化捕捉最显著响应,建筑、道路这类强边缘地物在 max pool 下更突出。两种池化走同一个 MLP 再相加,等于让网络自己决定当前层该相信哪种统计量。我在遥感数据上试过只保留平均池化的版本,building 类别的 recall 掉了接近两个点,说明 max 分支对强边缘地物确实有贡献。
2.2 插入点与通道数:layer1 到 layer4 后各加一个 CBAM
ResNet 的 Body 部分由 layer1 到 layer4 四个残差阶段组成,每过一个 stage,分辨率减半、通道数翻倍。resnet18/34 用的 BasicBlock,expansion=1,四个 stage 输出通道是 64、128、256、512;resnet50/101/152 用的 Bottleneck,expansion=4,输出通道变成 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_model='resnet50', num_classes=21, cbam_at=(True, True, True, True)): super().__init__() self.backbone = models.__dict__[base_model](weights='IMAGENET1K_V1') # 判断是 BasicBlock 还是 Bottleneck,Bottleneck 的 expansion=4 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 自带了 stride=2 的下采样,输出分辨率减半后接 CBAM,CBAM 的卷积不会改变空间尺寸,所以整个串联是合法的。第三,torchvision 不同版本的 weights 参数名不一样,老版本没有 weights 关键字,只有 pretrained 参数,如果你用的是 0.13 之前的 torchvision,把 weights='IMAGENET1K_V1' 换回 pretrained=True 即可。
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(arch='resnet50', num_classes=21, mode='head_only'): # 按需求加载 resnet18/34/50/101/152 的预训练权重 model = models.__dict__[arch](weights='IMAGENET1K_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_grad=False 的参数不会计算梯度,优化器如果直接传 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, name='adam', lr=None): if name == 'adam': return torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr=lr if lr is not None else 1e-3, weight_decay=1e-4 ) elif name == 'sgd': return torch.optim.SGD( filter(lambda p: p.requires_grad, model.parameters()), lr=lr if lr is not None else 1e-2, momentum=0.9, weight_decay=5e-4 ) raise ValueError(f'Unknown optimizer: {name}')默认参数我按常见做法设置:Adam 初始学习率 1e-3,weight_decay 给 1e-4;SGD 初始学习率 1e-2,momentum 0.9,weight_decay 5e-4。注意 Adam 的 weight_decay 不能照抄 SGD 的 5e-4,Adam 本身有自适应步长,正则项太大容易欠拟合,1e-4 起步比较安全。SGD 的 momentum 在遥感分类里基本固定 0.9,太快容易震荡,太慢收敛慢。
3.3 余弦退火调度器:周期性地把学习率压下去再放回来
学习率策略用的是 cosine 余弦退火。它的特点是学习率从初始值按余弦曲线平滑下降到最小值,而不是像 StepLR 那样阶梯式骤降。前期保持较高学习率快速探索,后期低学习率精细收敛,末期 loss 曲线更平滑,不容易在最优解附近来回震荡。
total_epochs = 60 scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=total_epochs, eta_min=1e-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(dim=1) == 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_classes=21) print(f'Epoch {epoch:03d} | train_loss={train_loss:.4f} ' f'train_acc={train_acc:.4f} | val_loss={val_metrics["loss"]:.4f} ' f'val_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_classes=21): 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(dim=1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) cm = confusion_matrix(all_labels, all_preds, labels=list(range(num_classes))) # cm[i][j]:真实类别 i 被预测成类别 j tp = np.diag(cm).astype(float) fp = cm.sum(axis=0) - tp # 列和减主对角线:预测成该类但预测错的 fn = cm.sum(axis=1) - tp # 行和减主对角线:属于该类但被漏掉的 tn = cm.sum() - (cm.sum(axis=0) + cm.sum(axis=1) - 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(axis=0) + cm.sum(axis=1) - 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 画最省事,annot=True 显示每个格子里的样本数,fmt='d' 保证显示整数而不是科学计数法:
import matplotlib.pyplot as plt import seaborn as sns def plot_confusion_matrix(cm, class_names, save_path='cm.png'): plt.figure(figsize=(14, 12)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=class_names, yticklabels=class_names) plt.xlabel('Predicted') plt.ylabel('True') plt.tight_layout() plt.savefig(save_path, dpi=150)读取热力图时,重点看主对角线两侧的高亮格子。比如森林类别老是预测成农田,说明这两类在高层的特征比较接近,这时候回去看 layer3 或 layer4 后加 CBAM 的权重图,大概率能发现空间注意力把容易混淆的区域权重拉高了。异常集中在一两个类别时,别急着调网络结构,先检查数据集里这两个类别的样本数量和标注质量,往往只是样本不均衡。
5. 避坑指南:消融实验里五个翻过车的细节
5.1 优化器换了学习率却没跟着换:对比结果不可信
现象:用 Adam 和 SGD 分别跑同一套 ResNet50+CBAM,SGD 的收敛速度明显慢,最终准确率也低一截,一度以为 SGD 不适合这个任务。 原因:Adam 的默认学习率我沿用 1e-3,SGD 也用了 1e-3。SGD 没有自适应步长,1e-3 对它来说太小,更新步长几乎可以忽略,模型根本没训起来。 解决:SGD 的初始学习率提到 1e-2,momentum=0.9,weight_decay=5e-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_root='data'): 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(f'train 和 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 的完整微调分开记录,因为冻结参数和全量微调的学习率策略根本不是一回事,混在一起写论文,审稿人一问就穿帮。希望这篇笔记能让你少走几个弯路。
本文还有配套的精品资源,点击获取