简介:这份资源是面向计算机相关专业学生与深度学习入门者的水果识别实战项目包,源自大三学期期末大作业,经导师指导并获98分评审认可,可作为课程设计、期末大作业或项目实战练习的参考方案。压缩包共约2000个文件,整体661.64MB,其中1667个jpg、260个jpeg与63个png构成图像数据集主体,另有5个xml标注文件、4个py源码脚本和1个md说明文档,覆盖数据、代码与文档三类核心内容。资源同时包含CNN与MobileNetV2两种网络结构的搭建与对比实验,配套实验报告和文档说明,便于读者理解模型设计思路、训练流程与结果分析。目前已有604人学习,适合需要完整项目案例、想快速复现水果识别流程或对比不同卷积网络性能的学习者参考借鉴。
1. 从一份水果识别大作业说起:CNN 和 MobileNetV2 到底怎么选
水果识别是深度学习课程里最经典的落地题目之一,但真正动手做的时候,很多人会卡在同一个地方:用自己从零搭的 CNN,准确率上不去;换成 MobileNetV2,又不知道预训练权重怎么接、数据集怎么组织、报告里的对比实验怎么写。这份大作业标题里同时出现了 CNN、MobileNetV2、源码、实验报告、文档说明和数据集,说明它不是单一模型 demo,而是一套完整的课程设计交付物,通常包含多个子项目,覆盖从数据准备到模型训练再到结果分析的完整链路。
对正在赶大作业的本科生、刚入门深度学习的转行者,或者需要一套可复现基线来做二次开发的人来说,这类项目的价值不在于代码有多复杂,而在于它把「数据集怎么放、模型怎么搭、训练怎么调、结果怎么呈现」这条链路走通了。下面按实际落地顺序拆开讲,重点放在能直接抄作业的部分,同时把容易翻车的地方标出来。
2. 数据集组织与预处理:水果识别模型的第一道坎
2.1 水果数据集的典型结构与划分策略
水果识别数据集通常按类别分文件夹存放,每个类别下是若干张 JPG 或 PNG 图片。常见的水果类别包括苹果、香蕉、橙子、葡萄、西瓜、草莓等,规模从几千张到几万张不等。如果标题里提到「含多个项目」,那数据集很可能被复用在多个子任务里,比如同时做分类和检测,或者同时跑 CNN 和 MobileNetV2 两套模型。
目录结构一般长这样:
fruits_dataset/ ├── train/ │ ├── apple/ │ ├── banana/ │ ├── orange/ │ └── ... ├── val/ │ ├── apple/ │ └── ... └── test/ ├── apple/ └── ...划分比例常见的是 7:2:1 或 8:1:1。如果数据集本身没有划分好,需要自己写脚本按类别分层抽样,避免某个类别在验证集里一张都没有。这里有个血泪经验:水果图片经常出现背景干扰,比如苹果放在木桌上、香蕉挂在树上,如果训练集和验证集的背景分布差异太大,验证准确率会虚高,实际测试时直接翻车。
2.2 用 torchvision 做数据增强的最小代码
下面这段代码是水果分类任务里最常用的预处理和增强流程,直接基于torchvision.transforms实现:
import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader # 训练集增强:随机裁剪、翻转、颜色抖动 train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), # 随机裁剪并缩放到224 transforms.RandomHorizontalFlip(p=0.5), # 水平翻转 transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05), # 颜色扰动 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) # ImageNet统计量 ]) # 验证集和测试集只做缩放和归一化 val_transform = 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]) ]) train_dataset = datasets.ImageFolder('fruits_dataset/train', transform=train_transform) val_dataset = datasets.ImageFolder('fruits_dataset/val', transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4) print(f"类别数: {len(train_dataset.classes)}, 训练样本: {len(train_dataset)}")逻辑说明:RandomResizedCrop的scale=(0.7, 1.0)表示随机裁剪面积占原图的 70% 到 100%,这个参数对水果识别很关键,因为水果在图片中的占比变化很大。ColorJitter的hue参数不要设太大,水果颜色是重要特征,色相扰动过强会让苹果和橙子混淆。Normalize用的均值和标准差是 ImageNet 的统计量,如果后面要用预训练权重,这一步必须保持一致。
参数说明:batch_size=32是 8GB 显存下的安全值,如果显存更小可以降到 16 或 8。num_workers=4在 Windows 上如果报错,改成 0 或 2。scale下限不要低于 0.5,否则水果可能被裁得只剩一小块,模型学不到完整形状。
提示:如果数据集里某个类别的图片数量明显少于其他类别,建议在
DataLoader里加WeightedRandomSampler,否则模型会偏向多数类。
3. 从零搭 CNN:结构设计、训练循环与参数调优
3.1 一个适合水果分类的轻量 CNN 结构
从零搭 CNN 做水果识别,不需要堆太深,4 到 5 个卷积块就够了。下面这个结构在 10 类水果、每类 500 张左右的规模下,通常能跑到 85% 以上的验证准确率:
import torch.nn as nn class FruitCNN(nn.Module): def __init__(self, num_classes=10): super(FruitCNN, self).__init__() self.features = nn.Sequential( # 输入 3x224x224 nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 112x112 nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 56x56 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 28x28 nn.Conv2d(128, 256, kernel_size=3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.AdaptiveAvgPool2d(1) # 256x1x1 ) self.classifier = nn.Sequential( nn.Dropout(0.5), nn.Linear(256, 128), nn.ReLU(inplace=True), nn.Linear(128, num_classes) ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) return self.classifier(x)逻辑说明:每个卷积块后面接BatchNorm2d和ReLU,BatchNorm能加速收敛并起到一定正则作用。最后用AdaptiveAvgPool2d(1)把特征图压成 1x1,避免全连接层参数过多。Dropout(0.5)放在分类头前面,防止过拟合。
参数说明:卷积核统一用 3x3,padding=1保持空间尺寸不变。通道数从 32 翻到 256,翻倍节奏是 2 的幂次,方便显存对齐。如果类别数不是 10,改num_classes即可。如果输入尺寸不是 224,AdaptiveAvgPool2d会自动适配,不用改结构。
3.2 训练循环与学习率调度
训练循环里最容易出问题的是学习率设太大导致 loss 震荡,或者设太小导致收敛太慢。水果识别任务常用的起点是lr=1e-3,配合CosineAnnealingLR或StepLR:
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = FruitCNN(num_classes=10).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-6) best_acc = 0.0 for epoch in range(50): model.train() running_loss = 0.0 for images, labels in train_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() scheduler.step() # 验证 model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_acc = correct / total print(f"Epoch {epoch+1:02d} | Loss: {running_loss/len(train_loader):.4f} | Val Acc: {val_acc:.4f}") if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_cnn.pth')逻辑说明:weight_decay=1e-4是 L2 正则,配合 Dropout 一起抑制过拟合。CosineAnnealingLR让学习率从 1e-3 平滑降到 1e-6,避免后期在最优解附近震荡。每个 epoch 结束后做一次验证,只保存验证准确率最高的权重。
参数说明:T_max=50要和总 epoch 数一致。如果训练集很小(比如每类不到 200 张),把weight_decay提到 1e-3,Dropout提到 0.6。如果 loss 在前几个 epoch 就降到很低但验证准确率不涨,说明过拟合了,需要加数据增强或减少模型容量。
注意:
scheduler.step()放在 epoch 结束后调用,不要放在 batch 循环里,否则学习率下降过快。
4. MobileNetV2 迁移学习:预训练权重怎么接、层怎么冻
4.1 MobileNetV2 的结构特点与迁移策略
MobileNetV2 的核心是倒残差结构(Inverted Residual)和线性瓶颈(Linear Bottleneck),在保持轻量的同时有不错的特征提取能力。做水果识别时,通常有两种迁移策略:一是只替换最后的分类层,冻结前面所有层;二是冻结前几层,微调后面的层。如果数据集规模在几千张以上,建议用第二种,效果通常比全冻结好 3 到 5 个百分点。
import torchvision.models as models import torch.nn as nn def build_mobilenetv2(num_classes=10, freeze_ratio=0.7): model = models.mobilenet_v2(pretrained=True) # 冻结前 freeze_ratio 比例的特征层 features = list(model.features.children()) freeze_until = int(len(features) * freeze_ratio) for i, layer in enumerate(features): if i < freeze_until: for param in layer.parameters(): param.requires_grad = False # 替换分类头 in_features = model.classifier[1].in_features model.classifier = nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, num_classes) ) return model model = build_mobilenetv2(num_classes=10, freeze_ratio=0.7).to(device)逻辑说明:freeze_ratio=0.7表示冻结前 70% 的特征层,只训练后面 30% 和分类头。MobileNetV2 的features是一个Sequential,里面包含多个倒残差块,按顺序冻结可以保留底层的通用特征(边缘、纹理),让高层去适应水果的特定特征。
参数说明:freeze_ratio在 0.5 到 0.8 之间调整。如果数据集很小(每类 100 张以下),可以设到 0.9 甚至全冻结。如果数据集很大(每类 1000 张以上),可以设到 0.3 或更低。Dropout(0.3)比从零搭 CNN 时的 0.5 要低,因为预训练模型本身已经有正则效果。
4.2 微调时的学习率设置与差分学习率
迁移学习的关键参数是学习率。如果对所有层用同一个学习率,预训练权重容易被破坏。常见做法是给特征层和分类头设置不同的学习率:
# 分组设置学习率 features_params = [] classifier_params = [] for name, param in model.named_parameters(): if param.requires_grad: if 'classifier' in name: classifier_params.append(param) else: features_params.append(param) optimizer = optim.Adam([ {'params': features_params, 'lr': 1e-4}, # 特征层用小学习率 {'params': classifier_params, 'lr': 1e-3} # 分类头用大学习率 ], weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-6)逻辑说明:特征层的学习率设为分类头的十分之一,避免预训练权重被快速覆盖。分类头是随机初始化的,需要更大的学习率来快速收敛。这种差分学习率在 MobileNetV2 微调里几乎是标配。
参数说明:如果验证准确率在前 5 个 epoch 就超过 90%,说明任务太简单,可以把特征层学习率再降一个数量级。如果验证准确率一直不涨,检查分类头的学习率是不是太小,或者冻结比例是不是太高。
提示:MobileNetV2 的
pretrained=True会下载 ImageNet 预训练权重,如果网络环境不稳定,可以提前手动下载权重文件放到~/.cache/torch/hub/checkpoints/目录下。
5. 避坑与排查:水果识别项目里最容易翻车的 5 个地方
5.1 验证准确率很高但实际测试一塌糊涂
现象:训练时验证集准确率能到 95%,但拿手机拍几张水果照片测试,识别结果乱七八糟。
原因:训练集和验证集来自同一个数据源,背景、光照、拍摄角度高度相似,模型学到了背景特征而不是水果本身。比如所有苹果都在白色盘子里,模型可能把「白色盘子」当成了苹果的特征。
解决:在验证集里加入不同背景、不同光照的图片,或者用RandomResizedCrop和ColorJitter加大增强力度。更彻底的做法是单独留一个「野外测试集」,从网上找不同场景的水果图片,不参与训练和调参,只做最终评估。
5.2 类别不平衡导致某些水果永远识别不出来
现象:训练 loss 正常下降,但混淆矩阵显示某个类别几乎全被预测成另一个类别。
原因:某个类别的样本数远少于其他类别,交叉熵损失被多数类主导,模型倾向于把所有样本预测成多数类。
解决:在CrossEntropyLoss里加weight参数,按类别频率的倒数设置权重。或者用WeightedRandomSampler在采样阶段就做平衡。如果某个类别样本实在太少,考虑用数据增强生成更多样本,或者直接合并到相似类别里。
5.3 显存不够导致训练中断
现象:训练到一半报CUDA out of memory,或者一开始就报错。
原因:batch_size太大,或者输入分辨率太高,或者模型参数量超出显存容量。
解决:先把batch_size减半,如果还不行就把输入尺寸从 224 降到 192 或 160。MobileNetV2 在 160 分辨率下仍然有不错的精度。另外检查num_workers是不是设得太大,数据加载线程也会占用显存。
5.4 预训练权重加载失败或维度不匹配
现象:load_state_dict报size mismatch或unexpected key。
原因:分类头的输出维度跟预训练模型不一致,或者模型结构被修改过但权重文件还是旧的。
解决:用strict=False加载,忽略分类头的维度不匹配。或者先加载特征层权重,再单独初始化分类头。如果是从零搭的 CNN,不要试图加载 MobileNetV2 的权重,结构完全不同。
5.5 训练速度慢到无法接受
现象:一个 epoch 要跑十几分钟,50 个 epoch 跑一整天。
原因:没有用 GPU,或者num_workers设成 0 导致数据加载成为瓶颈,或者图片没有提前缩放到统一尺寸。
解决:确认torch.cuda.is_available()返回True。把num_workers设成 CPU 核心数的 2 到 4 倍。提前把所有图片缩放到 256x256 存成新文件,训练时只做随机裁剪和归一化,能省掉大量 IO 时间。
6. 实验报告怎么写才像做过:对比表格与可视化技巧
实验报告是这类大作业的交付重点之一,但很多人写成了流水账。真正能拿高分的报告,核心是「对比」和「解释」。下面这张表是我常用的对比框架,把 CNN 和 MobileNetV2 放在同一套数据上跑,记录关键指标:
| 指标 | 从零搭 CNN | MobileNetV2 全冻结 | MobileNetV2 微调 70% |
|---|---|---|---|
| 验证准确率 | 85.2% | 91.5% | 94.8% |
| 训练参数量 | 1.2M | 2.2M(仅分类头) | 2.2M(部分特征层) |
| 单 epoch 耗时 | 45s | 38s | 42s |
| 收敛 epoch | 35 | 12 | 18 |
| 过拟合风险 | 高 | 低 | 中 |
表格里的数字要根据自己实际跑的结果填,不要照抄。关键是在报告里解释「为什么 MobileNetV2 微调比全冻结好」——因为水果数据集和 ImageNet 有分布差异,微调能让高层特征适应水果的特定纹理和颜色。同时解释「为什么从零搭 CNN 收敛慢」——因为没有预训练权重,所有参数都要从头学。
可视化部分,混淆矩阵和 t-SNE 特征分布图是加分项。混淆矩阵能直观看出哪些水果容易混,比如苹果和桃子、橙子和橘子。t-SNE 把倒数第二层的特征降到二维,能看出模型是否把不同类别分开了。如果两类在 t-SNE 图上混在一起,说明模型没学好区分特征,需要检查数据增强或类别平衡。
最后说一个我自己的习惯:每次跑完实验,先把best_cnn.pth和best_mobilenetv2.pth的验证准确率、训练时间、参数量记在一个单独的文本文件里,不要等到写报告时再翻日志。这个习惯帮我省了很多后悔药,尤其是当你要同时跑多个项目的时候。希望帮到你。
本文还有配套的精品资源,点击获取