
简介这是一套面向图像分类实战的EfficientNet迁移学习工程重点解决104种常见花卉的自动识别适合希望从零开始掌握迁移学习、完成自定义分类项目的开发者。包内共2000个文件以1993张花卉样本jpg为主另有3个Python训练与评估脚本、2个json结果文件、1个txt文件与1个readme文档其中脚本负责模型训练与评估json保存各类指标结果readme说明更换数据集的流程压缩包约514.13MB数据集与标签齐全可直接运行。网络部分覆盖EfficientNet b0至b7八种结构可自由选择是否加载官方预训练权重、是否冻结部分层还提供Adam、SGD、AdamW三种优化器配合多类别交叉熵损失和cos余弦退火学习率便于在精度与速度间灵活权衡。训练与验证阶段均输出loss、准确率曲线并生成混淆矩阵、召回率、精确率、F1值、特异度等系列评估图像与数值各类别结果写入json文件当前模型在训练集可达到约0.9准确率。按readme提示可替换数据集迁移到其他图像分类场景目前已有124人学习或浏览。1. 图像分类实战EfficientNet迁移学习拿下104种花的识别值得照着做很多做图像识别的朋友一上来就追大模型参数堆到几个亿结果在自己那点数据集上还没一个预训练好的轻量网络好用。这篇文章要讲的EfficientNet迁移学习项目就是用EfficientNet-B0做骨干网络借助ImageNet预训练权重在104种常见花卉数据集上做图像分类与图像识别。训练时间按小时算单卡就能跑最终准确率能到95%左右。这个方案轻量、可复现、适合快速落地特别适合刚入门深度学习、想用有限算力跑通完整图像分类流程或者要做植物识别类应用的工程师。2. 选型与数据准备EfficientNet为什么适合做迁移学习数据集该怎么摆2.1 EfficientNet轻量在哪里复合缩放与B0的取舍EfficientNet的核心是复合缩放把网络的宽度通道数、深度层数、输入分辨率三个维度按固定系数一起放大而不是像传统网络那样只加深或只加宽。基线模型B0参数只有约530万在ImageNet上top-1准确率接近77%这个性价比相当夸张。从B0到B7参数和计算量逐级膨胀精度提升却在递减所以做迁移学习时B0和B1是最常用的起点。对104种花卉这个任务来说数据规模通常在几千到几万张远小于ImageNet的百万级。这时候选B2、B3以上收益有限训练时间和显存却成倍增加。我一般先在B0上跑通全流程确认数据没大问题再考虑升级到B3。下表是B0到B3的差异方便做取舍模型参数量预训练输入尺寸相对推理耗时适用场景EfficientNet-B0530万224x2241x快速验证、小数据集、端侧部署EfficientNet-B1780万240x2401.3x精度略高训练时间可接受EfficientNet-B2910万260x2601.8x数据较多、追求准确率EfficientNet-B31220万300x3002.6x单卡训练有余量、想榨精度这里有个容易忽略的点迁移学习制胜关键是预训练权重而不是网络结构有多新。EfficientNet在ImageNet上学到的底层纹理、边缘、颜色特征对花卉识别完全适用轻量网络在数据量不大的情况下反而不容易过拟合这也是它适合做104类细粒度识别的原因。2.2 104种花卉数据集的结构与划分做图像分类项目第一步是把数据整理成标准目录结构。常见做法是每个类别一个文件夹文件夹名就是类别名。104个类别摆好后用torchvision的ImageFolder直接加载标签会按文件夹名称的字母序自动生成。flower_data/ ├── train/ │ ├── 玫瑰/ │ │ ├── 001.jpg │ │ ├── 002.jpg │ │ └── ... │ ├── 荷花/ │ ├── 郁金香/ │ └── ... 共104个类 ├── val/ │ └── 104个类文件夹 └── test/ └── 104个类文件夹下面这个脚本按类别分层划分数据集保证每个类别的图像按比例进入train/val/test避免某些类别在验证集中消失import os import random import shutil src flower_data_all # 原始数据每个类一个文件夹 dst flower_data ratios (0.7, 0.15, 0.15) # train/val/test 比例 random.seed(42) # 固定随机种子保证可复现 classes [d for d in os.listdir(src) if os.path.isdir(os.path.join(src, d))] classes.sort() # 按字母序排列使标签顺序稳定 for mode in [train, val, test]: os.makedirs(os.path.join(dst, mode), exist_okTrue) for cls in classes: imgs [f for f in os.listdir(os.path.join(src, cls)) if f.lower().endswith((.jpg, .jpeg, .png))] random.shuffle(imgs) n_train int(len(imgs) * ratios[0]) n_val int(len(imgs) * ratios[1]) splits { train: imgs[:n_train], val: imgs[n_train:n_train n_val], test: imgs[n_train n_val:], } for mode, files in splits.items(): out_dir os.path.join(dst, mode, cls) os.makedirs(out_dir, exist_okTrue) for f in files: shutil.copy(os.path.join(src, cls, f), os.path.join(out_dir, f)) print(f{cls}: total{len(imgs)}, ftrain{len(splits[train])}, fval{len(splits[val])}, ftest{len(splits[test])})这段脚本的逻辑很简单按类别遍历打乱每类的图片列表按比例切三段复制到新目录。重点在于按类别分层随机而不是把全量图片混在一起随机切。混合随机的问题在于某个类别数据少时可能全被分到trainval里根本没有这个类训练时模型从没见过该类样本的验证信号。random.seed(42)保证了每次运行结果一致104类数据划分结果可以复现排查问题时不至于因为随机差异找不到根因。2.3 数据加载与预处理分辨率要和预训练权重匹配数据加载直接用ImageFolder配合DataLoader归一化的均值和标准差必须用ImageNet统计量[0.485, 0.456, 0.406]和[0.229, 0.224, 0.225]。原因很简单预训练权重是在归一化后的ImageNet图像上训练的推理和微调时输入分布不一致结果就会打折扣。from torch.utils.data import DataLoader from torchvision import datasets, transforms # ImageNet 统计量与预训练权重匹配 IMAGENET_MEAN [0.485, 0.456, 0.406] IMAGENET_STD [0.229, 0.224, 0.225] # 训练集增强适度即可不要过度 train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.2, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.3), transforms.ToTensor(), transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD), ]) # 验证集/测试集只做缩放裁剪不做随机增强 val_transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD), ]) train_dataset datasets.ImageFolder(flower_data/train, train_transform) val_dataset datasets.ImageFolder(flower_data/val, val_transform) test_dataset datasets.ImageFolder(flower_data/test, val_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse, num_workers4, pin_memoryTrue)参数说明RandomResizedCrop(224)先从原图随机裁剪一块区域再缩放到224scale(0.2, 1.0)表示裁剪面积在原图的20%到100%之间这相当于引入尺度变化对花卉这种主体大小不固定的任务很有用。RandomRotation(15)角度范围15度花卉照片各种角度都有旋转增强能提升泛化能力。验证集Resize(256)再CenterCrop(224)是迁移学习标准做法比直接缩放保留更多中心细节也避免测试时因为随机裁剪造成指标不稳定。B0的预训练输入是224x224如果后续换B1就需要改成240B2改260B3改300这个尺寸和模型必须对应否则预训练权重的感受野和输入分布都对不上精度会明显下降。3. 迁移学习训练冻结、分层学习率与关键参数3.1 两种迁移模式何时只训分类头何时微调整个网络迁移学习在图像分类项目里通常有两种做法。第一种是特征提取模式冻结backbone的所有参数只训练新替换的分类头。这种做法适合每类样本很少比如几十张的情况backbone在ImageNet上学到的通用特征足够强训练快且不容易过拟合。第二种是微调模式解冻部分或全部骨干层用较小学习率继续训练让模型适应花卉数据的特有纹理和颜色分布。以104种花为例子每类几百张图时我一般采用两阶段策略第一阶段冻结backbone只训分类头3到5个epoch快速把分类器收敛到合理水平第二阶段解冻backbone后面几个stage用更小的学习率做全模型微调。这么做比一开始就全量微调稳定得多原因是随机初始化的分类头梯度尺度大如果一开始就反向传播到backbone预训练权重容易被冲坏。3.2 训练脚本逐行拆解冻结策略与分组优化器下面是一份可直接跑的PyTorch训练脚本核心部分import torch import torch.nn as nn from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR from torchvision import models device torch.device(cuda if torch.cuda.is_available() else cpu) # 加载 ImageNet 预训练权重 model models.efficientnet_b0(weightsmodels.EfficientNet_B0_Weights.IMAGENET1K_V1) in_features model.classifier[1].in_features # 替换分类头原分类头是 (Dropout, Linear(1280, 1000)) model.classifier[1] nn.Linear(in_features, 104) model model.to(device) # 冻结 backbone只训练分类头第一阶段 for param in model.features.parameters(): param.requires_grad False # 分组参数分类头 lr1e-3backbone lr1e-4第二阶段反向传播时生效 param_groups [ {params: model.classifier.parameters(), lr: 1e-3}, {params: model.features.parameters(), lr: 1e-4}, ] optimizer AdamW(param_groups, weight_decay1e-4) # 解冻 backbone 后几个 stage第二阶段使用 # for idx, stage in enumerate(model.features): # if idx 6: # for param in stage.parameters(): # param.requires_grad True scheduler CosineAnnealingLR(optimizer, T_max30) criterion nn.CrossEntropyLoss()逻辑说明model.features是EfficientNet的backbone特征提取层model.classifier[1]是最后的全连接层。替换分类头后输出维度从1000变成104这个新层是随机初始化的。注释中解冻代码表示当进入微调阶段时从features的第6个stage开始允许梯度更新前几个stage保留预训练参数不动。这里参数分组是关键技巧分类头的随机初始化参数学习率给高一些1e-3backbone参数给低一些1e-4因为预训练权重已经很接近最优解学习率太大会破坏原有特征。AdamW比Adam多了正确的权重衰减实现泛化效果更好weight_decay1e-4是迁移学习里比较稳妥的经验值。3.3 训练循环记录指标、保存最佳模型、早停best_acc 0.0 patience 0 max_patience 6 for epoch in range(30): # ---------- 训练 ---------- model.train() train_loss, train_correct, train_total 0.0, 0, 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() # 梯度裁剪防止预训练层梯度异常 nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() train_loss loss.item() * images.size(0) _, preds torch.max(outputs, 1) train_correct (preds labels).sum().item() train_total labels.size(0) # ---------- 验证 ---------- model.eval() val_correct, val_total 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, preds torch.max(outputs, 1) val_correct (preds labels).sum().item() val_total labels.size(0) train_acc train_correct / train_total val_acc val_correct / val_total print(fEpoch {epoch1:02d} ftrain_loss{train_loss / train_total:.4f} ftrain_acc{train_acc:.4f} fval_acc{val_acc:.4f}) # 保存验证集最优模型 if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_flower.pth) patience 0 else: patience 1 if patience max_patience: print(Early stop triggered) break scheduler.step()训练循环本身是常规流程但有三个值得留意的点。梯度裁剪max_norm5.0是防止解冻backbone后预训练层出现梯度爆炸的保险。EfficientNet的BN层在迁移学习中容易出现梯度异常裁剪后训练更稳。Early Stopping的patience6表示验证集准确率连续6个epoch不创新高就停止训练。104类花卉项目如果数据质量没问题通常在15到25个epoch内收敛早停阈值设太大反而浪费时间。torch.save(model.state_dict())只保存权重不保存模型结构加载时先构建模型再load_state_dict。这个习惯可以避免跨版本不兼容问题也方便把B0换B3时直接复用训练代码。3.4 关键训练参数速查表参数建议值说明优化器AdamW比SGD收敛快迁移学习首选分类头学习率1e-3新初始化的层需要较大学步长backbone学习率1e-4预训练权重微调幅度要小weight decay1e-4抑制过拟合过大反而欠拟合batch size32B0/224约占用4-6GB显存epoch30-50配合早停以验证集为准输入尺寸224B0与预训练权重严格匹配梯度裁剪5.0防止BN层和深层梯度爆炸batch size这里多说一句如果显存不够优先把batch降到16而不是强行开梯度累积。图像分类模型对batch size相对不敏感16的batch在104类任务上损失几乎可忽略调试阶段跑得更快。如果确实需要大batch打开PyTorch的自动混合精度AMP可以省一半显存。4. 避坑EfficientNet花卉识别中常见的5个高频坑4.1 验证集准确率高真实场景却翻车现象拿测试集或新拍摄的照片推理准确率和验证集差5到10个百分点甚至更多模型像换了个人。原因最常见的是数据划分不严谨。原始的104类花数据集里同一个植物不同角度的照片往往来自同一个拍摄批次如果随机划分时这些图片同时进了训练集和验证集验证集就等于开卷考试。另外可能把带水印、带背景框的图片当成了有效特征模型学到的是环境信号而不是花卉本身新场景一换背景准确率就崩。解决划分时必须按类别分层前面脚本已经做了。同时检查训练集和验证集里有没有同source的重复或近似图片比如从同一段视频抽的帧。我一般会在划分后把训练集和验证集的文件名做一个交集检查确认没有同一棵植物在不同文件夹里。再进一步把验证集换成网络上找的真实花卉照片才能真正评估泛化能力。4.2 训练早期loss不降反升验证集准确率在50%以下徘徊现象第一个epoch loss比随机猜测还高或loss在2到4之间震荡val_acc一直上不去。原因分类头是随机初始化的backbone被冻结时如果分类头学习率设置过大输出层权重在梯度方向上大幅震荡反过来如果backbone没有冻结且学习率设成了统一的1e-3预训练权重会被冲坏。EfficientNet的BN层对梯度尺度特别敏感这个问题会被放大。解决严格区分两个阶段。第一阶段冻结backbone只训分类头学习率从5e-4到1e-3都可以第二阶段解冻后再用1e-4以下的学习率。如果loss还是不正常先不要动架构把优化器换回SGD试试有时AdamW的默认参数在极小数据集上反而不稳定。4.3 训练集准确率99%验证集只有85%现象train_acc一路冲到99%val_acc卡在85%附近不动两者差距持续拉大。原因过拟合。104类花的数据集如果每类图片只有几十张而模型训练了30个epoch以上backbone特征会过度适配训练集的细节纹理和背景。原始ImageNet预训练权重本来能提供良好的泛化性但微调时间太长把它破坏了。解决三管齐下。第一数据增强加猛一点RandomResizedCrop的scale下限从0.2降到0.08加上RandomErasing随机遮挡一部分区域迫使模型不能依赖局部单一特征。第二weight decay从1e-4提到5e-4分类头加dropoutEfficientNet默认有0.2可以调到0.3。第三提前停微调backbone解冻后只训练5到8个epoch多数情况精度已经够用继续训练收益很小。4.4 CUDA out of memory显存一炸心态也炸现象训练到一半爆显存或一加载数据就OOM。原因大部分时候不是模型太大而是验证/测试时开了过大batch或num_workers过高导致内存碎片。还有一种隐蔽情况CenterCrop(224)之前如果Resize(256)数据加载器里的图像在进入模型前会被多份拷到显存batch64的val loader瞬间占用就能超过训练。解决先把batch降到16用AMP混合精度把模型和前向计算切换成fp16显存基本减半。再把num_workers调成2到4pin_memoryTrue保持勾选。如果还爆把输入尺寸临时改成192观察——虽然和预训练尺寸不符降低精度但排查问题阶段可以快速跑通流程。等确认问题和数据量无关再恢复224。4.5 110类里总是混那三五对相近花卉的识别难题现象混淆矩阵里错误高度集中在某几对类别——月季和玫瑰、鸢尾和菖蒲、百合和花木兰其他类别表现正常。原因细粒度分类问题。这些花外形相似颜色、花瓣层数、纹理差异很小。EfficientNet-B0只有530万参数特征表达能力有限尤其是在只做了默认图像增强的情况下模型很难捕捉到足够细的区分线索。解决两个方向。第一个方向是给易混类单独做分类器把易混类抽出来组成一个5到8类的子数据集用B1重新训练一个子模型推理时先用104类模型粗分类碰到易混类再走子模型逻辑简单但有效。第二个方向是升级到B3并把输入分辨率提到300让模型能看到更多细节。如果还不行就要考虑在损失函数上做文章加一个辅助损失去拉大易混类特征的距离。5. 验证与诊断看见模型错在哪才有迭代的依据5.1 混淆矩阵定位易混对训练完模型后很多人只看总准确率就收工了。但104类分类项目真正值钱的信息在混淆矩阵里。下面这段代码把验证集预测结果和真实标签对齐输出错误次数最多的TOP5类别对import torch import numpy as np from sklearn.metrics import confusion_matrix model.eval() all_preds, all_labels [], [] with torch.no_grad(): for images, labels in val_loader: images images.to(device) outputs model(images) _, preds torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) class_names val_dataset.classes # 104个类名 # 统计易混对真实类为i预测为j errors [] for i in range(len(class_names)): for j in range(len(class_names)): if i ! j and cm[i][j] 0: errors.append((cm[i][j], class_names[i], class_names[j])) errors.sort(reverseTrue) print(TOP5 易混对真实类 - 误判类 - 次数:) for cnt, true_cls, pred_cls in errors[:5]: print(f {true_cls} - {pred_cls}: {cnt}次)这段代码执行后的输出会直接告诉你模型的盲区。比如结果显示郁金香经常被误判成睡莲那就说明学习到的颜色特征多于形状特征——郁金香和睡莲在颜色上有较多重叠而花瓣形态差异明显。这个诊断比盲目堆数据要精准得多。5.2 把错分样本沉淀成hard set给模型返工做准备把每个验证集里预测错误的图片单独复制出来构建一个hard set目录这是迭代训练最重要的资产。下面的脚本把错误样本保存成“真实类名_预测类名_文件名.jpg”的格式一眼就能看出问题类型import os import shutil hard_dir hard_set os.makedirs(hard_dir, exist_okTrue) for img_path, true_cls, pred_cls in zip(val_images_paths, all_labels, all_preds): true_name class_names[true_cls] pred_name class_names[pred_cls] if true_name pred_name: continue filename os.path.basename(img_path) new_name f{true_name}_as_{pred_name}_{filename} shutil.copy(img_path, os.path.join(hard_dir, new_name)) print(fhard set 已生成共 {len(os.listdir(hard_dir))} 张错误样本)这里val_images_paths需要在加载数据时提前保留文件路径可以用val_dataset.samples取到每个元素是(路径, 标签)。保存成hard set后人工过一遍这些图片区分三类问题标注错误、图像质量差、模型确实分不清。前两类直接修正数据第三类才值得调整模型和增强策略。5.3 CAM可视化验证模型在看什么准确率不能回答“模型为什么对”CAM类激活图可以。EfficientNet-B0没有全局平均池化前的单一特征图很方便做CAMfrom torchvision.transforms import functional as F # 取最后一层卷积输出 final_conv model.features[7][0] def get_cam(image_path, true_label): img F.resize(F.to_tensor(Image.open(image_path).convert(RGB)), (224, 224)) img F.normalize(img, IMAGENET_MEAN, IMAGENET_STD) img img.unsqueeze(0).to(device) features [] def hook_fn(module, input, output): features.append(output.detach()) handle final_conv.register_forward_hook(hook_fn) output model(img) handle.remove() # 取目标类别的梯度 model.zero_grad() one_hot torch.zeros(1, 104).to(device) one_hot[0][true_label] 1 output.backward(gradientone_hot) weights final_conv.weight.grad.mean(dim(2, 3)) # 每个通道的权重 cam torch.matmul(weights, features[0].squeeze(0).flatten(1)) cam cam.reshape(7, 7).cpu().numpy() # 224/32 7 return camCAM图叠加到原图上就能看到模型激活区域集中在花瓣还是叶片。如果大量样本激活在背景上说明模型学到了背景特征数据增强里的RandomResizedCrop尺度范围要调大如果激活集中在花蕊说明分类依据是可靠的花部特征confidence就更有参考价值。5.4 验证指标不止准确率单类召回率与置信度分布104类分类任务每类的召回率差异往往比总体准确率更值得关注。某类花样本少且形态多变召回率可能只有70%拖低整体表现。建议单独输出每个类的准确率、召回率、F1找出最差的5个类优先补充这些类的训练数据。置信度分布也很直观把验证集每张图片的softmax最大概率统计成直方图。如果大量图片置信度在0.5以下但预测正确说明模型有潜力但训练不充分如果置信度很高但预测错误说明模型“过度自信地错”这是典型的特征学习偏差CAM可视化正好用得上。6. 进阶技巧从“跑通”到“能用”的四件小事当B0模型稳定跑出94%-95%验证准确率后如果还想再往上走按性价比从高到低做这四件事。第一推理时TTATest Time Augmentation。把验证/测试图片做水平翻转和多个尺度0.8x、1.0x、1.2x的预测取平均概率作为最终结果。实现上只需循环三到五次前向推理不需要改模型。以我的经验这个操作几乎免费地提升0.5到1个点的准确率尤其在易混类上效果明显。第二两级训练策略。先冻结backbone训分类头到收敛把模型保存为checkpoint再加载这个checkpoint解冻backbone最后两到三个stage分类头学习率降到2e-4、backbone学习率降到2e-5继续训。这个两阶段流程比一次性全量微调稳定得多是迁移学习中比较可靠的做法。第三导出ONNX或TorchScript进行推理加速。训练和推理共用一套代码没问题但部署到服务端或边缘设备时导成ONNX再用ONNX Runtime推理速度通常提升一到两倍。EfficientNet结构的算子对ONNX Runtime支持很好不会像某些Transformer模型一样碰到OOP算子兼容问题。导出的同时记得固化图像的预处理流程resize、normalize都放进模型里或固定在推理脚本里否则很容易出现“训练时正常、部署时掉点”的坑。第四单类数据不足时的兜底策略。104类里通常有一到两类样本特别少比如只有20到30张。与其硬训我习惯的做法是从模型的hard set里找出被误判到这些类的样本看看是“缺类内多样性”还是“类间相似”。样例少的花最好是收集更多该花不同角度、不同光照的图片这个办法永远是第一优先。如果临时补充不了就用类别加权损失给少样本类更高的惩罚权重能起一点作用但别指望太多。我做这个项目的最大教训是模型结构的选择只影响几个点的准确率浮动真正决定成败的是数据划分的严谨性和验证集设计的合理性。每次训练前先确认数据没有泄漏、没有错误标注训练后先看混淆矩阵而不是只看acc。EfficientNet这个方案最大的价值就在于它足够轻、足够快让你有充足的时间把迭代精力放到数据和诊断上而不是花大量成本跟模型架构缠斗。希望帮到你。本文还有配套的精品资源点击获取