ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MobileNetV3植物幼苗分类实战:轻量模型从训练到部署

MobileNetV3植物幼苗分类实战:轻量模型从训练到部署 简介面向深度学习入门者和图像分类实践者这份基于PyTorch的MobileNetV3植物幼苗分类工程以12类幼苗图像为数据完整演示了模型训练与验证过程适合做课程设计或项目模板。压缩包共2461个文件包含2454张PNG幼苗图片、5个Python脚本、1个pth权重文件和1个PDF说明整体约952MB。其中PNG为按类别组织的图像数据Python脚本涵盖模型调用、数据加载、训练与预测pth权重可直接用于推理PDF给出了项目说明。目前已有1720人学习工程集中了从torchvision调用MobileNetV3、自定义数据集加载、Cutout与Mixup数据增强、余弦退火调整学习率以及两种预测写法等关键技巧。跟随该资源可以系统掌握轻量级分类任务的实现路径并轻松迁移到其他图像分类场景。1. 为什么植物幼苗分类偏要用 MobileNetV3 这个轻量骨架很多人在做植物幼苗分类时第一个动作就是把 ResNet50 拉起来跑。模型还没训完先被显存和推理延迟卡住放到边缘设备上更是连预览都拖不动。MobileNetV3 是 2019 年提出的轻量级骨架把深度可分离卷积、SE 注意力机制和 h-swish 激活揉进了一套 NAS 搜出来的结构里在 PyTorch 里几十行就能替换出自己的分类头参数体量比 ResNet 系列小一个数量级精度却相差不大。植物幼苗分类和普通猫狗分类不一样类别多、差异细叶子边缘、叶脉走向、子叶形状都是细纹理特征。靠堆参数学到的是混沌的整体纹理而 MobileNetV3 的自注意力结构更贴合这种任务。这篇笔记把这类项目从数据整理、增强参数、模型改造、训练调参到避坑记录完整拆开适合已经会写 PyTorch 训练循环、但还没把轻量级分类项目从零端到端跑通的人。2. MobileNetV3 的架构取舍bneck、SE 注意力与 h-swish 在幼苗识别里的分工2.1 深度可分离卷积用更少的感受野成本抓叶片细纹理MobileNetV3 的基本单元叫 bneck核心是深度可分离卷积。普通卷积是每个输出通道和所有输入通道做全连接的 3x3 卷积参数量是 C_out × C_in × 3 × 3深度可分离卷积先对每个输入通道单独做 3x3 卷积再用 1x1 卷积把通道信息融合参数量降到 C_in × 9 C_out × C_in大约是原来的八分之一到九分之一。幼苗分类里叶片轮廓、叶脉、锯齿边缘这些特征本来就是局部响应depthwise 逐通道提取后再用 1x1 融合信息综合的效率和精度都不差。需要注意 bneck 内部不是直接做 depthwise而是先做一层 1x1 升维把通道扩展成 4 到 6 倍再做 depthwise最后 1x1 投影回目标通道数。这个「扩展-压缩」结构让每个通道在更高维空间里做局部卷积然后压回是小网络在大数据集上还能保住精度的关键设计。h-swish 激活在这个结构里也很重要。公式是 x·ReLU6(x3)/6用 ReLU6 去近似 swish。swish 在移动端算起来贵h-swish 在量化后几乎不增加额外开销。对植物幼苗这类输入尺寸不大、边缘设备较多的任务h-swish 的数值稳定性比 ReLU 好特征图在正负区间都有响应对小叶子在暗背景下的弱响应更友好。2.2 SE 注意力机制模型靠什么把真叶和子叶分开SESqueeze-and-Excitation是 MobileNetV3 里最容易被忽略的一块。每个 SE 模块先对特征图做全局平均池化得到一个能描述该通道整体响应强度的标量再过两个全连接层生成 0 到 1 之间的权重最后把这个权重乘回原特征图的每个通道。通道响应强的被放大弱的被抑制模型在往前走的过程中不断重新校准「该看哪些通道」。在幼苗识别场景里不同物种在相同光照下的差异经常集中在某个通道组合里。例如黑草和北美独行菜的叶子轮廓相似但叶脉走向和叶片光泽不同这些差异在特定卷积核的响应上才体现得出来。没有 SE 时这些通道都是等权重往后传有 SE 后模型会学会优先放大「叶脉方向」相关的通道抑制「土壤背景纹理」相关的通道。动手验证 SE 是否真的起作用可以在 PyTorch 里统计模型里 SE 模块的数量import torchvision.models as models from torchvision.ops import SqueezeExcitation model models.mobilenet_v3_large(weightsmodels.MobileNet_V3_Large_Weights.DEFAULT) se_count sum(1 for m in model.modules() if isinstance(m, SqueezeExcitation)) print(fSE blocks in MobileNetV3-large: {se_count})这段代码用torchvision.ops.SqueezeExcitation去匹配模型内所有 SE 模块。输出数量因 torchvision 版本会有细微差异但 large 版本的 SE 结构是固定的分布在特征提取的中间层。如果训练时发现模型对某个相似类始终分不开先确认不是 SE 被量化或剪枝弄丢了。2.3 large 与 small 版本怎么选先跑大再压小版本结构特点参数量量级适合场景在幼苗分类里的用法mobilenet_v3_largebneck 层数多、通道更宽5M 左右普通 PC、服务器 GPU、精度优先默认首选拿它跑出 baselinemobilenet_v3_smallbneck 层数少、首层 16 通道2M 出头树莓派、嵌入式板卡、实时视频部署性能不够时把 small 拉出来对比我一般会先用 large 跑通全流程记录验证集精度和单张推理延迟。如果 large 在延迟上差一点优先把输入尺寸从 224 降到 160计算量直接减半精度通常只掉 0.5 到 1 个点还不行再切 small。很多人上来就选 small结果训练半天发现是网络表达能力不够又回头换 large浪费一轮时间。3. 植物幼苗数据集准备目录划分、增强参数表与类别均衡3.1 数据目录怎么摆ImageFolder 还是自定义 Dataset植物幼苗分类常用数据集是按类别分目录组织的比如 12 类幼苗各有独立文件夹。这种结构用torchvision.datasets.ImageFolder最省事不用自己写 Dataset。目录结构建议这样data/plant_seedlings/ train/ Black-grass/ Charlock/ Cleavers/ Corn/ ... val/ Black-grass/ Charlock/ Cleavers/ Corn/ ...ImageFolder会把每个子目录当成一个类别标签按目录名字母序生成。train 和 val 必须严格分开验证集绝不能混入训练图片否则混淆矩阵全是虚高。建目录时有个细节把清洗出来的损坏图片单独放corrupted/目录不要随手删方便回头查哪批图出了问题。ImageFolder默认支持的图片格式包括 jpg、jpeg、png、ppm、bmp、pgm、tif日常摄影和手机拍摄的图都覆盖了。只有当数据源是 CSV 路径列表或者需要动态生成样本时才值得写自定义 Dataset否则重复造轮子还会引入索引错位这种低级 bug。3.2 增强参数表照着这套不会把幼苗增强没了幼苗图像和 ImageNet 的常规图不一样幼苗在画面里占比小、颜色偏绿、背景是土壤类别差异集中在叶片细节上。增强的尺度要保守不能把叶片裁没了也不能把叶色改得不像植物。我常用的增强参数如下处理操作参数设置为什么这样设RandomResizedCropscale(0.6, 1.0)幼苗主体占比小scale 太开会裁掉半株苗RandomRotation15 度小角度模拟种植角度超过 30 度会把朝向语义破坏RandomHorizontalFlipp0.5叶片左右对称翻转安全ColorJitterbrightness0.2, contrast0.2, saturation0.2, hue0.05颜色是幼苗分类重要特征调整幅度必须小Normalizemean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)ImageNet 统计量通用分类任务默认值from torchvision import transforms train_tf transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0), ratio(0.8, 1.25)), transforms.RandomRotation(15), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2, hue0.05), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_tf transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])参数说明RandomResizedCrop的 scale 是最关键的参数表示裁剪面积占原图面积的比例0.6 是幼苗场景的底线再低容易出现「只看到一片叶子」的训练样本。RandomRotation只转 15 度幼苗的茎-叶相对位置具有方向语义旋转过大模型会学到错误的空间先验。val 集只做缩放和中心裁剪不做任何随机变换这能保证验证结果反映模型真实水平。特别提醒不要给幼苗做RandomVerticalFlip根在下、叶在上是植物的基本语义垂直翻转直接制造假样本。3.3 类别不均衡加权采样还是换损失函数幼苗分类数据集普遍存在类别不平衡。常见的 12 类幼苗数据集里多数的类别有近千张图少数的可能只有几十张。MobileNetV3 在小数据集上本来就容易过拟合类别再不均衡少数类基本学不到东西。一个简单的做法是WeightedRandomSampler按每个类别样本数的倒数设置采样权重让少数类在同一个 epoch 里被反复抽到from collections import Counter from torch.utils.data import WeightedRandomSampler, DataLoader counts Counter(train_set.targets) sample_weights [1.0 / counts[idx] for _, idx in train_set.samples] sampler WeightedRandomSampler( weightssample_weights, num_sampleslen(train_set.samples), replacementTrue ) train_loader DataLoader( train_set, batch_size32, samplersampler, num_workers4, drop_lastTrue )逻辑说明train_set.targets是每个样本的类别索引列表Counter统计各类别样本数。对每个样本权重设为 1 除以该类别样本数样本越多权重越低。replacementTrue表示允许同一张图被重复抽到这样少数类在一个 epoch 里能出现多次让优化器能稳定地更新那些类别对应的梯度。用 sampler 时 DataLoader 的shuffle参数必须为 False两者互斥。另一种更省事的办法是直接用带权重交叉熵损失给每个类别一个权重向量CrossEntropyLoss(weightclass_weights)。我个人的经验是如果少数类样本少于总样本的 5%采样器比损失函数权重更直接如果只是轻度不均衡直接给 loss 加权更平稳。不要同时用采样器和 loss 权重会把少数类过拟合砸穿。4. 用 PyTorch 搭建 MobileNetV3 训练闭环权重加载、冻结策略与参数设置4.1 模型构造与 torchvision 版本差异torchvision 从 0.13对应 PyTorch 1.12开始原生支持mobilenet_v3_large和mobilenet_v3_small新版本里加载预训练权重的写法是传weightsMobileNet_V3_Large_Weights.DEFAULT。老代码里的pretrainedTrue在新版本已经标记为 deprecated虽然还能用但不推荐。MobileNetV3-large 的classifier是一个Sequential前两层是 Linear(960, 1280) 和 Hardswish第三层是 Dropout第四层才是最终的 Linear(1280, 1000)。用 ImageNet 预训练权重时必须把最后一层替换成自己的类别数量import torch import torchvision.models as models from torchvision.models import MobileNet_V3_Large_Weights num_classes 12 weights MobileNet_V3_Large_Weights.DEFAULT model models.mobilenet_v3_large(weightsweights) # 替换分类头classifier 是 Sequential最后一层才是输出层 in_features model.classifier[3].in_features model.classifier[3] torch.nn.Linear(in_features, num_classes) # 冻结特征提取层只训练分类头 for param in model.features.parameters(): param.requires_grad False参数说明model.classifier[3]是索引到第四个元素因为 Python 从 0 计数前三个元素是Linear(960, 1280)、Hardswish、Dropout。替换时in_features1280是从原层的in_features属性动态取的避免硬编码。冻结 backbone 是迁移学习的标准操作。预训练权重已经学会了通用的纹理和边缘特征植物幼苗的叶片结构在这些特征空间里能找到对应。先冻结只训练分类头等分类头的 loss 下降得差不多了再解冻最后几个 bneck 做微调。解冻的写法是把最后一段特征层重新设为可训练# 解冻最后两层 bneck其余保持冻结 for param in model.features[-2:].parameters(): param.requires_grad True # 优化器只接收需要梯度的参数省显存且避免误更新冻结层 trainable_params [p for p in model.parameters() if p.requires_grad] optimizer torch.optim.AdamW(trainable_params, lr3e-5)PyTorch 的优化器不会更新requires_gradFalse的参数但把它们传进去会增加优化器内部状态。只传入可训练参数更干净后续想打印每层是否冻结也方便。这里还有个容易忽略的点修改分类头后如果直接把整个模型的state_dict存下来加载时需要保证网络结构一致后续如果改了num_classes再加载就会报 key 不匹配我习惯把class_to_idx一起存进 checkpoint。4.2 训练循环、AMP 混合精度与关键参数训练 MobileNetV3 的参数设置比 ResNet 要更保守。学习率大了小模型在小数据集上很容易震荡学习率小了收敛太慢还容易卡在局部最优点。我常用的训练参数如下参数冻结阶段解冻阶段依据batch_size32 / 6432按显存调整32 基本够用optimizerAdamWAdamW比 SGD 省心weight_decay 更稳lr3e-43e-5解冻阶段要降一个量级weight_decay1e-41e-4小网络防止过拟合的关键schedulerCosineAnnealingLRCosineAnnealingLR余弦退火让后期更稳epoch20 到 3030 到 50配合早停不固定训练循环里值得展开的是 AMP 混合精度。MobileNetV3 本身是轻量模型显存占用不大但混合精度能显著加快训练速度。PyTorch 1.6 之后用torch.cuda.amp新版也可以用torch.amp.autocast(cuda)效果一致device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion torch.nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lr3e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) scaler torch.cuda.amp.GradScaler() best_acc 0.0 patience 0 for epoch in range(60): model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() with torch.cuda.amp.autocast(): outputs model(images) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() running_loss loss.item() * images.size(0) scheduler.step() # 每个 epoch 结束后跑验证集保存最优模型 val_acc evaluate(model, val_loader, device) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pt) patience 0 else: patience 1 if patience 10: break代码里的GradScaler配合autocast是 AMP 的标准组合前向用低精度计算梯度用缩放器避免半精度下小梯度直接变零。T_max50是余弦退火的半个周期表示学习率在 50 个 epoch 内从初始值降到接近 0实际循环用早停中断完全合理。evaluate函数不在代码里展开职责就是跑一遍验证集返回准确率。注意保存模型用的是state_dict()而不是整个模型对象这样加载时换分类头也灵活。4.3 命令行入口把参数留出来方便批量试实验做到后面改参数应该在命令行里完成而不是每次改代码。我一般会给训练脚本加一套 argparse 参数import argparse parser argparse.ArgumentParser(descriptionTrain MobileNetV3 on plant seedlings) parser.add_argument(--data, default./data/plant_seedlings) parser.add_argument(--model, defaultlarge, choices[large, small]) parser.add_argument(--batch-size, typeint, default32) parser.add_argument(--epochs, typeint, default60) parser.add_argument(--lr, typefloat, default3e-4) parser.add_argument(--freeze-backbone, actionstore_true) parser.add_argument(--seed, typeint, default42) args parser.parse_args()参数说明--freeze-backbone是个布尔开关传了就全冻结不传就微调整个网络。这个开关在项目早期对比预训练迁移效果时非常有用。--model参数控制加载 large 还是 small配合第 2.3 节的「先跑大再压小」策略切换模型不需要改代码。固定随机种子是复现实验的前提。只设一个torch.manual_seed(42)不够还要同步设置random.seed(42)和numpy.random.seed(42)并在 DataLoader 里设置generatortorch.Generator().manual_seed(42)否则数据采样的随机性仍然无法复现。命令行跑起来的训练命令长这样python train.py --data data/plant_seedlings --freeze-backbone --epochs 30 --batch-size 64训练脚本的日志要记录两个东西每个 epoch 的损失和验证准确率。不要只打印 lossloss 下降不代表验证集准确率一定上升尤其在类别不均衡的数据上。5. MobileNetV3 幼苗分类训练避坑记录四个高频翻车点5.1 加载预训练权重报错size mismatch or missing key现象模型结构刚改完一跑就报错要么提示分类头的 shape 不匹配要么提示missing key(s)还有的报unexpected key。原因有两种可能。第一分类头没替换干净模型输出的还是 1000 维但标签是 12 类CrossEntropyLoss 直接爆维度错误。第二保存的 checkpoint 是用不同结构保存的比如之前训练过 10 类后来改成 12 类加载整个state_dict时旧的分类头权重和新结构对不上。解决替换一定要落在model.classifier[3]而不是model.classifier。小坑是在冻结阶段忘记把模型整个to(device)导致 CPU 和 GPU 参数混在一个设备集合里。如果是自己保存的 checkpoint保存时建议带上结构信息torch.save({model_state: model.state_dict(), class_to_idx: train_set.class_to_idx, num_classes: num_classes}, best.pt)。这样加载前可以先校验类别数而不是等报错再回头查。5.2 验证集准确率一直卡在 30% 到 40% 不动现象训练 loss 一直在降验证集准确率却纹丝不动甚至比随机猜好不了多少。训练集准确率倒是能到 90% 以上。原因类别不均衡导致的假性收敛。模型学到的是「多数类识别 少数类全部预测成多数类」在加权采样器下训练时的分布被改过但验证集还是原始分布于是验证准确率上不去。另一个常见原因是验证集增强和训练集增强不一致但这里几乎可以排除。解决先打印分类报告看哪些类别 recall 是 0。如果是少数类全军覆没给损失函数加权比换采样器更好控制import torch class_weights torch.tensor( [1.0 / counts[i] for i in range(num_classes)], dtypetorch.float32 ).to(device) criterion torch.nn.CrossEntropyLoss(weightclass_weights)逻辑说明counts[i]是第 i 类的样本数样本越少权重越高。这样 loss 对少数类的惩罚更大模型必须认真区分少数类才能把整体 loss 降下来。加了 class weight 后学习率最好降到原来的二分之一否则少数类梯度过大容易震荡。5.3 CUDA out of memory 但显存看着没满现象nvidia-smi显示显存还有 4 到 5G 空闲一训练就报CUDA out of memory。原因PyTorch 的显存缓存分配器会预留显存进程启动时占用的空间比实际需要的大nvidia-smi看到的剩余量不是 PyTorch 实际可用的量。另一个大头是梯度图和 AMP 缩放器的中间变量尤其输入尺寸是 224 时backbone 特征图占用的都是显存。解决最直接的是 batch_size 减半一般从 32 减到 16 就能解决。第二个办法是开 AMP上面已经写过。第三个是减少num_workersDataLoader 的每个 worker 也会预取图片4 个 worker 在内存紧张时会放大显存碎片。如果 batch 减到 8 还不够用梯度累积模拟大 batch不增加显存accum_steps 4 for i, (images, labels) in enumerate(train_loader): images, labels images.to(device), labels.to(device) with torch.cuda.amp.autocast(): outputs model(images) loss criterion(outputs, labels) / accum_steps scaler.scale(loss).backward() if (i 1) % accum_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()这里的loss除以accum_steps是为了让累积后的梯度量级和普通大 batch 一致。梯度累积的等效 batch_size 是显存 batch 乘以累积步数32 显存 × 4 步 128 等效 batch配合 CosineAnnealingLR 效果很稳定。5.4 增强参数照抄 ImageNet 默认值验证集反而崩现象训练集 loss 降得很快但验证集准确率波动大甚至不如不加增强。有人把RandomResizedCrop的 scale 设成 ImageNet 默认的(0.08, 1.0)结果准确率掉了十几个点。原因ImageNet 的样本主体居中且占画面比例大scale0.08 裁出来还是一只完整的动物。幼苗数据集的幼苗纤细、占比小scale0.08 会把叶片裁掉大半模型看到的是碎片化的绿色斑块学到的全是背景纹理。ColorJitter 的 brightness 调大后本来土壤和叶子的灰度差被抹平类别区分度反而下降。解决把 scale 改回(0.6, 1.0)rotation 限制在 15 度内hue 不超过 0.05。判断增强参数是否过头的标准是验证集掉点就先减增强强度而不是继续加。增强的作用是提供有限多样性幼苗分类的数据量本来就小增强过度等于制造噪声。这类问题被很多人叫玄学其实不是就是裁切比例和颜色扰动踩到了植物的生物学特征上。6. 训练之后别急着上线混淆矩阵、Grad-CAM 验证与 ONNX 导出训练完成后准确率数字只是第一步。植物幼苗分类的价值在区分相似物种只报一个 90% 的准确率你不知道模型犯的具体错误类型也无法判断能不能部署。我拿到最优 checkpoint 后会做三件事。第一件是打印混淆矩阵。禾本科的几种幼苗特征高度相似模型很容易把 Black-grass 和 Loose Silky-bent 混在一起。用 sklearn 跑一个分类报告加混淆矩阵from sklearn.metrics import confusion_matrix, classification_report y_true, y_pred [], [] model.eval() with torch.no_grad(): for images, labels in val_loader: images images.to(device) preds model(images).argmax(dim1).cpu().tolist() y_true.extend(labels.tolist()) y_pred.extend(preds) print(classification_report(y_true, y_pred, target_namesclass_names)) cm confusion_matrix(y_true, y_pred)混淆矩阵的对角线外高值就是模型的「弱点类别」。把这些误判案例挑出来按类别命名存到一个目录里回看是哪几类纠缠不清——通常不是模型不够大而是这两类的训练图本身就少或者拍摄角度导致视觉差异小。第二件事是确认模型在看什么。Grad-CAM 类方法在 MobileNetV3 上很容易做hook 特征提取的最后一层输出把激活图叠加到原图上。理想情况下模型注意力应该集中在叶片和叶脉上而不是土壤大色块。如果热点在背景上大概率是增强参数里的 ColorJitter 把叶片和背景的颜色拉得太接近了。第三件事是部署验证。MobileNetV3 的常见部署路径是导出 ONNX。导出前把模型切到 eval 模式关闭 dropout导出时指定动态 batch 维度model.eval() dummy torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, seedlings.onnx, input_names[input], output_names[output], opset_version13, dynamic_axes{input: {0: batch}, output: {0: batch}} )opset 13 是目前兼容性较好的选择。导出后拿同一张图分别跑 PyTorch 和 onnxruntime对比 top-1 是否一致。ONNX 转换不是无损的MobileNetV3 的 h-swish 会被拆成多个基础算子数值误差会让输出 softmax 有细微波动但 top-1 类别不应该变。如果 top-1 变了优先检查模型里是否有训练模式下才启用的层没有切 eval。我自己的习惯是每次实验结束把 bad cases 按「类别_预测类别_置信度.jpg」重命名存好下次调参直接看这些图。ONNX 导出后的第一件事是比对输入输出维度第二件事是跑同一张图的推理结果。希望这些踩过的坑能帮到你。本文还有配套的精品资源点击获取
返回列表