简介:这份代码包面向正在学习Pytorch图像分类的开发者,以EfficientNet实战为主线,配套完整可运行的训练与测试脚本,适合想通过具体项目掌握EfficientNet迁移学习、数据集组织与模型保存加载的读者。资源共含8个文件,以5个Python脚本为核心,覆盖数据集封装、训练入口、测试评估等环节;两个pyc为缓存文件,一个pth为训练好的模型权重,压缩包整体约38MB,便于直接下载后对照学习。目前已有1205人学习下载,受到图像分类入门与进阶用户的关注。通过该资源可以快速复现一次完整的EfficientNet图像分类流程,结合作者提供的博文讲解,能够理解数据加载、模型构建、训练参数设置及结果测试的衔接方式,并基于已有权重文件直接验证模型效果,减少从零搭建的时间成本。
1. 图像分类EfficientNet实战.zip:解压之前,先想清楚你要用它解决什么问题
看到「图像分类EfficientNet实战.zip」这个名字,多数人的第一反应是解压、跑通、换数据,但我先给一个反直觉的结论:如果这个包里只有模型定义和远程权重下载,而不含可复用的数据组织、训练参数和评估脚本,那它只能帮你复现一次 ImageNet,离“实战”还差很远。EfficientNet 在图像分类任务里站稳脚跟,靠的不是单纯的“更深”或“更宽”,而是把网络深度、宽度和输入分辨率统一放缩的复合缩放思路。它对从业者的真正价值,是把通用视觉预训练能力迁移到具体业务上,比如森林图像分类或工业质检。适合谁读:手里有自定义数据集、想快速判断 EfficientNet 能不能上、以及希望在有限显存下把准确率再顶高一点的人。解压之前,先回答三个问题:数据集规模多大、要不要冻结主干、用什么指标验收,后面所有步骤都围绕这三个问题展开。
2. 从实战包看模型选型:EfficientNet 的复合缩放与 B0/B3/B5 边界
2.1 不是“越深越好”:复合缩放和 MBConv 的工作原理
EfficientNet 的起点是一个基线网络 B0,后续 B1 到 B7 不是简单把层数翻倍,而是用一个复合系数 φ 同时控制三个维度:网络深度(layer 数)、宽度(通道数)和输入分辨率。这个设计的动机来自一个观察:单独把某一维度放大,准确率提升会很快饱和;但三个维度按比例放大时,可以在差不多的计算量下拿到更高精度。实战中理解这一点很重要,因为很多刚接触的人一上来就选 B5,结果显存不够、训练很久,准确率却不比小模型迁移后高多少。
MBConv(移动倒残差瓶颈)是 EfficientNet 的基础模块,核心是深度可分离卷积和 Squeeze-and-Excitation(SE)注意力。深度可分离卷积把空间卷积和通道卷积分开,降低参数量;SE 模块则对每个通道做全局池化后学习权重,让模型更关注信息量大的通道。换句话说,EfficientNet 的涨点不只是架构堆料,而是“让每个通道知道自己该看什么”。这个特性放到细粒度分类上特别有用,比如森林图像分类里分辨不同树种的纹理差异,SE 通道注意力会自然地放大有效特征。
2.2 一个可靠实战包的内部结构:模型、配置、数据、权重分离
如果你见过足够多这种实战 zip,会发现可靠版本通常长一个样子:模型定义、配置、数据目录和训练脚本彼此独立。我一般会这样组织:
efficientnet-classifier/ ├── data/ │ ├── train/ # 每个子目录一个类别 │ └── val/ ├── models/ │ └── efficientnet.py # 实现或引入 timm/create_model ├── configs/ │ └── b3.yaml # 模型名、分辨率、epoch、学习率 ├── train.py # 训练入口 ├── evaluate.py # 验证与指标 └── requirements.txt如果你的 zip 解压后只有单个 ipynb 文件,问题也不大,但建议按上面的结构拆开,尤其是configs/独立出来。因为 EfficientNet 的输入分辨率、预训练路径、分类头维度和学习率都是彼此关联的参数,分散在代码里会让你调一次参就改一次代码,最后难以界定哪个改动带来了收益。把配置独立出来,至少你还能在表格里记录每一组实验。
2.3 B0/B3/B5 怎么选:参数表与探路脚本
下面这张表是我在实际项目中常用的起步选择依据,不是越大越好,而是看你的数据量和推理环境。
| 模型 | 默认输入分辨率 | 参数量级 | 训练难度 | 适用场景 |
|---|---|---|---|---|
| EfficientNet-B0 | 224 | 约 5.3M | 低 | 快速验证、小数据集、移动端初步评估 |
| EfficientNet-B3 | 300 | 约 12M | 中 | 中等数据量的业务分类,显存 8G 起步 |
| EfficientNet-B5 | 456 | 约 30M | 中高 | 高分辨率细粒度识别,显存 16G 以上 |
选型时先不要看测试集准确率,而要看单张推理耗时和显存占用。一个省事的方法是用 timm 列出现有模型,先跑一个最小的前向:
import timm print(timm.list_models('efficientnet_b*', pretrained=True)) model = timm.create_model('efficientnet_b3', pretrained=True, num_classes=0) print(model)这段代码先打印可用的 EfficientNet 变体,再创建 B3 模型并去掉分类头(num_classes=0)。这样你可以直接观察特征维度、前向计算量,再决定自己的分类头接多少神经元。逻辑说明:create_model的pretrained=True会加载在 ImageNet 上训练好的权重,num_classes=0是截断分类头的常用写法,便于做迁移学习。参数说明:efficientnet_b3可以换成efficientnet_b0或efficientnet_b5;如果网络受限,可以先只跑 CPU 推理,model = timm.create_model('efficientnet_b0', pretrained=False)验证结构。
提示:不要一出问题就跳到更大的模型。B0 到一个新数据集上的表现,往往能预示 B3 同参数下的趋势。先小后大,是我觉得最稳妥的选型路径。
3. 用预训练 EfficientNet 跑通自定义图像分类:最小脚本与参数
3.1 数据准备:让 ImageFolder 处理多分类目录
EfficientNet 实战包多数依赖 torchvision 的ImageFolder,它要求训练数据按类别分目录存放。假设你做森林图像分类,类别是 conifer、broadleaf、bare,目录结构应该是:
mkdir -p data/forest/train/conifer mkdir -p data/forest/train/broadleaf mkdir -p data/forest/train/bare # 验证集同样建目录,但放不同图片 mkdir -p data/forest/val/conifer别小看这一步,类别目录命名和验证集划分直接决定后续一切指标。我见过的翻车大多是验证集没和训练集严格分开,或者类别名带了空格,在加载时报错。实操建议:把原始图片按类别放入对应文件夹,然后用 Python 脚本按比例切到 train/val,不要手动拖拽。
import os import random import shutil random.seed(42) src = 'raw_images' # 原始图片,文件名是类别前缀 classes = ['conifer', 'broadleaf', 'bare'] val_ratio = 0.2 for c in classes: files = [f for f in os.listdir(src) if f.startswith(c + '_')] random.shuffle(files) val_n = int(len(files) * val_ratio) for f in files[:val_n]: shutil.copy(os.path.join(src, f), f'data/forest/val/{c}/{f}') for f in files[val_n:]: shutil.copy(os.path.join(src, f), f'data/forest/train/{c}/{f}')这里用f.startswith(c + '_')是假设图片名带类别前缀;如果你的数据是 CSV 标注,就不要用这个脚本,改用 pandas 读取标签后按行复制。逻辑说明:先把数据随机打乱,再按比例切分,保证每个类别的验证比例一致。参数说明:val_ratio在中小数据集上我一般取 0.15~0.25,太大会让训练样本变少,太小则验证指标不稳。
3.2 train.py 最小可用脚本:从加载权重到保存权重
下面是一个能直接跑通的最小训练脚本,结构上参考了常见图像分类项目,但压缩到只剩核心逻辑。你可以把它保存为train.py,然后配合上一节的目录结构运行。
import torch import torch.nn as nn from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR from torch.utils.data import DataLoader from torchvision import datasets, transforms from timm import create_model def main(): seed = 42 torch.manual_seed(seed) device = 'cuda' if torch.cuda.is_available() else 'cpu' train_tf = transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) val_tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) train_ds = datasets.ImageFolder('data/forest/train', train_tf) val_ds = datasets.ImageFolder('data/forest/val', val_tf) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=2, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=2, pin_memory=True) model = create_model('efficientnet_b0', pretrained=True) in_features = model.classifier.in_features model.classifier = nn.Linear(in_features, len(train_ds.classes)) model.to(device) optimizer = AdamW(model.parameters(), lr=3e-4, weight_decay=0.01) scheduler = CosineAnnealingLR(optimizer, T_max=20) criterion = nn.CrossEntropyLoss(label_smoothing=0.1) for epoch in range(20): model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() logits = model(images) loss = criterion(logits, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) scheduler.step() # 每轮验证一次,只记录准确率 model.eval() correct = total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) preds = model(images).argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) print(f'epoch {epoch+1}: loss={running_loss/len(train_ds):.4f}, ' f'val acc={correct/total:.4f}') torch.save(model.state_dict(), 'efficientnet_b0_forest.pt') if __name__ == '__main__': main()这段脚本的逻辑流是:定义数据增强 → 加载 ImageFolder → 创建预训练 B0 → 替换分类头 → AdamW 优化 → 每轮验证一次 → 保存权重。参数说明:
RandomResizedCrop(224):在训练时随机裁剪并缩放到 224,和 B0 默认输入一致。换 B3 时改这里和 CenterCrop 的尺寸。batch_size=32:在显存足够时尽量用 32 或更大;B0 在 8G 显存下开 32 没问题,B3 建议降到 16。AdamW(lr=3e-4, weight_decay=0.01):全量微调的典型起点。如果只训练分类头而冻结主干,学习率可以提到 1e-3。label_smoothing=0.1:给标签做平滑,缓解过拟合,尤其适合小数据集。
提示:如果你想让模型先“热起来”,前几个 epoch 可以固定 backbone,只让分类头学习;等到验证准确率不再上升,再放开全部层微调,学习率降到 1e-4。这是迁移学习里最常见也最稳的节奏。
3.3 参数为什么这么设:学习率、批次、冻结层边界
为什么分类头替代后不能直接沿用 ImageNet 的 1000 个输出?因为你的类别数不同,最后一层Linear(in_features, 1000)与nn.Linear(in_features, 3)的维度不匹配。直接初始化新头会带来一个现象:前几个 batch 的 loss 明显偏高,这是正常的,因为新分类头是随机权重。如果你看到 loss 始终不降,反而要检查是不是只把分类头设置为可训练,而主干的学习率被设成了 0。
冻结层边界要分清:requires_grad=False会让该层参数不被优化,但如果你的 BatchNorm 层仍然是训练模式,均值方差仍会更新。冻结主干时建议把 BatchNorm 也切到 eval 模式,否则预训练统计会被逐步破坏,这是很多迁移学习项目里隐藏的“黑匣子”问题。
4. 分辨率、分类头、混合精度:EfficientNet 容易被忽略的三个边界
4.1 输入分辨率真的是超参数,换分辨率等于换模型
EfficientNet 的每个变体都有一个“推荐分辨率”,B0 是 224,B3 是 300,B5 是 456。这不是随意数字,而是复合缩放时和网络深度宽度一起参与优化的。把 B0 的输入强行改成 256,理论上可行,但预训练权重里 BatchNorm 的统计量是在 224 分布上估计的,除非微调足够多轮,否则早期会看到输出分布明显偏移。我一般把分辨率当作超参数来调,但调法有次序:先小分辨率跑通流程,再逐步加大到目标分辨率,观察验证准确率和训练耗时。常见做法是:
- 显存紧张:用 B0 + 224,优先保证 batch size;
- 想提升精度:分辨率上调到 300,但要同步降低 batch size,必要时加梯度累积;
- 细粒度分类:如果目标物体局部纹理很重要,才值得上 456 分辨率,这时直接考虑 B5 或 B4。
盲目调大分辨率的代价是训练时间近线性上涨,而准确率提升往往在 1% 以内。所以我的原则是:先看 baseline 准确率离目标还有多远,再决定要不要动分辨率。
4.2 用 ViT 做对照评估时,分类头要不要调整
这是一个很实际的问题:你已经用 EfficientNet 跑出了结果,想用 Vision Transformer 做对照实验,直接换模型还是要有额外操作。答案是分类头必须调整,而且不能保留 EfficientNet 训练好的最后一层权重。原因有三点:
- ViT 的
head之前有特殊的pre_logits,输出维度不一定等于 EfficientNet 的classifier.in_features; - ViT 使用 CLS token 聚合全局信息,而 EfficientNet 是全局平均池化后接线性层,两者特征语义空间不同;
- 即便维度碰巧相同,用 CNN 的分类头初始化 Transformer 的线性头,也会让早期 loss 异常震荡。
正确做法是:用timm.create_model('vit_base_patch16_224', pretrained=True, num_classes=你的类别数)直接创建新模型,然后从头训练分类头。如果你只想用 ViT 做特征提取器,就把num_classes=0取出 token 特征,再自己接一个nn.Linear(feat_dim, num_classes)。记住,分类头是“跟着数据走的”,无论主干是 EfficientNet 还是 ViT,换数据集时都要重新初始化最后几层,这是我在多个项目里验证过的规律。
4.3 混合精度、标签平滑与 EMA:提升稳定性的数值细节
谈到实战细节,最实用的是混合精度,也就是 AMP。EfficientNet 里的 BatchNorm 和 Swish 激活在低精度下容易出现数值不稳定,所以训练时不能简单用model.half()。推荐做法是用 PyTorch 自带的torch.cuda.amp:
scaler = torch.cuda.amp.GradScaler() for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() with torch.amp.autocast('cuda'): logits = model(images) loss = criterion(logits, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()逻辑说明:autocast让前向在 FP16 下计算,Loss Scaling 防止梯度下溢。参数说明:GradScaler的初始 scale 值一般不用改;如果你看到 loss 变成 NaN,优先怀疑 FP16 溢出,可以关闭混合精度对比一下。EMA(指数移动平均)是另一个低成本技巧,维护一份参数均值用于验证,往往能让验证准确率稳定提升 0.3%~0.8%。我通常在最后一个 epoch 保留ema_model,不追求实时更新,只在 val 时使用。
5. 图像分类实战常见问题排查:翻车点与修复经验
5.1 训练损失下降,可验证准确率不动
现象:训练集 loss 平稳下降,但每轮验证准确率始终在随机水平附近,比如三分类一直停在 33%。
原因:最常出现在验证集预处理和训练集不一致。最常见的是验证集用了和训练集相同的RandomResizedCrop,导致每张图都做了随机裁剪,结果模型看到的验证样本不完整;或者验证集类别目录没有对齐ImageFolder.classes的顺序。另一个原因是在 GPU 上跑得到训练指标,但验证时忘了model.eval(),BatchNorm 统计还停留在训练状态,输出不稳定。
解决:把验证集 transform 单独写成Resize(256) + CenterCrop(224),并确保已调用model.eval()。再检查val_ds.classes和train_ds.classes是否一致,不一致的话重新按类目组织目录。如果这两个都没问题,就看 loss 值是否始终高于某个阈值,比如 B0 三分类的初始 loss 约 1.1,如果落在 1.5 以上且不降,多半是分类头维度不对应导致的随机输出。
5.2 反复用测试集调“最准”模型,结果一上生产就崩
现象:模型在本地测试集上准确率高达 95%,但上线后面对新数据只有 80%,甚至更低。
原因:这是数据泄漏和过拟合测试集的典型表现。很多人把测试集反复用于选模型、选超参数,测试集的信息已经间接进入决策过程,相当于用答案考自己。另一部分是训练集和测试集分布太相似,比如按时间采集的数据被随机切分,导致同一时间段的图片同时出现在训练和测试里,模型学到了背景或光照,而不是目标特征。
解决:把验证集单独抽出来,专用于调参;测试集只在最终评估时用一次。项目早期用 k 折交叉验证替代单次切分。如果数据带有时间戳或拍摄地点,一定要按时间和设备分组切分,而不是随机切分,这一点在森林图像分类这类场景尤其重要,同一块林地不同时间的光照差异很大。
5.3 显存溢出和预训练权重加载失败的环境坑
现象:训练到中途报CUDA out of memory,或者加载权重时提示state_dict尺寸不匹配。
原因:显存溢出通常是 batch size 和分辨率相乘的结果;B3 在 8G 显存下如果仍然保持 64 的 batch,几乎必然爆显存。权重加载失败则常见于用错模型名,比如创建 B3 却加载 B5 的权重,或num_classes不一致导致最后一层形状对不上。
解决:先降 batch size 到 16,再把分辨率降一档。如果还炸,检查是否有多进程休眠占用显存,用torch.cuda.empty_cache()在每轮验证后清缓存。加载权重时明确固定pretrained=False之后手动载入,避免 timm 自动下载到错误版本。我的经验是:显存溢出是最好解决的坑,它不需要玄学调参,只需把 batch size 和分辨率解耦,先固定分辨率为 224,batch 从 32 起,等显存有余量再逐步增加。
提示:三个坑有一个共同预防口诀——先固定数据流,再调模型;先固定分辨率,再调 batch;先确认验证集没泄漏,再追求高准确率。
6. 少样本验证技巧:1-shot/5-shot 试水与混淆矩阵检查
6.1 用冻结特征做 1-shot/5-shot 评估,提前判断数据可学性
如果你拿到一个全新的小样本数据集,先别急着全量微调,我习惯先做一层“冰柱测试”:冻结 EfficientNet 主干,用预训练特征训练一个线性分类器。做法是把图片经过model.forward_features提取成特征向量,然后传入逻辑回归。每类只取 1 张图、5 张图,分别得到 1-shot 和 5-shot 的准确率,以此评估这个域是否能被预训练模型“接住”。
from sklearn.linear_model import LogisticRegression # 先收集全部特征和标签 features, labels = [], [] with torch.no_grad(): for images, lb in val_loader: images = images.to(device) feat = model.forward_features(images).mean(dim=[2, 3]) # (B, C) features.append(feat.cpu()) labels.append(lb) X = torch.cat(features).numpy() y = torch.cat(labels).numpy() # 每类只保留 k 张做训练,模拟 1-shot/5-shot k = 5 X_train, y_train, X_test, y_test = [], [], [], [] for c in np.unique(y): idx = np.where(y == c)[0] np.random.shuffle(idx) X_train.append(X[idx[:k]]) y_train.append(y[idx[:k]]) X_test.append(X[idx[k:]]) y_test.append(y[idx[k:]]) X_train = np.concatenate(X_train) y_train = np.concatenate(y_train) clf = LogisticRegression(max_iter=1000) clf.fit(StandardScaler().fit_transform(X_train), y_train) acc = clf.score(StandardScaler().fit(X_train).transform(X_test), y_test) print(f'{k}-shot linear probe acc: {acc:.4f}')逻辑说明:forward_features输出不带分类头,mean(dim=[2,3])做全局平均池化得到一维特征,然后直接交给逻辑回归。这段代码的价值是极快,几秒钟就能看到预训练模型在这个数据域上的“底线”。参数说明:k=1就是 1-shot,k=5是 5-shot;如果准确率已经在可用范围,说明主干特征足够好,再微调全模型,收益会更明显;如果接近随机猜测,就要考虑数据量、标注质量,而不是继续加大模型。
6.2 混淆矩阵与 CAM 热力图的确认方法
线性探测通过后再看失败模式。三分类数据我必看混淆矩阵,因为准确率会掩盖类别的失衡问题:比如 bare 可能被大量误判成 broadleaf。用sklearn.metrics.confusion_matrix出一张表,横向看哪些类别互相混淆,再回到图片样本中逐张确认。如果是类别自身相似度高,那就需要更高分辨率的 B5 或者更细的标注;如果错误集中在某一张拍摄条件下的图,就要补充该条件的训练样本。
CAM(类激活图)能给出模型到底在看哪里。EfficientNet 是 CNN,可以用 Grad-CAM 可视化最后卷积层的梯度。做法不复杂:取得最后一个卷积层的输出和分类得分,对得分做反向传播得到梯度,再对通道加权求平均,叠回到原图上。我通常只看两类样本:误判样本和低置信度样本。如果 CAM 高亮区域不在目标物体上,说明模型在“抄捷径”,它对某个背景块或光照变化形成了依赖。这种时候加数据增强或换更高分辨率,往往能拉回真正的特征。
这个流程走下来,你就有了三层判断:1-shot/5-shot 的线性探测判断数据可不可学,混淆矩阵判断误判集中在哪,CAM 判断模型是不是看错了位置。我在新项目里都会先做完这三步,再决定是否大规模训练。曾经有个森林图像分类项目,线性探测 5-shot 只有 58%,换成 B5 并全量微调后也只涨了 6 个点,最后查出来是训练集中裸地和草地两类图像本身重叠太多,标注口径不一致,问题根本不在模型。希望这段路能帮你少走一次弯路,也希望你遇到相似问题时,先冷静从数据和验证逻辑入手,而不是盲目堆模型。
本文还有配套的精品资源,点击获取