简介面向PyTorch图像分类入门者这份完整的MobileNetV3植物幼苗分类实战包适用于课程设计、科研入门或算法复现场景。资源从植物幼苗数据集中抽取12类样本演示如何调用torchvision中的mobilenetv3模型完成迁移学习与分类全流程。压缩包共2461个文件、951.83MB其中2454张PNG图片构成训练与验证图像集5个Python脚本分别实现自定义数据集加载、Cutout与Mixup两种数据增强、模型训练与验证、余弦退火学习率调整以及两种预测写法1个PTH权重文件可供直接加载推理另附PDF说明文档梳理核心思路。已有1720人浏览学习。整套包让读者拿到数据、代码、权重和说明四位一体的完整方案按脚本顺序执行即可复现实验并观察数据增强与学习率策略对模型精度的影响适合需要图像分类项目模板或希望深入掌握MobileNetV3落地细节的开发者。1. 先跑通再调参MobileNetV3 在 12 类植物幼苗分类上的落地笔记用 MobileNetV3 做植物幼苗分类最反直觉的一点是模型不大但精度能逼近大模型。这份资源把 12 类幼苗数据、PyTorch 数据加载、Cutout/Mixup 增强、余弦退火和训练验证全部串成一条完整的脚本链对做农业识别或想练手轻量分类模型的人非常合适。你不需要从零搭框架拿到后把路径改成自己的数据就能复现。接下来我说清楚每一步怎么调、参数为什么这么设以及我复现时踩过的五个坑。2. 模型选型与预训练调用为什么是 MobileNetV3权重从哪来2.1 选型理由深度可分离卷积、SE 注意力与 h-swishMobileNetV3 是 Google 在 2019 年提出的轻量级分类骨干核心由三块拼成深度可分离卷积、SE 注意力模块和 h-swish 激活函数。深度可分离卷积把标准卷积拆成 depthwise 和 pointwise 两步计算量直接降一个数量级SE 模块在每层特征图上做通道维度的重标定让网络自动关注更有区分度的特征h-swish 是 swish 的近似版本用分段线性函数模拟精度损失小但推理速度快。对比 MobileNetV2V3 最大的改进是把注意力机制塞进了 bottleneck 结构里。对植物幼苗这种类间差异小的任务幼苗在不同生长阶段的叶片纹理、颜色变化都很大SE 模块的通道注意力能明显提升对细微差异的捕捉能力。我的习惯是类间差异大的场景优先考虑带注意力机制的轻量网络MobileNetV3-Large 在这个数据集上的表现明显优于同规模的 V2。选型还有个现实因素torchvision 直接提供了 mobilenet_v3_large 和 mobilenet_v3_small 的预训练权重权重是在 ImageNet 上训好的拿来迁移学习可以少走很多弯路。幼苗分类数据集通常只有几千张图从零训练轻量网络容易欠拟合加载预训练权重再微调是性价比最高的做法。2.2 torchvision 调用与分类头替换PyTorch 里调用 MobileNetV3 很简单一行代码就能拿到预训练模型。不同版本的 torchvision 对 weights 参数的写法有变化旧版用 pretrainedTrue新版推荐用 weights 枚举两者效果一样但新写法更规范也方便查看权重来源。import torch import torch.nn as nn import torchvision.models as models from torchvision.models import MobileNet_V3_Large_Weights # 新版推荐写法显式指定预训练权重 model models.mobilenet_v3_large(weightsMobileNet_V3_Large_Weights.IMAGENET1K_V1) # 查看分类头结构 print(model.classifier) # Sequential( # (0): Linear(in_features960, out_features1280) # (1): Hardswish() # (2): Dropout(p0.2) # (3): Linear(in_features1280, out_features1000) # ) # 替换最后一层适配 12 类植物幼苗 model.classifier[3] nn.Linear(in_features1280, out_features12)这里有个关键点MobileNetV3-Large 的 classifier 是四层结构前三层分别是升维线性层、Hardswish 激活和 Dropout最后才是真正的输出层。很多人直接替换整个 classifier把 Hardswish 和 Dropout 也弄丢了虽然也能训练但精度和正则化效果会打折扣。正确做法是只替换索引为 3 的全连接层。如果数据集很小比如每一类只有几十张图我一般会先冻结 backbone只训练分类头。等分类头收敛后再解冻全部层用较小的学习率微调。冻结的方法很简单设置 requires_gradFalse 即可。# 先冻结 backbone只训练分类头 for param in model.features.parameters(): param.requires_grad False # 解冻时反过来 for param in model.features.parameters(): param.requires_grad True冻结策略的边界要清楚当数据量少于每类 100 张时冻结 backbone 能防止过拟合当数据量达到几千张时建议直接全量微调。植物幼苗分类通常属于中等数据量场景我建议第一轮冻 backbone 跑 10 个 epoch第二轮解冻再跑 20 个 epoch这样既能利用预训练特征又能让模型适应幼苗数据特有的分布。3. 自定义数据集与数据增强从 ImageFolder 到 Cutout/Mixup3.1 Dataset 定义与归一化参数torchvision 自带的 ImageFolder 要求数据目录按类别分子文件夹这对植物幼苗数据集基本够用但有两个问题一是目录名必须是英文类别名二是无法在加载时做细粒度的样本过滤。资源里采用了自定义 Dataset 的方式写起来不复杂但灵活性高很多。import os from PIL import Image from torch.utils.data import Dataset class SeedlingDataset(Dataset): def __init__(self, root, transformNone): self.paths [] self.labels [] self.classes sorted(os.listdir(root)) self.class_to_idx {cls: idx for idx, cls in enumerate(self.classes)} for cls in self.classes: cls_dir os.path.join(root, cls) if not os.path.isdir(cls_dir): continue for img_name in os.listdir(cls_dir): if img_name.lower().endswith((.jpg, .png, .jpeg)): self.paths.append(os.path.join(cls_dir, img_name)) self.labels.append(self.class_to_idx[cls]) def __len__(self): return len(self.paths) def __getitem__(self, idx): img Image.open(self.paths[idx]).convert(RGB) if self.transform: img self.transform(img) return img, self.labels[idx]这个 Dataset 的核心逻辑在init里先扫描根目录下的所有子文件夹按字母序生成类别索引再把每张图片的路径和标签分别存到两个 list 里。getitem按索引读取图片统一转成 RGB 三通道避免灰色图片在后续归一化时报维度错误。归一化参数直接沿用 ImageNet 的统计值这是加载预训练权重的必要条件。如果你换用自己算的均值和方差模型前几层的激活分布会和预训练权重不匹配收敛速度明显变慢。from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.3), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])训练集和验证集的 transform 必须不同这是新手最容易忽略的坑。训练集用 RandomCrop 做随机裁剪等价于在空间维度上做数据扩充验证集只用 Resize 和 CenterCrop保证每次评估的输入是一致的。如果把增强也加到验证集上验证 loss 会不停抖动你根本分不清是模型没收敛还是增强在捣乱。3.2 Cutout 增强实现Cutout 的思路很粗暴随机选一个正方形区域把像素值置为遮挡状态强制模型不要过度依赖某个局部特征。对植物幼苗来说一张叶片上的病斑或泥土很容易被模型当成关键特征Cutout 能有效缓解这类过拟合。实现时直接在 Tensor 层面操作最方便。import random import torch class Cutout: def __init__(self, n_holes1, length32): self.n_holes n_holes self.length length def __call__(self, img): # 输入是已经 ToTensor 之后的 Tensor形状为 [C, H, W] h, w img.size(1), img.size(2) mask torch.ones((h, w), dtypetorch.float32) for _ in range(self.n_holes): y random.randint(0, h) x random.randint(0, w) y0 max(0, y - self.length // 2) y1 min(h, y self.length // 2) x0 max(0, x - self.length // 2) x1 min(w, x self.length // 2) mask[y0:y1, x0:x1] 0 img img * mask.unsqueeze(0) return imgCutout 实现里有两个细节要留意。第一length 默认 32对 224 的输入图来说遮挡面积大概占 2%这个比例对幼苗分类比较合适如果叶子纹理细密可以调到 24。第二mask 置 0 后乘到 img 上被遮挡区域的像素变成全 0也就是归一化前的黑色。有争议的做法是填充 0 还是填充均值Flatten 论文里用的是填充 0实际训练中我发现填充 0 对收敛影响不大省事。使用 Cutout 时要注意 Compose 的顺序必须放在 ToTensor 和 Normalize 之后因为它操作的是 Tensor。我见过有人把它放在 ToTensor 之前对 PIL Image 做乘法直接报类型错误。3.3 Mixup 增强实现Mixup 和 Cutout 的思路刚好相反它把两张图按比例混合标签也按同样的比例做软化。这样做相当于在样本之间做线性插值让模型学到更平滑的决策边界。对幼苗分类这种类间相似度高的任务Mixup 的收益很明显尤其能降低过拟合。import numpy as np def mixup_data(x, y, alpha0.2): 将 batch 内的样本两两混合 if alpha 0: lam np.random.beta(alpha, alpha) else: lam 1.0 batch_size x.size()[0] index torch.randperm(batch_size) mixed_x lam * x (1 - lam) * x[index] y_a, y_b y, y[index] return mixed_x, y_a, y_b, lammixup_data 返回四个值混合后的图像、原始标签、打乱后的标签、混合比例 lam。训练循环里计算 loss 时必须分别用 lam 和 1-lam 加权两个标签的交叉熵。alpha 参数控制混合程度alpha 越小混合越极端。我做过对比alpha0.2 在幼苗分类上效果最好alpha1.0 时样本被混合得太过特征几乎看不清。Mixup 的一个副作用是会让训练准确率看起来比实际低因为模型输出的预测概率和软标签之间天然存在偏差这是正常现象。判断模型好坏要看验证集准确率不是训练集。你如果发现训练 acc 一直在 90% 上下浮动、验证 acc 却稳步上升别慌Mixup 软标签导致的不是模型坏了。4. 训练循环与余弦退火让 loss 曲线平稳下降4.1 优化器与调度器选型优化器的选择直接影响微调效果。MobileNetV3 预训练权重已经在一个大规模数据集上收敛过微调时用大学习率的 SGD 很容易把已有特征破坏掉loss 直接飞了。我一般分两种策略数据量少用 AdamW数据量够用 SGD 加动量。这个项目我复现时用的是 SGD初始学习率 0.01配合余弦退火效果比 Adam 稳定。余弦退火的核心思想是让学习率按余弦曲线从初始值逐渐降到最小值。前期下降平缓模型可以大步搜索后期下降加快模型在小范围里精细调整。相比阶梯式下降余弦退火不需要手动设置每个阶段的学习率省心很多。import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR epochs 30 optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_maxepochs, eta_min1e-6)T_max 设为 epochs 的总数表示学习率从 0.01 降到 1e-6 共经历 30 个 epoch。eta_min 是最低学习率设成 1e-6 而不是 0是为了防止后期学习率过小导致模型完全停止更新。如果你想让学习率经历多个周期的升降可以把 T_max 设为周期长度比如 10这样每 10 个 epoch 循环一次。epochs 数量的选择要和数据集大小、增强策略匹配。12 类幼苗每类几百张图30 个 epoch 是起步50 个 epoch 能充分发挥余弦退火的威力。如果训练到一半验证 acc 不再变化不要急着加 epoch先确认是学习率太小还是数据增强太强。4.2 训练与验证循环训练循环本身不复杂但有两个关键点Mixup 存在时 loss 的计算方式不同验证时要用不带增强的 transform。from tqdm import tqdm def train_one_epoch(model, loader, optimizer, criterion, device, use_mixupTrue): model.train() total_loss 0.0 correct 0 total 0 for images, labels in tqdm(loader): images images.to(device) labels labels.to(device) if use_mixup: images, y_a, y_b, lam mixup_data(images, labels, alpha0.2) outputs model(images) loss lam * criterion(outputs, y_a) (1 - lam) * criterion(outputs, y_b) else: outputs model(images) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * images.size(0) _, preds outputs.max(1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total def validate(model, loader, criterion, device): model.eval() total_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in tqdm(loader): images images.to(device) labels labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) _, preds outputs.max(1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total注意一个细节使用 Mixup 时训练阶段统计的 acc 是拿混合样本的预测结果和原始标签直接比的这个数值会偏低不代表模型真实水平。我建议训练阶段只记录 lossacc 全部以验证集为准。如果你想看训练 acc就单独跑一遍不使用 Mixup 的评估。还有一个隐藏问题使用 Mixup 时样本是从同一个 batch 内部进行混合的如果 batch 太小比如小于 16随机打乱后混合的多样性不足增强效果会打折扣。我建议 Mixup 和较大的 batch 搭配使用至少 32。4.3 模型保存与加载训练完成后模型保存也有讲究。PyTorch 有两种主流保存方式只保存 state_dict或者保存整个模型。我强烈建议只保存 state_dict因为整个模型保存会把使用的类定义也序列化进去换机器或改代码后容易报找不到类的错误。# 保存最佳模型 best_acc 0.0 for epoch in range(epochs): train_loss, train_acc train_one_epoch(...) val_loss, val_acc validate(...) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), fbest_seedling_{epoch}.pth) scheduler.step() # 加载模型 model models.mobilenet_v3_large(weightsMobileNet_V3_Large_Weights.IMAGENET1K_V1) model.classifier[3] nn.Linear(1280, 12) model.load_state_dict(torch.load(best_seedling.pth))保存 best 模型时我把 epoch 编号也写进文件名这样能追溯是在哪个 epoch 达到的精度。加载时有个新手容易踩的坑必须先实例化 MobileNetV3 并替换分类头才能 load_state_dict顺序反了会报 size mismatch 错误。验证时只算 acc 还不够我建议顺便记录每个类别的 precision 和 recall。植物幼苗数据集中有些类别天生难分比如黑草和繁缕叶片形态相似如果只关注整体 acc你会漏掉模型在某些类别上的系统性失败。每次验证结束后打印一份分类报告训练完直接就能定位问题类别。5. 避坑复现过程中的五个常见问题5.1 验证集被增强污染现象训练时用了 Cutout 和 Mixup验证 loss 一直上下乱跳acc 忽高忽低像噪声而不是正常的曲线波动。原因最常见的原因是验证集的 transform 里也写了 Cutout 或 RandomCrop。验证集的输入每次都不一样模型评估结果自然不稳定。第二个偷偷污染验证集的地方是 Mixup 被写进了验证阶段的数据加载逻辑。解决验证集只保留 Resize、CenterCrop、ToTensor、Normalize 四步Transform 分成 train_transform 和 val_transform 两套训练循环和验证循环分别传入。5.2 Mixup 的 lam 在 loss 计算时丢失现象训练 loss 下降正常但准确率曲线比预期平滑很多最后收敛 acc 明显偏低。原因把 mixup_data 返回的 lam 丢掉了loss 直接拿 outputs 和 y_a 算交叉熵相当于混合了图片但没混合标签。模型被迫去拟合一个事实上并不存在的纯类别标签梯度方向被带偏。解决严格按照 lam * criterion(outputs, y_a) (1 - lam) * criterion(outputs, y_b) 计算 loss两个 term 缺一不可。一个排查技巧打印第一次迭代的 loss 看数值范围Mixup 后的初始 loss 通常在 2.5 到 3.0 之间如果明显偏低多半是 lam 没参与计算。5.3 预训练权重被大学习率直接冲毁现象第一个 epoch 的 loss 正常第二个 epoch 开始 loss 急剧上升后面完全无法收敛。登录 TensorBoard 能看到参数范数在快速膨胀。原因预训练模型在 ImageNet 上已经处于一个较优的局部最优解附近0.01 的 SGD 初始学习率对它来说太粗暴了一步就把权重推出了原来的盆地。解决三种常见做法选一种。第一初始学习率降到 0.001第二加 warmup前 5 个 epoch 学习率从 0 线性升到目标值第三冻结 backbone 只训练分类头。我习惯先冻结 backbone 训 10 轮再解冻用 0.001 微调全模型。5.4 DataLoader 的 num_workers 设置不当现象Windows 上训练脚本运行到一半直接报 BrokenPipeError或者程序卡死在数据加载阶段。Mac 和 Linux 上偶尔也会出现但频率低得多。原因num_workers 设置过大时Windows 系统对多进程数据加载的支持不够稳定子进程在迭代过程中和主进程断开连接。我见过有人设了 8结果程序连一个 epoch 都跑不完。解决Windows 下把 num_workers 设为 0改用主进程加载Linux 下建议 4 到 8 之间具体看 CPU 核心数。如果必须用多进程加载把数据加载逻辑包到 ifname main: 里避免脚本被多个进程重复执行。5.5 训练正常但验证 acc 在某个类别上永远偏低现象整体 acc 在 88% 左右但打印分类报告发现某个类别 recall 只有 40%比如把黑草频繁误判成繁缕。训练多轮后这个类别的表现也没有改善。原因类别本身特征相似加上数据不均衡模型倾向于把模糊样本分到样本量更大的类别。也可能是该类别的训练样本数量远少于其他类别网络没有学到足够判别性特征。解决先看类别分布如果类间数量差超过 2 倍用 WeightedRandomSampler 做类别平衡采样如果数据量本身够就要考虑增加针对难分类别的增强强度或者收集更多该类别的数据。还有一个技巧是把难分样本从验证集里抽出来单独看确认是否存在人工标注错误。6. 预测的两种写法单张推理与批量目录推理训练完模型后最后一步是推理。这个资源里提供了两种预测写法我分别拆开说。第一种是单张图片推理适合调试和部署到 API 接口里。核心是走一遍和验证集相同的预处理流程然后关梯度做前向传播def predict_one(img_path, model, class_names, device, transform): img Image.open(img_path).convert(RGB) tensor transform(img).unsqueeze(0).to(device) model.eval() with torch.no_grad(): output model(tensor) prob torch.softmax(output, dim1) score, idx prob.max(dim1) return class_names[idx.item()], score.item()softmax 这一步很多人会漏掉直接用 logits 的输出做 max也能得到类别索引但拿不到置信度。部署到实际场景时置信度是判断模型是否可靠的关键依据。我一般会把置信度阈值设成 0.6低于这个值的样本打入待人工确认队列。第二种是批量目录推理适合对数据集中所有未标注样本做预测。写法上只是在外层多套了一个循环但要记得用 torch.no_grad() 包住整个循环避免逐张释放计算图造成显存抖动import glob import os def predict_dir(dir_path, model, class_names, device, transform): files glob.glob(os.path.join(dir_path, *.jpg)) \ glob.glob(os.path.join(dir_path, *.png)) results {} model.eval() with torch.no_grad(): for f in files: img Image.open(f).convert(RGB) tensor transform(img).unsqueeze(0).to(device) output model(tensor) prob torch.softmax(output, dim1) score, idx prob.max(dim1) results[f] (class_names[idx.item()], score.item()) return results两种写法的差别在于单张推理频繁做 model.eval() 和设备切换适合一次性查询批量推理把 eval 模式固定在循环外适合处理成百上千张图片。如果你要完全离线跑批量预测建议把 model 和 transform 都放到循环外面初始化避免重复加载。我自己复现这套项目时把两种写法都封装进了同一个 inference.py对外只暴露两个函数。从那以后我每次做分类任务都强制走一遍完整流程先跑批量预测按置信度从低到高排序抽样检查低置信度样本最后再决定要不要调阈值。这套检查习惯帮我避开了不少数据标注错误带来的虚假精度希望帮到你。本文还有配套的精品资源点击获取