简介:面向图像分类与迁移学习入门者及研究人员,这份基于EfficientNet的104种常见花卉识别项目,覆盖了从数据加载、模型选择到训练评估的完整流程,是一份可直接运行的深度学习实战方案。项目内置b0至b7共8种EfficientNet变体,支持通过pretrained与freeze_layers参数灵活选择加载官方权重或仅微调分类层,同时提供Adam、SGD、AdamW三种优化器和余弦退火学习率策略,在约16k张花卉图像上训练精度可达0.9左右。压缩包采用zip格式,共2000个文件,主要为1993张花卉JPG图像,配套3个Python脚本、2个JSON指标文件、1个txt文本及1份readme说明文档,整体约514MB。训练与验证阶段会输出loss和准确率曲线,并生成混淆矩阵、recall、precision、F1-score及特异度等评估图表,各类别具体指标可在JSON文件中查看。目前已有124人学习,特别适合希望快速跑通完整迁移学习流程、再参考readme替换数据集开展自定义花卉或细粒度图像分类实验的开发者。
1. EfficientNet 迁移学习做 104 类花卉识别:16k 张图如何跑到 0.9 准确率
同一个 104 类花卉图像分类任务,有人从零训练 ResNet 熬通宵,验证准确率卡在 0.6;有人用 EfficientNet 轻量级网络加迁移学习,一个下午就拿到 0.9。这份资源就是一套拿 EfficientNet b0~b7 搭建、约 16k 张图做支撑的迁移学习工程,pretrained 和 freeze_layers 决定是否加载官方权重、是否冻结骨干,优化器在 Adam/SGD/AdamW 之间切换,学习率用余弦退火,训练集准确率可以到 0.9 左右。训练完自动输出 loss、混淆矩阵、recall、precision、F1、特异度到 JSON。
适合正在做课程设计的学生、刚接触迁移学习的开发,以及想要一份可复现 baseline 做对比的研究者。下面按数据对齐、模型选型、评估落盘、避坑排查的顺序把关键操作和参数选择一次讲清楚。
2. 数据集与标签对齐:16k 张图片如何变成可训练的 DataLoader
2.1 先数图片,再对标签:文件名与 class_id 的核对
这个资源里的原始文件用数字编号命名,出现 0008.jpg、0038.jpg、15104.jpg 这种连续编号,说明图片名本身不是类别名,类别信息必然落在单独的标签文件或目录层级上。我最怕的是标签和图片数量对不上,所以拿到手第一件事是数文件。在数据集根目录执行:
# 统计所有 jpg 数量,数量应接近资源说明里的 16k find . -type f -name "*.jpg" | wc -l # 查看前 30 个文件名,确认编号是连续还是带前缀 find . -type f -name "*.jpg" | sort | head -30这两条命令只做一件事:验证原始素材没有被拷贝截断。常见问题是下载解压时中断,wc -l数出来只有几千张,后面 DataLoader 一跑就报错。接着读标签文件:
import pandas as pd labels = pd.read_csv("labels.csv") # 资源内附带的标签表 print(labels.head()) print("类别数:", labels["class_id"].nunique()) print("图片数:", labels["image_name"].nunique())如果图片数和文件名对不上,优先检查是不是有扩展名为.jpeg或.png的图被漏掉。目录结构不是关键,关键看标签表里的image_name是不是能直接拼出完整路径。我一般会再跑一层检查,把“标签表里存在但磁盘上不存在”的文件名筛出来:
import os missing = [ name for name in labels["image_name"] if not os.path.exists(os.path.join("dataset", name)) ] print("缺失图片数:", len(missing), missing[:10])这一步看起来很笨,实际是最值钱的一步,能避免后面训练到一半才出现FileNotFoundError。尤其是编号型文件名,在不同系统之间容易丢前导零,比如0008.jpg被转成8.jpg,可复现性就被破坏了。血泪经验:最好把对齐检查写进dataset.py,训练脚本启动前自动执行。
2.2 分层划分训练集和验证集:别让某种花只出现在一半数据里
104 种花卉类别分布并不是均匀的,某些品种可能只有几十张,另一些有几百张。如果直接随机train_test_split,小类别很容易在验证集里只剩一两张,评估出的召回率全是 0。正确做法是按类别分层切分:
from sklearn.model_selection import train_test_split train_df, val_df = train_test_split( labels, test_size=0.2, stratify=labels["class_id"], random_state=42, ) print("训练样本:", len(train_df), "验证样本:", len(val_df))stratify=labels["class_id"]保证每个类别在训练集和验证集里都按 8:2 的比例留下样本;random_state=42固定随机种子,保证每次复现得到同样划分。对于 104 类、16k 张的数据量,每个类别在验证集里大约能留下 25~30 张,足够画混淆矩阵和计算各类别的 precision、recall、F1。如果以后换到只有几千张的自定义数据集,test_size要降到 0.15,否则验证集的波动会非常大,指标也变成噪声。
2.3 DataLoader 与 FlowerDataset:输入尺寸、归一化和 batch 配置
EfficientNet 不同变体对输入分辨率有不同要求:b0 用 224×224,b1 用 240,b3 到 300,b5 用 456,b7 直接用到 600×600。颜色归一化不能乱写,否则加载了官方预训练权重后反而掉点。我把归一化和分辨率参数收敛到一个配置项里:
import torch from torch.utils.data import Dataset, DataLoader from PIL import Image import torchvision.transforms as T IMG_SIZE = 224 # efficientnet_b0 默认输入 train_transform = T.Compose([ T.Resize((IMG_SIZE + 32, IMG_SIZE + 32)), T.RandomCrop(IMG_SIZE), T.RandomHorizontalFlip(0.5), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_transform = T.Compose([ T.Resize((IMG_SIZE, IMG_SIZE)), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])这里Resize((IMG_SIZE+32, IMG_SIZE+32))再加RandomCrop(IMG_SIZE)是常见的训练增强组合,相当于给图片留了一点随机裁剪空间;推理阶段直接缩放到IMG_SIZE即可。Normalize的 mean 和 std 是 ImageNet 统计值,因为加载的是官方 ImageNet 预训练权重,输入分布必须对齐。如果用 b5 或 b6,把IMG_SIZE改到 456 或 528,还要同步把 batch size 减半,否则显存直接爆。
接着是自定义 Dataset 类,把 DataFrame 里的样本转成 tensor:
class FlowerDataset(Dataset): def __init__(self, df, label_map, img_dir, transform): self.df = df self.label_map = label_map self.img_dir = img_dir self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] path = os.path.join(self.img_dir, row["image_name"]) image = Image.open(path).convert("RGB") label = self.label_map[row["class_id"]] return self.transform(image), torch.tensor(label, dtype=torch.long) train_dataset = FlowerDataset(train_df, label_map, "images", train_transform) train_loader = DataLoader( train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True, )label_map是把字符串类名转成 0~103 整数索引的字典,在资源里通常和标签文件放在一起。num_workers=4建议在 Linux 下使用,Windows 下如果报错就调成 0;pin_memory=True在 CUDA 环境下能减少一次拷贝开销。这套 DataLoader 是整个工程的地基,后面模型训不起来,八成是这里没对齐。
2.4 类别不平衡时的二选一:加权采样还是增强
当某一类花图片只有 20 张时,模型训练极易忽略该类,出现“整体准确率还行,但少数类 precision 全为 0”的情况。常见做法是加权采样,给少数类更多的抽中概率:
from torch.utils.data import WeightedRandomSampler class_counts = labels["class_id"].value_counts().to_dict() weights = [1.0 / class_counts[c] for c in train_df["class_id"]] sampler = WeightedRandomSampler( weights, num_samples=len(train_df), replacement=True, ) train_loader = DataLoader( train_dataset, batch_size=32, sampler=sampler, # 用 sampler 时不再设 shuffle=True num_workers=4, pin_memory=True, )这里给每个样本分配的权重是1 / 类别频数,小类别样本的抽中概率自动提高。这个资源没有刻意强调平衡,但你把class_metrics.json打开看 support 时,会发现少数类指标明显塌陷,这时候再上加权采样也来得及。
提示:资源里自带一键运行脚本,但数据路径和标签列名在不同操作系统上可能不一致。建议先跑一次 2.1 的对齐检查,再进入训练,别让缺图问题浪费一整个训练周期。
3. EfficientNet 选型与训练参数:b0~b7、pretrained、freeze_layers、余弦退火的取舍
3.1 b0~b7 到底差在哪:缩放系数决定输入分辨率和参数规模
EfficientNet 的核心是 compound scaling,同时放大深度、宽度和输入分辨率,而不是像 ResNet 那样只加深。b0 是基本型,参数量约 5.3M;b7 是最高版本,参数量约 66M,输入分辨率提高到 600×600。两者计算量差距接近一个数量级,所以在 16k 张图像这样中等规模的数据上,我通常建议从 b0 或 b1 先跑通流程,再换 b3、b5 冲精度。常用选型参考如下:
| 模型变体 | 输入分辨率 | 参考参数量 | 适用场景 |
|---|---|---|---|
| efficientnet_b0 | 224×224 | 约 5.3M | 快速验证、显存有限 |
| efficientnet_b1 | 240×240 | 约 7.8M | 平衡速度和精度 |
| efficientnet_b3 | 300×300 | 约 12M | 中等精度要求 |
| efficientnet_b5 | 456×456 | 约 30M | 高精度、显存允许 |
| efficientnet_b7 | 600×600 | 约 66M | 极端精度、多卡 |
表格里的参数值是公开网络的常见统计,实际以timm返回的模型配置为准。这个资源的训练脚本把model_name暴露成参数,是希望你在不同机器配置下做折中。我的建议是:显卡在 8G 以下先跑 b0,16G 以上再碰 b5,别一上来就选 b7,训练慢且中途容易 OOM。
3.2 pretrained 与 freeze_layers 的四种组合
迁移学习不是简单地加载权重,还要决定哪些层参与训练。资源里两个参数pretrained和freeze_layers控制的正是这件事。用timm搭模型是这个场景最顺的写法:
import torch import torch.nn as nn import timm class EfficientNetClassifier(nn.Module): def __init__(self, model_name="efficientnet_b0", num_classes=104, pretrained=True, freeze_layers=True): super().__init__() self.backbone = timm.create_model( model_name, pretrained=pretrained, num_classes=num_classes, ) if freeze_layers: self._freeze_except_head() def _freeze_except_head(self): for name, param in self.backbone.named_parameters(): if "classifier" not in name: param.requires_grad = False def forward(self, x): return self.backbone(x)timm.create_model的num_classes参数会把原始 1000 类分类头替换成 104 类,同时自动修正最后一层的输入维度。_freeze_except_head遍历所有参数,只让分类器层的参数保持requires_grad=True,其他骨干参数全部冻结,这样反向传播时只更新分类头,梯度计算量小很多。
四种组合的适用场景:
pretrained=True, freeze_layers=True:数据量小、资源少,最快拿到 baseline;pretrained=True, freeze_layers=False:本资源最推荐,16k 张图足够微调骨干,精度通常比只训分类头高 2~4 个点;pretrained=False, freeze_layers=False:彻底从头训练,数据量至少再翻十倍,不推荐;pretrained=False, freeze_layers=True:随机初始化骨干再冻结,等于只训一个线性层,基本没有意义。
这种“目标类别已知、直接用预训练先验改造分类器”的思路,本质上就是直推式迁移学习的落地形式:你不去学新域的结构,只把别人在 ImageNet 上学到的通用特征拿过来重新排列组合。
3.3 优化器、损失函数与余弦退火:一套能直接复用的训练配置
项目给出了 Adam、SGD、AdamW 三个选项,损失函数用多类别交叉熵,学习率策略是 cos 余弦退火。我的经验是:AdamW 结合余弦退火在迁移学习微调场景下泛化更好,SGD 对学习率更敏感,但收敛后决策边界更干净;Adam 居中。下面是训练配置的常见写法:
from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR lr = 1e-3 weight_decay = 0.05 epochs = 30 criterion = nn.CrossEntropyLoss() optimizer = AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr=lr, weight_decay=weight_decay, ) scheduler = CosineAnnealingLR( optimizer, T_max=epochs, eta_min=1e-6, )filter(lambda p: p.requires_grad, ...)传给优化器,是因为冻结层不参与更新时,如果全部传进去,会在反向传播时报“变量无梯度”错误。T_max=epochs表示 30 个 epoch 内学习率从 1e-3 降到 1e-6,恰好一个余弦周期;eta_min是下界,防止学习率降到完全为 0,否则最后几个 epoch 模型完全学不动。
如果改用 SGD,一般配momentum=0.9, weight_decay=1e-4,初始学习率降为 1e-2 再走余弦。从该资源的输出看,训练集准确率能到 0.9 左右,验证集如果也稳定在 0.8 以上,这个配置基本不需要大改;如果验证集明显低于训练集,说明骨干微调过头,可以把freeze_layers=True或者把lr降到 3e-4。
3.4 设备迁移与梯度裁剪:稳定训练的最后一处细节
在使用 b5 或 b6 时,高分辨率输入会让 batch 变小,梯度也更容易出现尖刺。训练主循环里我会固定加两步:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) for epoch in range(epochs): model.train() for images, targets in train_loader: images = images.to(device) targets = targets.to(device) logits = model(images) loss = criterion(logits, targets) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() scheduler.step()clip_grad_norm_把梯度范数限制在 5.0 以内,能明显减少 loss 突然跳成 NaN 的概率。对 b0 和 b1 来说这个值可以调高到 10,对 b5 以上建议调低到 3。注意scheduler.step()必须放在每个 epoch 结束之后,而且要放在optimizer.step()执行完的位置,顺序错了余弦退火会提前走完一个周期。
4. 评估与指标落盘:loss 曲线、混淆矩阵、recall、precision、F1、特异度的导出方式
4.1 训练/验证的 loss 与准确率曲线:过拟合的直观判断
训练过程中把每个 epoch 的训练和验证指标记录下来,是最低成本的过拟合检测方式。训练循环里维护一个history字典:
history = { "train_loss": [], "val_loss": [], "train_acc": [], "val_acc": [], } for epoch in range(epochs): model.train() train_loss, train_correct, train_total = 0.0, 0, 0 for images, targets in train_loader: images = images.to(device) targets = targets.to(device) logits = model(images) loss = criterion(logits, targets) optimizer.zero_grad() loss.backward() optimizer.step() train_loss += loss.item() * images.size(0) train_correct += (logits.argmax(1) == targets).sum().item() train_total += images.size(0) scheduler.step() model.eval() val_loss, val_correct, val_total = 0.0, 0, 0 with torch.no_grad(): for images, targets in val_loader: images = images.to(device) targets = targets.to(device) logits = model(images) loss = criterion(logits, targets) val_loss += loss.item() * images.size(0) val_correct += (logits.argmax(1) == targets).sum().item() val_total += images.size(0) history["train_loss"].append(train_loss / train_total) history["train_acc"].append(train_correct / train_total) history["val_loss"].append(val_loss / val_total) history["val_acc"].append(val_correct / val_total)验证阶段必须包在torch.no_grad()里,同时要model.eval(),否则 BN 层的统计参数会继续更新,验证指标就失真了。loss * images.size(0)是为了按样本数加权,避免最后一个 batch 不完整导致平均 loss 算偏。曲线绘制可以直接看两端:
import matplotlib.pyplot as plt plt.plot(history["train_loss"], label="train_loss") plt.plot(history["val_loss"], label="val_loss") plt.legend() plt.savefig("loss_curve.png") plt.close() plt.plot(history["train_acc"], label="train_acc") plt.plot(history["val_acc"], label="val_acc") plt.legend() plt.savefig("acc_curve.png") plt.close()如果train_loss持续下降但val_loss掉头上升,基本确定是过拟合,回退到freeze_layers=True或把weight_decay调到 0.1。需要注意,资源描述里的训练集准确率 0.9 是指训练过程的记录值,你应该更关注验证曲线是否同步稳定,训练集上的数字再好看也不能替代泛化能力。
4.2 混淆矩阵与四类指标:recall、precision、F1、特异度的计算
准确率在 104 类任务上会掩盖太多信息:即使整体 0.9,某几个相似难辨的花种召回率可能只有 0.5。项目里导出的指标包含混淆矩阵、recall、precision、F1 和特异度,最终汇总到 JSON 文件。评估代码可以直接用 sklearn 实现:
import numpy as np from sklearn.metrics import confusion_matrix, classification_report import json all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for images, targets in val_loader: images = images.to(device) logits = model(images) all_preds.extend(logits.argmax(1).cpu().numpy()) all_labels.extend(targets.cpu().numpy()) cm = confusion_matrix(all_labels, all_preds) report = classification_report(all_labels, all_preds, output_dict=True) with open("class_metrics.json", "w", encoding="utf-8") as f: json.dump(report, f, indent=2, ensure_ascii=False) # 特异度:TN / (TN + FP),按类别逐列计算 tn = cm.sum() - (cm.sum(axis=0) + cm.sum(axis=1) - np.diag(cm)) fp = cm.sum(axis=0) - np.diag(cm) fn = cm.sum(axis=1) - np.diag(cm) specificity = tn / (tn + fp)classification_report的output_dict=True可以直接序列化成 JSON,里面每个类别都带 precision、recall、f1-score、support,训练完打开文件就能定位瓶颈花种。特异度公式里的tn是每个类别的真负样本数,用混淆矩阵的全局总量减去该类别相关的行列得到,不能直接从 sklearn 里取现成的。104×104 的混淆矩阵直接画热力图会糊成一团,我通常把原始cm存成confusion_matrix.npy或 CSV,另外单独输出对角线和 Top 混淆对,方便后续复现。
4.3 模型权重的保存与加载:标签映射必须一起存
训练完通常保存两样东西:模型权重和标签映射。104 类任务里class_id到真实花名的映射如果不一起存,下次预测就会张冠李戴。保存代码:
torch.save({ "model_state_dict": model.state_dict(), "label_map": {i: name for name, i in label_to_idx.items()}, "model_name": model_name, "num_classes": num_classes, }, "best_model.pth")label_map保存的是生成 DataLoader 时使用的字典,预测阶段只要加载这个.pth,就能把模型输出的类别序号翻译回真实花名。这一步在项目描述里虽然只用一句“具体各类别的指标在 json 文件中查看”带过,但实际价值可能比训练本身更大,因为没有标签映射的权重文件基本等于废品。
5. 避坑排查:跑这个花卉图像分类项目最容易翻车的四个地方
5.1 缺图错误:FileNotFoundError 提示某张 jpg 不存在
现象:训练刚跑一个 epoch,DataLoader 抛出FileNotFoundError: 15104.jpg,而且每次报的图片还不一样。
原因:标签表和实际文件名没有对齐,常见场景是 Windows 下载解压后文件名大小写变化,或者标签文件里写的是15104,磁盘上实际是15104 (1).jpg。
解决:把 2.1 的对齐检查前置到训练脚本入口。我习惯在main.py里先执行一次全量文件名比对,发现缺失立即退出,而不是等训练循环跑到一半才中断。代码很简单:set(df["image_name"]) - set(os.listdir(dir))就能找出缺失样本。
5.2 加载预训练权重报 shape 不匹配
现象:运行脚本直接报错,错误行指向state_dict最后一层classifier.weight尺寸是[1000, ...],而模型建出来是[104, ...]。
原因:预训练权重在 ImageNet 上训练,分类头是 1000 类;直接model.load_state_dict(pretrained_state_dict)会因为最后一层线性层的形状不一致而失败。
解决:不要手动给分类头赋值再加载。用timm.create_model(model_name, pretrained=True, num_classes=104),timm会先载入不含分类头的权重再替换新头;如果项目支持传入权重路径,也要用strict=False加载,让分类头自由初始化,骨干层严格加载。
5.3 冻结骨干后训练损失不下降或下降极慢
现象:freeze_layers=True、pretrained=True配置下,20 个 epoch 后验证准确率还卡在 0.1 附近。
原因:冻结骨干后整个模型只有最后的全连接层参与更新,而新分类头是随机初始化的,如果学习率设到 1e-4 这种小值,头部要学很久才能脱离随机状态。
解决:把优化器里的学习率调回 1e-3,或者给分类头单独设更高的学习率。更稳的做法是先不冻结骨干,用 1e-4 微调 5 个 epoch 再手动冻结,但资源的一键脚本一般不会做得这么复杂,最简单的是在_freeze_except_head里保留分类头的同时,把全局lr恢复到 1e-3。
5.4 余弦退火后期指标抖动,准确率像过山车
现象:前 15 个 epoch 验证准确率稳步上升,最后 5 个 epoch 突然大起大落,甚至出现验证集 loss 反弹。
原因:T_max设置小于实际 epoch 数,学习率降到eta_min后没有回升机制,模型在小学习率区域反复在局部最优附近震荡;或者验证集样本太少,最后几个 epoch 的 batch 波动被放大。
解决:确保T_max等于epochs,并在scheduler.step()前打印当前学习率,确认余弦曲线真的在下坡。如果验证集不足 2000 张,把test_size提高到 0.25,宁可让训练样本少一点,也不要让验证指标变成玄学。
6. 换自己的数据集:改三个配置项跑通新场景的实战技巧
这套工程的价值不只是用来识别 104 种花,更是一个能重复利用的迁移学习模板。我每次换到新任务,只动三个配置项:DATA_DIR指向新图片根目录,LABEL_FILE指向标签文件,NUM_CLASSES改成新任务的类别数。训练脚本里所有和 104 相关的地方,都会从NUM_CLASSES自动推断。
如果新数据集没有现成标签文件,最常见的做法是整理成按类别分目录的结构,然后用torchvision.datasets.ImageFolder自动生成标签:
dataset = torchvision.datasets.ImageFolder( root=DATA_DIR, transform=train_transform, )ImageFolder会为每个子目录自动分配class_id,dataset.classes的长度就是类别数,然后在main.py里把NUM_CLASSES替换成len(dataset.classes)即可。如果图片数量明显比 16k 少,比如只有 5k 张、30 类,我会把freeze_layers改回 True,只训练分类头,lr 保持 1e-3;如果超过 50k 张,再放开全部骨干微调,lr 降到 3e-4,weight_decay调回 0.1。
评估阶段也有一个值得养成的习惯:不要只盯着整体准确率。项目输出的class_metrics.json里会给出每个类别的 support,用 support 小于 30 的类别的 F1 做结论基本不可信,因为验证样本太少,一个 batch 的波动就能让指标上下 10 个点;真正稳定的是那些 support 较大的类别。把这个逻辑再往前推一步,把混淆矩阵对角线之外数值最大的几个“最容易混淆的花种对”挑出来看样例图,往往会发现它们形态非常接近,这就是后续加数据增强或换更大模型的直接依据。
从那以后我每次换数据集,都会强迫自己按这条流程走一遍:先数文件、再对齐标签、看 support 再下结论、最后才调模型参数。这样做至少不会再被单个 epoch 的准确率波动骗到。希望帮到你。
本文还有配套的精品资源,点击获取