简介:面向深度学习入门者与图像分类实践者的轻量级卷积神经网络实战项目,以ShuffleNet为基础,完成8种不同阶段菠萝成熟度的自动分类任务。模型参数量约一百万,采用余弦学习率衰减训练五十轮,测试集最佳精度达百分之八十七,可支撑课程设计、实验教学或业务快速验证。压缩包共两千个文件,整体约201MB,以图片数据为主,其中训练集四千八百余张、测试集八百余张,并配有四个脚本、说明文档、配置文件及训练好的权重文件,解压后即可直接运行。项目将训练集与测试集按类别分目录存放,预测脚本可自动对待预测目录下全部图片进行推理,并在左上角标注概率最大的前三个类别,同时结果目录保存有权重、训练日志和损失与精度曲线,便于复现与分析。说明文件中也给出了训练自定义数据的方法,代码会自动适配类别数量,方便迁移到其他分类场景。已有124人学习,适合快速上手轻量级卷积神经网络图像分类项目。
1. 为什么拿 ShuffleNet 做菠萝成熟度分类:先想清楚再动手
标题里提到了经典轻量级 CNN、ShuffleNet、图像分类,本质是在解决一个非常具体的农业视觉问题:8 种不同成熟阶段的菠萝,能不能靠一张照片自动分出来。很多人上来就堆 ResNet、EfficientNet,结果算力吃紧、推理慢,最后在手机或边缘设备上跑不动。ShuffleNet 这类轻量级网络,设计的初衷就是让卷积网络在有限的算力下还保持可用的精度,这正是菠萝成熟度分类这类农业落地场景需要的特性。
做这个项目最实际的价值在于:菠萝成熟度直接影响采收、仓储和上架决策,靠人眼判断会有人为误差,而且大规模产线上不可能每个果都翻过来看。用图像分类网络把成熟度切成 8 个阶段,等于把老师傅的经验变成一套可复用的视觉规则。但这里有个常见的认知偏差——很多人以为这类任务只靠换网络结构就能解决,实际上数据质量和标注一致性往往比模型选型更关键。这篇笔记会把从数据组织、网络搭建、训练调参到部署避坑的完整链路解开,全程按 ShuffleNetV2 为主线,不走弯路。
2. 先把 8 类成熟度定义清楚:数据标注与目录组织
2.1 8 个阶段的划分逻辑:别让标注决定模型上限
菠萝从开花到完熟是一个连续过程,但图像分类需要离散标签。8 种不同阶段不是一个标准答案,不同产区、不同品种的划分方式不同,常见做法是结合表皮颜色、果眼形态和硬度来定义:
| 阶段编号 | 阶段名 | 外观特征描述 | 采收建议 |
|---|---|---|---|
| 0 | 绿熟期 | 全果深绿,果眼饱满,无转色 | 不宜采收,运输期长 |
| 1 | 乳熟期 | 果基开始泛黄白,果体略变浅 | 可远途运输 |
| 2 | 转色期 | 果体中部 10%~30% 转黄 | 短途运输 |
| 3 | 半熟期 | 黄色面积 30%~50%,果眼平展 | 就近销售 |
| 4 | 硬熟期 | 黄色面积 50%~70%,果身仍有弹性 | 最佳风味,采收窗口 |
| 5 | 软熟期 | 黄色面积 70%~90%,果身偏软 | 当天下架,尽快食用 |
| 6 | 过熟期 | 表皮深黄或有褐斑,果肉有酒味 | 已过最佳窗口 |
| 7 | 腐烂期 | 霉斑、皱缩、汁液外渗 | 不可销售,废弃 |
这个划分的关键在于:每个阶段之间要有肉眼可辨的差异。如果两个相邻阶段连人都经常分不清,强行让模型学也只是记住了噪声。我一般会把标注规范写成一份图文对照文档,让参与标注的人先过一轮考试,标注一致率达到 90% 以上再开始正式任务。
2.2 数据目录结构:Train / Val / Test 三级分离
不要把所有图片放在一个文件夹里让训练脚本自己乱分,那样会让验证集和训练集分布重叠,最后评估结果虚高。标准做法是事先按阶段分好目录:
pineapple_maturity/ ├── train/ │ ├── 0_green/ │ ├── 1_milky/ │ ├── 2_turning/ │ ├── 3_half_ripe/ │ ├── 4_hard_ripe/ │ ├── 5_soft_ripe/ │ ├── 6_overripe/ │ └── 7_rotten/ ├── val/ │ └── ... 同上,每个阶段单独目录 └── test/ └── ... 同上,保持完全独立数据划分脚本其实很简单,但有一个容易被忽略的点:同一个菠萝的不同角度照片不能同时出现在训练集和验证集里。菠萝在不同成熟阶段外观差异大,但同一颗果子的照片相似度极高,模型一旦记住了某颗菠萝的特征,验证集就会作弊。所以最好以果实个体为单位划分,而不是以单张照片为单位。实际中如果没记录果实编号,就手动做一次相似图片去重再划分。
2.3 图像增强与预处理:在保留真实感的前提下扩数据
菠萝成熟度分类的数据集规模通常不会很大,少则几百张、多则几千张。增强策略不能只用随机翻转和裁剪,还要考虑实际采集环境。常见做法是模拟不同光照条件下的色温偏移,因为菠萝转色阶段的黄色与绿色比例会被光照严重影响。推荐增强组合:
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(15), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])参数说明:RandomResizedCrop 的 scale 设到 0.7~1.0,让模型看到果实的局部和整体;ColorJitter 的 hue 只给 0.05,避免色相偏移过大把青菠萝增强成黄菠萝,反而制造假数据。验证集和测试集不要做随机增强,只做 Resize 和 Normalize,保证评估指标稳定可对比。
3. 搭建 ShuffleNetV2:理解分组卷积与通道混洗的代价
3.1 轻量级不是玄学:ShuffleNetV2 的四个设计原则
ShuffleNetV2 最核心的价值在于,它不只讲结构有多轻,而是从硬件实际执行的角度总结了一套设计原则。四个原则分别是:输入输出同通道数时内存访问成本最低、分组数过大时 MAC 反而上升、碎片化操作降低并行度、逐元素操作不可忽略。这些原则直接导向了它 block 内部的基本结构:一半通道直接短路,另一半通道经过深度卷积和逐点卷积,最后用通道拼接代替相加。
这里有个关键点:通道混洗是为了解决分组逐点卷积导致的信息隔离。但 ShuffleNetV2 只在每个 block 的起始位置做混洗,而不是在每个卷积之后都做。这样既让组间信息流通,又控制了开销。如果你只是调用 torchvision 里的shufflenet_v2_x1_0,这些细节都被封装了,但理解它有助于后面调宽度倍率时判断模型的表达能力是否削弱。
3.2 完整模型代码:在 PyTorch 里构建 ShuffleNetV2
直接使用 torchvision 预训练模型是最省事的路径,但实战项目里经常需要改最后的分类头。下面是基于 PyTorch 加载 ShuffleNetV2 并替换输出层的完整写法:
import torch import torch.nn as nn from torchvision.models import shufflenet_v2_x1_0, ShuffleNetV2_X1_0_Weights def build_shufflenet(num_classes=8, pretrained=True, freeze_backbone=False): if pretrained: weights = ShuffleNetV2_X1_0_Weights.IMAGENET1K_V1 model = shufflenet_v2_x1_0(weights=weights) else: model = shufflenet_v2_x1_0(weights=None) in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(p=0.2), nn.Linear(in_features, 256), nn.ReLU(inplace=True), nn.Linear(256, num_classes) ) if freeze_backbone: for param in model.features.parameters(): param.requires_grad = False return model if __name__ == "__main__": model = build_shufflenet(num_classes=8, pretrained=True) dummy = torch.randn(2, 3, 224, 224) out = model(dummy) print("输出张量形状:", out.shape)这段代码里有两个值得注意的地方。第一,原来的model.fc是一层线性层,替换成两层线性结构是为了给成熟度分类增加一点非线性拟合能力,但不要加太多层,否则轻量级模型会过拟合小规模数据。第二,freeze_backbone参数控制是否冻结主干,冷启动时建议先冻结训练 10 个 epoch,再解冻微调,可以防止随机初始化的分类头把主干梯度带偏。
3.3 宽度倍率与模型复杂度:x0.5 / x1.0 / x1.5 怎么选
ShuffleNetV2 官方提供了不同宽度倍率版本,x1.0 表示通道数按基准设置,x0.5 则通道数减半,x1.5 和 x2.0 依次加宽。对菠萝成熟度分类来说,任务本身不是极细粒度识别,8 个阶段的差异主要在颜色和纹理上,x1.0 已经足够。
如果部署目标是树莓派或手机端,建议用 x0.5 起步,测试精度掉点不超过 2% 就采用。x1.5 和 x2.0 在 ImageNet 上有提升,但在这种小规模数据集上往往吃力不讨好,参数翻倍,精度却可能因为过拟合而下降。可以用下面的脚本快速比较两个版本的参数量:
from torchvision.models import shufflenet_v2_x0_5, shufflenet_v2_x1_0 model_small = shufflenet_v2_x0_5(weights=None) model_base = shufflenet_v2_x1_0(weights=None) def count_parameters(model): return sum(p.numel() for p in model.parameters() if p.requires_grad) print("x0.5 参数量:", count_parameters(model_small)) print("x1.0 参数量:", count_parameters(model_base))参数规模差约 4 倍,x0.5 大约 1.4M,x1.0 大约 5.4M 的量级。不要只看参数量,还要看实际推理时间。在小数据集上,x0.5 收敛更快,x1.0 上限更高。我一般先在 x0.5 上做数据验证,确认标签可靠后,再在最终的训练里切到 x1.0。
4. 训练与评估:ShuffleNet 在菠萝数据上的完整跑通流程
4.1 数据加载器与样本不均衡处理
8 个成熟阶段里,绿熟期和腐烂期的样本往往好采集,转色期和过熟期因为时间窗口短,图片数量可能只有其他类别的三分之一。样本不均衡在这个项目里必须面对,不能假装不存在。
from torch.utils.data import DataLoader, WeightedRandomSampler from torchvision.datasets import ImageFolder import numpy as np dataset = ImageFolder("pineapple_maturity/train", transform=train_transform) class_counts = [] for class_idx in range(len(dataset.classes)): count = sum(1 for _, label in dataset.samples if label == class_idx) class_counts.append(count) total = sum(class_counts) sample_weights = [] for _, label in dataset.samples: sample_weights.append(1.0 / class_counts[label]) sampler = WeightedRandomSampler(sample_weights, num_samples=total, replacement=True) train_loader = DataLoader(dataset, batch_size=32, sampler=sampler, num_workers=4, pin_memory=True)这里用了 WeightedRandomSampler,让少数类样本有更高的概率被抽到,比直接过采样复制图片更稳,因为过采样只是重复读文件,而随机采样器可以让每个 epoch 的批次组合都不同。另外有一点要注意:ImageFolder 要求目录名以整数开头并按升序排列,0_green 这类命名方式就是为它准备的,它会按首字符排序,所以 0~7 的前缀不能省略,否则顺序会乱。
4.2 训练超参数组合与完整脚本
ShuffleNetV2 在微调场景下并不需要太大的学习率。常见做法是 SGD 优化器搭配余弦退火,初始学习率设在 0.01 到 0.03 之间,权重衰减保持在合理水平。下面是完整训练脚本的核心部分:
import torch import torch.nn as nn from torch.optim import SGD from torch.optim.lr_scheduler import CosineAnnealingLR from tqdm import tqdm from sklearn.metrics import confusion_matrix, classification_report def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in tqdm(loader): images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() return running_loss / total, 100.0 * correct / total model = build_shufflenet(num_classes=8, pretrained=True) model = model.to(device) # 放低分类头的学习率,让主干特征不被破坏 fc_params = model.fc.parameters() backbone_params = model.features.parameters() optimizer = SGD([ {"params": backbone_params, "lr": 0.01}, {"params": fc_params, "lr": 0.05} ], momentum=0.9, weight_decay=1e-4) criterion = nn.CrossEntropyLoss(label_smoothing=0.1) scheduler = CosineAnnealingLR(optimizer, T_max=30)训练里最值得注意的参数是label_smoothing=0.1。菠萝成熟度相邻阶段之间的视觉边界是模糊的,比如转色期和半熟期的中间状态,让模型硬赌某一个类别会导致过拟合,标签平滑告诉模型不要对训练标签过度自信,这对模糊边界的分类任务很有效。余弦退火的学习率曲线配合 SGD,比固定学习率更快收敛到平坦的极小值点。
4.3 评估指标:不只看准确率,看混淆矩阵
8 分类问题的准确率非常容易被整体样本分布误导。如果绿熟期样本占 40%,模型把所有图都判成绿熟期也能有近四成的准确率。必须输出混淆矩阵,观察哪些阶段之间最常被混淆。
from sklearn.metrics import accuracy_score def evaluate(model, loader, device): model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for images, labels in tqdm(loader): images = images.to(device) outputs = model(images) _, preds = outputs.max(1) all_preds.extend(preds.cpu().tolist()) all_labels.extend(labels.tolist()) acc = accuracy_score(all_labels, all_preds) cm = confusion_matrix(all_labels, all_preds) return acc, cm acc, cm = evaluate(model, val_loader, device) print("验证集准确率:", acc) print("混淆矩阵:") print(cm)实际项目中,我会优先看半熟期和硬熟期的混淆数量。这两个阶段是最佳采收窗口,错判的损失最大。如果混淆矩阵显示相邻阶段互相误判,这通常不是模型的问题,而是标注定义不清晰,需要回头优化数据划分,而不是继续调模型结构。
5. ShuffleNet 实战避坑与常见问题排查
5.1 现象:训练损失下降但验证集准确率一直卡在 60% 左右
原因:最初的分类头替换出了问题。有人直接修改model.fc.out_features = 8,这在本地的shufflenet_v2_x1_0上可行,但在某些 PyTorch 版本里model.fc是 Linear 层,改out_features会使权重形状不匹配,预训练权重被丢弃,分类头等于是随机初始化且维度错乱。更隐蔽的原因是只改了分类头但没有冻结 BN 层,预训练主干逐渐被大学习率破坏。
解决:使用预训练权重时,主干学习率不要超过 0.01,BN 层建议全部保持训练状态但学习率跟随主干。你需要确认model.features里的 BatchNorm 是否被设置为track_running_stats=False,如果是,说明 BN 统计量没有更新,推理时归一化用的是随机初始化值,这会导致验证集表现断崖式下降。检查方法很简单:加载模型后打印一个 BN 层的running_mean,如果全是 0,就是被误关闭了。
5.2 现象:输入分辨率调到 320 后准确率不升反降
原因:ShuffleNetV2 的全局池化层对输入尺寸并不十分敏感,但模型预训练时是在 224 分辨率下学习特征的。直接把分辨率拉到 320,模型看到的物体尺度比例变了,它已经把菠萝的某个局部纹理和成熟度关联起来,过大分辨率反而让背景噪声进入决策。
解决:如果部署场景要求处理高清图,不要直接改输入尺寸,而是在预处理阶段把图片 Resize 到 224。如果确实需要高分辨率特征,搭配一个全局上下文融合模块,但这会让轻量级模型不再轻量。实战里最可靠的做法是训练时用 224,部署时保持相同分辨率,用测试集验证输入尺寸上级联增强的稳定性。
5.3 现象:模型在训练集上准确率接近 100%,但测试集严重翻车
原因:数据泄漏。常见泄漏路径有三条:一是同一菠萝的多视角图片被分到 train 和 val;二是做过人脸检测等预处理后,图片上的标记框残留在样本里,模型学会了识别框而不是成熟度;三是数据增强里的 ColorJitter 参数太激进,把青菠萝增强成了黄菠萝,模型学到的是比真实分布更宽泛的颜色范围,遇到真实数据反而无所适从。
解决:最实用的排坑方法是做一次训练集与测试集的相似性检查。把测试集图片 K 近邻到训练集,如果超过 20% 的测试样本能在训练集里找到外观几乎一致的邻居,就要怀疑划分方式有问题。另一个做法是单独留出一批标注后但完全不参与验证和调参的图片,放在柜子里当最终测试集,等所有决策做完再拿出来跑一次。
5.4 现象:8 分类训练 50 个 epoch 后,损失还在缓慢下降
原因:ShuffleNetV2 是轻量级模型,在 8 分类小数据集上收敛不应该这么慢。如果损失还降,可能是学习率调度器没有生效,或者验证集太小导致早停被跳过。另一个常见原因是CosineAnnealingLR的T_max设置与训练总 epoch 不一致,学习率还没降到最低就提前重启或结束。
解决:把scheduler.step()放在每个 epoch 末尾,并打印当前学习率日志确认变化。如果数据总量只有几百张,建议训练 30 epoch 以内,加早停,监督验证集准确率连续 10 个 epoch 不提升就停。不要盲目拉长训练时间,小型轻量模型在小数据集上的特征是容易被记住而不是被理解。
5.5 现象:部署到 Android 端时推理时间比预期慢一倍
原因:ShuffleNetV2 的官方实现里分组卷积和通道混洗对框架优化要求较高,某些框架的 CPU 推理对这类结构支持不好,导致实际延时远高于理论 FLOPs。很多人只在电脑上用 GPU 测速度,忽略 CPU 端的差异。
解决:部署前先用torch.backends.mkldnn或 ONNX Runtime 在 CPU 上测定一次推理时间。如果确实慢,考虑换用 PyTorch Mobile 的量化方案,或者干脆把模型换成移动端专用的变体。这里有个实用经验:不要把 FLOPs 当唯一指标,用目标设备上的实际延迟数据做选型决策。
6. 让 ShuffleNet 在这个项目里发挥到极致:迁移学习、知识蒸馏与模型导出验证
6.1 先用教师模型蒸馏出更强的轻量学生
很多团队在菠萝成熟度分类上急着把数据扔进 ShuffleNet,测试结果不如预期就开始升级硬件。我更推荐一个反直觉的做法:在训练资源充足的地方训练一个 ResNet50 模型精度远高于 ShuffleNet 的正常结果,然后用这个教师模型蒸馏 ShuffleNet 学生。蒸馏的核心在于让学生的输出同时对齐真实标签和教师的概率分布,那些相邻成熟阶段的软标签恰好能传达难以用离散标签表达的相似性。
def distillation_loss(student_output, teacher_output, labels, alpha=0.8, temperature=3.0): ce_loss = nn.CrossEntropyLoss()(student_output, labels) soft_teacher = nn.functional.softmax(teacher_output / temperature, dim=1) log_soft_student = nn.functional.log_softmax(student_output / temperature, dim=1) distill_loss = nn.KLDivLoss(reduction='batchmean')(log_soft_student, soft_teacher) * (temperature ** 2) return alpha * ce_loss + (1 - alpha) * distill_loss温度参数取 3.0 是因为 8 个类别之间的差异不大,温度太低概率分布太平滑教不出东西,太高则所有类别都趋同失去信息。alpha 权重设 0.8 是为了保住真实标签的主导地位。这个技巧能直接拉回 ShuffleNet 2~3 个百分点的准确率,代价只是训练阶段多一些开销。
6.2 用混淆矩阵结果反推数据采集策略
训练结束后,如果发现半熟期和硬熟期混淆严重,说明这两类样本在外观上的区分度不够。这时候不要急着改模型,而是回到采集环节补拍两类果实的剖面图和远端整体图。有些特征在外部果皮颜色上接近,剖面果肉颜色差异更大。这是深度学习中典型的用验证结果指导数据迭代,有效成本往往远低于再换一个更重的网络。验证集在这个阶段就成了你的第二双眼睛。
6.3 模型导出与设备部署的最小化流程
训练完成后,导出这一环最容易出错。把 PyTorch 模型转成 ONNX 再转 NCNN 或 LiteRT 是移动端常用路径。ShuffleNet 的 channel shuffle 操作在某些导出工具里不支持,需要调整 PyTorch 实现或用框架提供的算子映射。经验做法是先用 ONNX 导出不指定动态维度,这样能避开变形类算子报错的坑。
model.eval() dummy_input = torch.randn(1, 3, 224, 224).to(device) torch.onnx.export( model, dummy_input, "shufflenet_pineapple.onnx", opset_version=11, input_names=["input"], output_names=["output"], dynamic_axes=None )导出后务必在推理引擎上做数值一致性比对,比对时拿同一张测试图,对比 PyTorch 输出与 ONNX Runtime 的输出,差异超过 5% 就要检查算子兼容性。我相信 ShuffleNet 在菠萝成熟度分类这个任务里最大的优势不是精度天花板,而是它在有限算力下的稳健表现。结合蒸馏、合理的数据采集和精细的验证策略,这个轻量级网络完成 8 类成熟度分类完全没有问题。最后说一句我的经验:每换一次网络结构,就会丢掉一批此前在数据上积累的理解;把数据验证做透,ShuffleNet 这种轻量模型也足够可靠。希望帮到你。
本文还有配套的精品资源,点击获取