EfficientNet图像分类实战:森林航拍场景的迁移学习与CAM验证

发布时间:2026/10/10 18:17:13
EfficientNet图像分类实战:森林航拍场景的迁移学习与CAM验证
简介面向有一定深度学习基础、想快速上手 EfficientNet 图像分类实战的 PyTorch 学习者这是一套可直接运行的完整工程覆盖数据组织、模型训练与测试评估等关键环节适合作为入门实践或课程设计参考。包内共 8 个文件以 Python 脚本为主包含数据集加载、训练和测试三类脚本另有 2 个编译缓存文件及 1 个训练好的模型权重文件压缩包约 38.27MB文件组织紧凑便于按流程对照学习。已有 1205 人学习下载被较多学习者用于图像分类项目的复现参考。借助其中的完整代码与预训练权重读者可以复现 EfficientNet 从数据读取到模型训练、再到推理输出的完整链路也可以换成自己的图片数据集通过迁移学习快速开展实验。对刚接触图像分类或希望快速验证 EfficientNet 效果的学习者来说这份工程能明显缩短环境准备和代码调试的时间。1. 图像分类EfficientNet实战从森林分类场景看这套方案值不值得用如果你手头有一批森林航拍图要把针叶林、阔叶林、混交林和裸地分开那么拿起图像分类EfficientNet实战.zip这套方案最应该先想清楚一个问题为什么在最新的图像分类模型层出不穷的今天还要用EfficientNet做基线。我的答案是EfficientNet在单卡、中等数据量、工程交付周期短的场景里太稳了。它不需要分布式训练不需要几十G的数据做自监督预训练只要一个公开的预训练权重就能在普通工作站上快速出基线。这篇笔记按我自己做森林图像分类的顺序展开先讲清楚EfficientNet的结构和选型边界再给出一套能在本地跑通的数据准备、训练与评估流程接着聊小样本图像分类1-shot/5-shot时怎么迁移微调最后把我实际踩过的坑和用CAM验证模型“到底在看什么”的方法一次说透。新手可以照着把第一个模型跑出来熟手直接看第4章和第5章的参数与边界就行。2. EfficientNet凭什么成为图像分类基线复合缩放、MBConv与分类头替换2.1 复合缩放同时放大宽度、深度和分辨率而不是只加层数EfficientNet的核心贡献是提出了一种“复合缩放”策略。在它之前ResNet想变强就加深层数GoogLeNet喜欢加宽卷积通道有些人把输入分辨率调高这三种方法各有收益但都存在瓶颈。深度加过头梯度消失宽度加过头计算量爆炸分辨率加过头小卷积核撑不住。EfficientNet的做法是把这三件事放在同一个坐标系里假设总计算量预算是固定的那么深度、宽度、分辨率应当按一个固定比例同时放大而不是各自独立调。这个比例就是论文里的α、β、γ三个系数深度按α放大宽度按β²放大分辨率按γ²放大约束条件是α·β²·γ²≈2代表每放大一轮总计算量大约翻一倍。这个约束不是拍脑袋定的而是在小模型上用网格搜索先找出一组最优比例再把同样的比例迁移到大模型上。实际使用时我们不会手动算这三个值timm里已经配好了B0到B7的完整缩放表。B0是基线B1到B7依次增大计算量和精度。模型参数量约输入分辨率常见用途efficientnet_b05.3M224快速验证、移动端efficientnet_b17.8M240小数据集微调efficientnet_b29.2M260常规视觉任务efficientnet_b312M300中分辨率任务efficientnet_b419M380对精度有要求efficientnet_b530M456大显存单卡efficientnet_b643M528高精度、大显存efficientnet_b766M600极限精度、离线训练我一般不建议一上来就用b7。森林图像分类这类任务很多情况下b0或b1就能达到可用水平而b4以上的输入分辨率接近400甚至600会把训练时长和显存占用拉高一个量级换来的准确率提升往往只有1到2个点。比较合理的路径是先用b0确认数据有效再根据业务对精度的要求决定要不要升到b3或b4。2.2 MBConv与SE注意力用更少的参数拿到更高的精度EfficientNet的基础模块叫MBConv它是MobileNetV2倒残差结构的升级版。倒残差结构先用1x1卷积把通道数放大再用深度可分离卷积做空间特征提取最后用1x1卷积把通道数压回去。深度可分离卷积把空间卷积和通道映射拆开参数量比普通卷积小很多这就解释了为什么b0只有5.3M参数ImageNet精度却能超过当时同量级的ResNet。在MBConv内部还有一个SE模块也就是Squeeze-and-Excitation注意力。它把每个通道的特征图先压缩成一个全局统计量再用两个全连接层学习每个通道的重要程度最后把权重乘回特征图。SE的直观作用就是让模型更关注有判别力的通道——比如森林图像分类里树冠纹理和裸地颜色的通道被放大而背景的干扰信息被抑制。虽然EfficientNet整体像黑匣子但SE的存在给了我们一个粗糙的调试抓手如果某一类图片模型总是分错可以去看SE模块对哪些通道给的是低权重这往往比瞎调数据增强有效得多。MBConv还有一个值得记住的细节它默认使用SiLU激活函数而不是ReLU。SiLU在深层网络里梯度传导更平滑代价是计算量略大。这个细节在Keras和PyTorch的官方实现里都保持一致做量化或导出到ONNX时要注意算子支持情况。2.3 分类头替换从1000类到你的N类timm里的标准做法EfficientNet在ImageNet上训练时最后的分类层输出1000维。迁移到我们自己的图像分类任务时只需要把最后一层替换成对应类别数的全连接层。最常见的做法是直接借助timm库它把EfficientNet的实现、预训练权重和预处理配置都封装好了。import timm import torch.nn as nn model timm.create_model(efficientnet_b0, pretrainedTrue, num_classes0) model.classifier nn.Linear(model.num_features, 4)代码里num_classes0是关键它表示加载预训练权重时先把原来的1000类分类头扔掉只保留backbone特征提取部分。model.num_features是全局池化之后的特征维度b0是1280b4是1792不同模型不一样直接用这个属性最稳妥。新接的线性层是随机初始化的backbone部分则是ImageNet预训练权重。这里要特意说一个问题用ViT评估时分类头用调整吗。ViT和EfficientNet不一样ViT通常需要位置编码如果你训练时用224分辨率评估时换成384位置编码要插值分类头往往也需要跟着重新微调。EfficientNet没有位置编码分类头就是池化后接一层线性层只要评估时保持和训练时一样的预处理配置和分辨率分类头直接加载权重推理即可不需要额外调整。3. 用EfficientNet跑通森林图像分类工程数据目录、训练脚本与评估指标3.1 数据目录与ImageFolder把散落的图片整理成规范结构拿到森林图像数据后第一件事不是写模型而是把数据整理成torchvision.datasets.ImageFolder能直接读取的目录结构。这个结构要求每个类别一个文件夹文件夹名字就是类别标签。它最大的好处是省掉了手写Dataset的麻烦还能顺便通过文件夹名检查类别有没有拼写错误。data/forest/ ├── train/ │ ├── needle/ # 针叶林 │ ├── broadleaf/ # 阔叶林 │ ├── mixed/ # 混交林 │ └── bare/ # 裸地 └── val/ ├── needle/ ├── broadleaf/ ├── mixed/ └── bare/整理数据的同时我建议顺手统计一下每个类别的图片数量。这一步看似简单但能提前暴露两个问题类别不均衡程度是否过大以及某个类别的图片是否少到连一个验证集都分不出来。from pathlib import Path from collections import Counter train_dir Path(data/forest/train) class_counts Counter(p.parent.name for p in train_dir.rglob(*.jpg)) class_counts Counter(p.parent.name for p in train_dir.rglob(*.png)) print(class_counts)这段代码遍历训练目录下所有jpg和png文件统计每个类别文件夹的文件数。Counter会把同一类在多轮遍历里的计数加总所以这里写成两次Counter相加没问题。看到结果后你就能决定是用WeightedRandomSampler处理不均衡还是先用简单交叉熵把基线跑出来再说。统计结果还是后续划分小样本1-shot/5-shot支持集的数据基础提前做好有备无患。3.2 训练脚本timm加PyTorch的最小可跑版本下面这份脚本是我做森林图像分类的常用骨架删掉了日志和distributed相关的东西保留了核心流程数据加载、模型构建、训练循环、验证、保存最优模型。import argparse import timm import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets from timm.data import create_transform from sklearn.metrics import f1_score parser argparse.ArgumentParser() parser.add_argument(--data, defaultdata/forest, help数据集根目录) parser.add_argument(--model, defaultefficientnet_b0) parser.add_argument(--epochs, typeint, default30) parser.add_argument(--batch, typeint, default32) parser.add_argument(--lr, typefloat, default3e-4) args parser.parse_args() train_ds datasets.ImageFolder( f{args.data}/train, transformcreate_transform(input_size224, is_trainingTrue, auto_augmentrand-m9-mstd0.5), ) val_ds datasets.ImageFolder( f{args.data}/val, transformcreate_transform(input_size224, is_trainingFalse), ) train_loader DataLoader(train_ds, batch_sizeargs.batch, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_ds, batch_sizeargs.batch, shuffleFalse, num_workers4, pin_memoryTrue) model timm.create_model(args.model, pretrainedTrue, num_classeslen(train_ds.classes)) optimizer torch.optim.AdamW(model.parameters(), lrargs.lr, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxargs.epochs) criterion nn.CrossEntropyLoss() best_f1 0.0 for epoch in range(args.epochs): model.train() for images, labels in train_loader: optimizer.zero_grad() logits model(images) loss criterion(logits, labels) loss.backward() optimizer.step() scheduler.step() model.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in val_loader: preds model(images).argmax(dim1) all_preds.extend(preds.tolist()) all_labels.extend(labels.tolist()) macro_f1 f1_score(all_labels, all_preds, averagemacro) print(fepoch {epoch} macro_f1 {macro_f1:.4f}) if macro_f1 best_f1: best_f1 macro_f1 torch.save(model.state_dict(), best_forest_model.pth)有几个参数需要照着解释一下。学习率3e-4是AdamW配EfficientNet的稳妥起点如果你换成SGD初始学习率要调到1e-2附近配套动量0.9和weight_decay。余弦退火调度器的T_max设为epoch总数这样学习率会在最后一个epoch降到接近0比固定学习率在多数任务上都要稳。batch size默认32如果显存不够优先降到16或8然后看第5章讲的BN问题怎么处理不要改输入分辨率来换显存。create_transform在训练模式下会自动内置随机的裁剪、翻转和归一化验证模式下只做resize和归一化这一点很多人会重复归一化导致精度异常。3.3 评估指标准确率之外F1与混淆矩阵才是硬指标单看准确率评估森林图像分类模型是很容易自欺欺人的。如果裸地类别占了数据集的75%模型把所有图都判成裸地准确率照样是75%但这显然不是我们要的分类能力。我通常用macro-F1和混淆矩阵作为主要评估依据。from sklearn.metrics import classification_report, confusion_matrix print(classification_report(all_labels, all_preds, target_namestrain_ds.classes)) print(confusion_matrix(all_labels, all_preds))classification_report会输出每个类别的precision、recall和f1-score以及加权后的宏观指标。confusion_matrix能直观显示模型把哪两类混在一起。在森林分类场景里针叶林和混交林之间出现混淆是常态这通常说明两类特征在纹理上确实相似需要靠增加数据或改用更大分辨率模型来解但如果是裸地被大量误判为阔叶林就要先检查是不是数据标注出了问题。我自己的习惯是把best_f1作为保存模型的标准而不是用val loss因为宏F1对类别不均衡更敏感用它选出的模型在业务侧的表现更稳定。4. 小样本图像分类1-shot/5-shotEfficientNet的迁移微调完整流程4.1 构建N-way K-shot数据集代码里的分层抽样小样本图像分类的场景通常是每个类别只有几张可用图片术语叫1-shot或5-shot意思是每个类别只给1张或5张支持图片。要做这个实验需要把原来的训练集改造成N-way K-shot的形式每轮从N个类别里各抽K张作为支持集其余图片作为查询集。这样模型每一轮都在只看K张参考图的条件下去分类更多的图和传统训练完再做验证的模式不一样。import random def build_nway_kshot(class_to_paths, n_way4, k_shot1, seed0): rng random.Random(seed) class_names sorted(class_to_paths.keys()) assert len(class_names) n_way chosen rng.sample(class_names, n_way) support_paths, query_paths [], [] for cls in chosen: paths class_to_paths[cls][:] rng.shuffle(paths) support_paths [(p, cls) for p in paths[:k_shot]] query_paths [(p, cls) for p in paths[k_shot:]] return support_paths, query_paths这个函数的输入class_to_paths是一个字典键是类别名值是该类所有图片路径的列表。内部用同一个random.Random(seed)实例保证每轮抽样可复现。支持集从每类路径列表头部取K个查询集用剩余样本。1-shot时K15-shot时K5。需要特别注意的是如果某个类别样本数小于K1查询集会为空这个小样本实验就直接具现出了一个数据不足问题。我在做森林图像小样本分类时会先把少于10张的类别过滤掉再跑实验否则后续的评估结果会被这类极端情况带偏。4.2 微调策略与分类头初始化回答“用ViT评估时分类头用调整吗”从ImageNet预训练权重迁移到小样本任务直接全量微调几乎是注定过拟合的。常见做法分两步先冻结backbone只训练新分类头让随机初始化的分类头先找到一个合适的工作区间再解冻最后几个block用很小的学习率微调。# 第一步只训分类头 for name, param in model.named_parameters(): if classifier not in name: param.requires_grad False optimizer torch.optim.AdamW(model.classifier.parameters(), lr1e-3) # 第二步解冻最后两个block继续微调 for name, param in model.named_parameters(): if blocks.7 in name or classifier in name: param.requires_grad True optimizer torch.optim.AdamW([ {params: [p for n, p in model.named_parameters() if classifier in n], lr: 1e-3}, {params: [p for n, p in model.named_parameters() if blocks.7 in n and p.requires_grad], lr: 1e-5}, ], lr1e-5)参数分组这里要说明一下。分类头新初始化学习率给大一点1e-3可以让它快速收敛backbone的解冻部分是用预训练分布做的初始化学习率要小一两个数量级1e-5是安全值。如果直接把所有参数放在同一个优化器里用1e-3预训练特征很快就被破坏。还有一个藏在细节里的坑即使backbone的weight被冻结只要模型处于训练模式BatchNorm层的running_mean和running_var依然会持续更新小样本场景下这种更新会让BN统计量严重偏离预训练分布。我通常会在冻结阶段把BN层也固定住方法是遍历模型模块对BatchNorm层手动调用m.eval()这样它们在训练模式下也不会更新统计量。回到前面那个“用ViT评估时分类头用调整吗”的问题。ViT在小样本迁移时如果分辨率变化位置编码插值会影响每个token的分布分类头需要跟着做一步适配EfficientNet没有位置编码全局池化对输入尺寸也有一定的容忍度但一旦你动了训练时的分辨率最好还是用新的分辨率重新统计一下BN的running统计量。我的经验是训练和评估统一用同一套create_transform配置分类头在评估阶段不做任何调整直接加载权重推理。4.3 数据增强边界RandAugment与CutMix在小样本上的取舍小样本场景下数据增强的作用被放大但也不是越强越好。create_transform里的RandAugment、随机擦除、MixUp和CutMix都默认关闭的话1-shot实验往往很难收敛全部打开又容易让模型在极度扭曲的样本上过拟合到噪声。from timm.data import create_transform train_transform create_transform( input_size224, is_trainingTrue, auto_augmentrand-m9-mstd0.5, re_prob0.25, mixup0.0, cutmix0.0, )这个配置里rand-m9-mstd0.5是timm的RandAugment写法表示从9种增强操作里随机选幅度标准差0.5re_prob0.25表示有25%概率对图片做随机擦除。K1时我先不开MixUp和CutMix因为这两类增强会把不同类别的图片在像素层面混合支持集只有一张真实图时模型很容易学到“正确的假混合图”而不是真实分布K5以上再把mixup和cutmix从0.5开始试。这里有一个血泪经验小样本微调时如果发现验证集损失先降后升多半是增强过猛不是过拟合先把mixup关掉看趋势再说。5. EfficientNet图像分类避坑5个翻车现场与排查顺序5.1 训练不收敛先查归一化是不是被重复做了一次现象训练loss能降到很低但验证集准确率明显低于正常水平而且不管怎么调学习率都上不去。原因timm的create_transform在is_trainingFalse时已经内置了resize和归一化有些人在外面又叠了一层ToTensor加Normalize等于把输入像素分布做了一次偏移。EfficientNet的预训练权重期望输入是ImageNet的均值方差分布二次归一化后分布完全错位模型等于在一个它没见过的输入分布上做推理。解决训练和验证的数据变换全部走create_transform不要手动再拼一套transform。检查代码时搜一下是不是有transforms.Normalize出现在create_transform之前有就删掉。关键判断标准是验证集准确率明显低于训练集的正常收敛水平且训练loss下降正常基本就是前端输入分布出问题了。5.2 小显存小batchBN统计崩了Loss开始抖动现象显存只够放batch size 4或8训练过程中loss在第一个epoch剧烈波动甚至出现NaN。原因EfficientNet的BN层依赖一个batch内的统计量归一化小batch下均值和方差噪声太大训练不稳定。虽然预训练权重已经把BN的scale和shift调好了但训练模式下BN每次前向都要重新统计batch越小统计就越偏。解决在显存允许范围内尽量把batch提到16以上。如果实在提不上去最稳的做法是固定BN的running统计量只训练其他参数。具体方法就是遍历模型的所有BatchNorm层并调用m.eval()保持模型其他部分处于train状态for module in model.modules(): if isinstance(module, torch.nn.BatchNorm2d): module.eval()这样BN不再从当前batch计算统计量而是沿用预训练权重的统计值。代价是模型无法自适应目标域的分布变化所以这种方案更适合小样本短期微调不适合大规模重新训练。5.3 AMP混合精度SiLU激活在fp16下翻车现象开启AMP自动混合精度后训练loss出现NaN或者精度比纯fp32下降不少。原因EfficientNet的SiLU激活函数内部有指数运算fp16下的指数和小数值在反向传播时容易溢出梯度变成NaN。AMP默认策略会把部分算子放到fp16执行这类边缘溢出并不少见。解决先确认LossScaler有没有正确生效。PyTorch的标准AMP写法是torch.cuda.amp.autocast配合GradScaler如果只用autocast不用GradScaler梯度下溢问题会直接暴露。再不行就把分类头挪出autocast范围让全连接层在fp32下计算with torch.autocast(device_typecuda, dtypetorch.float16): features model.forward_features(images) logits model.classifier(features.float())把分类头拿出来后特征的均值和方差在fp16里计算核心分类线性运算回到fp32既保留了混合精度的显存收益又避开了全连接层梯度溢出。如果还翻车就把GradScaler的init_scale从默认的2**24调到2**28给梯度的中间值留更多安全空间。5.4 类别严重不均衡用Focal Loss替代CrossEntropy现象某一类样本占了数据集的绝大多数训练出的模型把所有图片都预测成多数类准确率看起来很高但少数类recall是0。原因交叉熵损失在类别不均衡时被多数类主导模型几乎不需要为少数类付出努力就能把整体loss压得很低。解决先按类别频率算一组权重给少数类更高的惩罚然后考虑把损失函数换成Focal Loss它通过(1 - pt) ** gamma这个因子自动降低已学好的样本权重让模型把注意力留给难分类的少数类样本。import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, gamma2.0, alphaNone): super().__init__() self.gamma gamma self.alpha alpha def forward(self, logits, target): ce F.cross_entropy(logits, target, reductionnone) pt (-ce).exp() loss (1 - pt) ** self.gamma * ce if self.alpha is not None: loss loss * self.alpha[target] return loss.mean()gamma2.0是Focal Loss的常用默认值表示对易分样本的衰减程度gamma越大模型越聚焦难例alpha是每个类别的权重向量通常取各类样本数倒数的归一化值。切换Focal Loss后要把早停指标从准确率改成macro-F1否则模型跌向多数类的趋势不会被及时发现。5.5 数据泄露同一航带的相邻帧让验证集“作弊”现象随机切分训练/验证集时验证准确率高达97%但模型部署到全新地区后精度暴跌到74%左右。原因森林航拍数据里相邻帧往往来自同一航带背景地形高度相似甚至部分图片有重叠区域。随机切分会把同一航带的相邻帧同时分到训练集和验证集模型学到的可能是“天空颜色一致就分到同一类”的捷径而不是树冠纹理特征。解决按航拍架次或地块进行分组同一组的图片必须同时进入训练集或验证集不能跨组切分。具体做法是给每张图片记录一个group_id然后使用sklearn的GroupShuffleSplit进行分层划分。如果数据里没有现成的组信息可以用GPS坐标做网格聚类把落在同一个空间格子的图片视为一组。这个坑在森林图像分类场景尤其隐蔽因为肉眼很难看出相邻帧的差异但模型对这些“作弊特征”极其敏感。6. 用CAM热力图验证模型学到的特征6.1 Grad-CAM绘制最后卷积层的注意力训练完模型后我建议不要急着交付先跑一遍Grad-CAM热力图确认模型关注的区域确实是业务上期望的目标特征。EfficientNet的最后一个MBConv层保留了较高的空间分辨率用它计算的热力图能比较精细地显示模型的注意力位置。from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget target_layer model.blocks[-1] # timm中EfficientNet的最后一个MBConv cam GradCAM(modelmodel, target_layers[target_layer]) input_tensor preprocess(image).unsqueeze(0) grayscale_cam cam(input_tensorinput_tensor, targets[ClassifierOutputTarget(0)])[0]这段代码对类别0生成热力图值越高代表模型判别“针叶林”时越依赖该区域。把热力图叠加到原图上你会发现两种结果热力图集中在树冠纹理上说明模型学到了合理特征可以放心继续调参热力图高亮在图片角落、水印或日期戳上说明模型在偷懒准确率再高也经不起换环境检验。不同timm版本的层命名可能略有出入如果model.blocks[-1]取不到直接打印model结构找最后一个带空间维度的卷积层即可。CAM验证不只是给自己看的也是交付模型给业务方时最有说服力的可视化材料。有一次我交项目前一天跑CAM发现一个分类模型高亮的位置全部落在相机水印上那一刻整个人都凉了。从那以后我把“先看CAM再看指标”写进了自己的交付清单宁可多花半天跑热力图也不愿意上线后再翻车。希望这篇文章里的流程能帮你少走几趟弯路也希望你照着跑通后能看到自己的模型把注意力放在真正该看的地方。本文还有配套的精品资源点击获取