简介面向图像分类与迁移学习入门者及研究人员这份基于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 b0b7 搭建、约 16k 张图做支撑的迁移学习工程pretrained 和 freeze_layers 决定是否加载官方权重、是否冻结骨干优化器在 Adam/SGD/AdamW 之间切换学习率用余弦退火训练集准确率可以到 0.9 左右。训练完自动输出 loss、混淆矩阵、recall、precision、F1、特异度到 JSON。适合正在做课程设计的学生、刚接触迁移学习的开发以及想要一份可复现 baseline 做对比的研究者。下面按数据对齐、模型选型、评估落盘、避坑排查的顺序把关键操作和参数选择一次讲清楚。2. 数据集与标签对齐16k 张图片如何变成可训练的 DataLoader2.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_size0.2, stratifylabels[class_id], random_state42, ) print(训练样本, len(train_df), 验证样本, len(val_df))stratifylabels[class_id]保证每个类别在训练集和验证集里都按 8:2 的比例留下样本random_state42固定随机种子保证每次复现得到同样划分。对于 104 类、16k 张的数据量每个类别在验证集里大约能留下 2530 张足够画混淆矩阵和计算各类别的 precision、recall、F1。如果以后换到只有几千张的自定义数据集test_size要降到 0.15否则验证集的波动会非常大指标也变成噪声。2.3 DataLoader 与 FlowerDataset输入尺寸、归一化和 batch 配置EfficientNet 不同变体对输入分辨率有不同要求b0 用 224×224b1 用 240b3 到 300b5 用 456b7 直接用到 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_SIZE32, IMG_SIZE32))再加RandomCrop(IMG_SIZE)是常见的训练增强组合相当于给图片留了一点随机裁剪空间推理阶段直接缩放到IMG_SIZE即可。Normalize的 mean 和 std 是 ImageNet 统计值因为加载的是官方 ImageNet 预训练权重输入分布必须对齐。如果用 b5 或 b6把IMG_SIZE改到 456 或 528还要同步把 batch size 减半否则显存直接爆。接着是自定义 Dataset 类把 DataFrame 里的样本转成 tensorclass 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, dtypetorch.long) train_dataset FlowerDataset(train_df, label_map, images, train_transform) train_loader DataLoader( train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue, )label_map是把字符串类名转成 0103 整数索引的字典在资源里通常和标签文件放在一起。num_workers4建议在 Linux 下使用Windows 下如果报错就调成 0pin_memoryTrue在 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_sampleslen(train_df), replacementTrue, ) train_loader DataLoader( train_dataset, batch_size32, samplersampler, # 用 sampler 时不再设 shuffleTrue num_workers4, pin_memoryTrue, )这里给每个样本分配的权重是1 / 类别频数小类别样本的抽中概率自动提高。这个资源没有刻意强调平衡但你把class_metrics.json打开看 support 时会发现少数类指标明显塌陷这时候再上加权采样也来得及。提示资源里自带一键运行脚本但数据路径和标签列名在不同操作系统上可能不一致。建议先跑一次 2.1 的对齐检查再进入训练别让缺图问题浪费一整个训练周期。3. EfficientNet 选型与训练参数b0b7、pretrained、freeze_layers、余弦退火的取舍3.1 b0b7 到底差在哪缩放系数决定输入分辨率和参数规模EfficientNet 的核心是 compound scaling同时放大深度、宽度和输入分辨率而不是像 ResNet 那样只加深。b0 是基本型参数量约 5.3Mb7 是最高版本参数量约 66M输入分辨率提高到 600×600。两者计算量差距接近一个数量级所以在 16k 张图像这样中等规模的数据上我通常建议从 b0 或 b1 先跑通流程再换 b3、b5 冲精度。常用选型参考如下模型变体输入分辨率参考参数量适用场景efficientnet_b0224×224约 5.3M快速验证、显存有限efficientnet_b1240×240约 7.8M平衡速度和精度efficientnet_b3300×300约 12M中等精度要求efficientnet_b5456×456约 30M高精度、显存允许efficientnet_b7600×600约 66M极端精度、多卡表格里的参数值是公开网络的常见统计实际以timm返回的模型配置为准。这个资源的训练脚本把model_name暴露成参数是希望你在不同机器配置下做折中。我的建议是显卡在 8G 以下先跑 b016G 以上再碰 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_nameefficientnet_b0, num_classes104, pretrainedTrue, freeze_layersTrue): super().__init__() self.backbone timm.create_model( model_name, pretrainedpretrained, num_classesnum_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_gradTrue其他骨干参数全部冻结这样反向传播时只更新分类头梯度计算量小很多。四种组合的适用场景pretrainedTrue, freeze_layersTrue数据量小、资源少最快拿到 baselinepretrainedTrue, freeze_layersFalse本资源最推荐16k 张图足够微调骨干精度通常比只训分类头高 24 个点pretrainedFalse, freeze_layersFalse彻底从头训练数据量至少再翻十倍不推荐pretrainedFalse, freeze_layersTrue随机初始化骨干再冻结等于只训一个线性层基本没有意义。这种“目标类别已知、直接用预训练先验改造分类器”的思路本质上就是直推式迁移学习的落地形式你不去学新域的结构只把别人在 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()), lrlr, weight_decayweight_decay, ) scheduler CosineAnnealingLR( optimizer, T_maxepochs, eta_min1e-6, )filter(lambda p: p.requires_grad, ...)传给优化器是因为冻结层不参与更新时如果全部传进去会在反向传播时报“变量无梯度”错误。T_maxepochs表示 30 个 epoch 内学习率从 1e-3 降到 1e-6恰好一个余弦周期eta_min是下界防止学习率降到完全为 0否则最后几个 epoch 模型完全学不动。如果改用 SGD一般配momentum0.9, weight_decay1e-4初始学习率降为 1e-2 再走余弦。从该资源的输出看训练集准确率能到 0.9 左右验证集如果也稳定在 0.8 以上这个配置基本不需要大改如果验证集明显低于训练集说明骨干微调过头可以把freeze_layersTrue或者把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_norm5.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], labeltrain_loss) plt.plot(history[val_loss], labelval_loss) plt.legend() plt.savefig(loss_curve.png) plt.close() plt.plot(history[train_acc], labeltrain_acc) plt.plot(history[val_acc], labelval_acc) plt.legend() plt.savefig(acc_curve.png) plt.close()如果train_loss持续下降但val_loss掉头上升基本确定是过拟合回退到freeze_layersTrue或把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_dictTrue) with open(class_metrics.json, w, encodingutf-8) as f: json.dump(report, f, indent2, ensure_asciiFalse) # 特异度TN / (TN FP)按类别逐列计算 tn cm.sum() - (cm.sum(axis0) cm.sum(axis1) - np.diag(cm)) fp cm.sum(axis0) - np.diag(cm) fn cm.sum(axis1) - np.diag(cm) specificity tn / (tn fp)classification_report的output_dictTrue可以直接序列化成 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 不存在现象训练刚跑一个 epochDataLoader 抛出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, pretrainedTrue, num_classes104)timm会先载入不含分类头的权重再替换新头如果项目支持传入权重路径也要用strictFalse加载让分类头自由初始化骨干层严格加载。5.3 冻结骨干后训练损失不下降或下降极慢现象freeze_layersTrue、pretrainedTrue配置下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( rootDATA_DIR, transformtrain_transform, )ImageFolder会为每个子目录自动分配class_iddataset.classes的长度就是类别数然后在main.py里把NUM_CLASSES替换成len(dataset.classes)即可。如果图片数量明显比 16k 少比如只有 5k 张、30 类我会把freeze_layers改回 True只训练分类头lr 保持 1e-3如果超过 50k 张再放开全部骨干微调lr 降到 3e-4weight_decay调回 0.1。评估阶段也有一个值得养成的习惯不要只盯着整体准确率。项目输出的class_metrics.json里会给出每个类别的 support用 support 小于 30 的类别的 F1 做结论基本不可信因为验证样本太少一个 batch 的波动就能让指标上下 10 个点真正稳定的是那些 support 较大的类别。把这个逻辑再往前推一步把混淆矩阵对角线之外数值最大的几个“最容易混淆的花种对”挑出来看样例图往往会发现它们形态非常接近这就是后续加数据增强或换更大模型的直接依据。从那以后我每次换数据集都会强迫自己按这条流程走一遍先数文件、再对齐标签、看 support 再下结论、最后才调模型参数。这样做至少不会再被单个 epoch 的准确率波动骗到。希望帮到你。本文还有配套的精品资源点击获取