ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于迁移学习的水果识别:VGG16、ResNet50等四模型对比实战

基于迁移学习的水果识别:VGG16、ResNet50等四模型对比实战

简介:这份资源面向计算机、人工智能、通信工程等专业的在校学生与教师,提供一套可直接用于毕业设计或课程设计的水果识别系统完整方案。项目基于迁移学习思路,对ImageNet上预训练的VGG16、ResNet50、MobileNetV2、DenseNet121四种模型进行微调,并迁移到水果数据集上完成分类任务,最高准确率达到93.08%,源码均经过实际运行测试,答辩评审平均分约96分。压缩包共277个文件,约17.53MB,其中包含8个Python脚本负责模型训练与推理,另有大量js、css、html等前端页面文件用于搭建可视化交互界面,以及gif、jpg、png等图片素材和字体文件,整体结构清晰、便于二次修改。目前已有319人学习下载,适合希望快速上手深度学习图像分类、需要完整毕设参考或在此基础上扩展功能的读者。

1. 从一份 93.08% 准确率的水果识别毕设说起

带过几届毕设之后,我对"水果识别"这个题目是有偏见的——太常见,常见到很多同学直接拿个 MNIST 改改就交差,答辩时被问一句"为什么用这个模型"就卡壳。但这份资源有点不一样:它没有从零搭网络,而是走迁移学习路线,把 ImageNet 上预训练好的 VGG16、ResNet50、MobileNetV2、DenseNet121 四个骨干分别微调,最后在水果数据集上跑到 93.08% 的准确率。这个数字不算惊艳,但胜在真实、可复现,而且四个模型横向对比的思路,恰好是答辩老师最爱追问的点。

这份资源包含源码、文档说明、数据集和训练好的模型权重,适合计算机、人工智能、通信、自动化等专业的同学做毕设、课程设计或立项演示。如果你基础还行,想在这个框架上改分类类别、换骨干网络、加个 Web 界面,它也能当个不错的起点。下面我按"这套东西怎么跑起来、参数怎么调、坑在哪"的顺序拆一遍。

2. 迁移学习选型:四个骨干网络到底怎么选

2.1 为什么是微调而不是从零训练

水果数据集通常只有几千到上万张图,类别也就几十种。这种规模下从零训练一个 CNN,过拟合几乎是必然的——训练集准确率能冲到 99%,验证集卡在 70% 上下不动。迁移学习的逻辑是:ImageNet 上预训练的卷积层已经学会了边缘、纹理、颜色块这些通用特征,水果识别需要的底层特征和它高度重合,你只需要替换最后的全连接分类头,再解冻部分高层做微调。

常见做法是冻结前面的卷积基,只训练新加的分类层,等 loss 稳定后再解冻最后几个 block 一起微调。这份资源里四个模型都是按这个思路走的,区别在于解冻的层数和学习率的设置。

2.2 四个骨干的取舍

模型参数量输入尺寸特点适合场景
VGG16约 138M224×224结构简单,全连接层占参数大头教学演示,理解迁移学习流程
ResNet50约 25M224×224残差连接,训练稳定追求准确率,显存够用
MobileNetV2约 3.4M224×224深度可分离卷积,轻量部署到移动端或边缘设备
DenseNet121约 8M224×224密集连接,特征复用强数据量偏少时表现好

选型上没有绝对优劣。VGG16 参数量大但结构直白,适合第一次跑通流程;ResNet50 是稳妥的基线;MobileNetV2 如果你后面要往手机或树莓派上搬,优先考虑它;DenseNet121 在小数据集上往往能比 ResNet 高出一两个点。资源里四个都给了,建议先跑 MobileNetV2 快速验证流程,再用 ResNet50 冲准确率。

2.3 环境搭建与依赖安装

先确认 Python 版本,建议 3.8 到 3.10,太新的版本有些深度学习库轮子还没跟上。

# 创建虚拟环境,避免和系统包冲突 python -m venv fruit_env # Windows 激活 fruit_env\Scripts\activate # Linux/Mac 激活 source fruit_env/bin/activate # 安装核心依赖,版本按资源文档里的来 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install numpy pandas matplotlib opencv-python pillow tqdm scikit-learn

这里torch的安装命令带了 CUDA 11.8 的索引地址,如果你没有 NVIDIA 显卡,把--index-url那行去掉,装 CPU 版本即可。opencv-python用于图像预处理,scikit-learn用来生成分类报告和混淆矩阵。装完之后用python -c "import torch; print(torch.cuda.is_available())"验证一下 GPU 是否可用,返回True说明环境没问题。

2.4 数据集的目录结构

资源里的数据集一般按类别分文件夹存放,这是torchvision.datasets.ImageFolder能直接读取的标准格式:

fruit_dataset/ ├── train/ │ ├── apple/ │ ├── banana/ │ ├── orange/ │ └── ... ├── val/ │ ├── apple/ │ └── ... └── test/ └── ...

如果你的数据是混在一起的一堆图片加一个标签 CSV,需要先写个脚本按类别拆分。常见做法是训练集、验证集、测试集按 7:2:1 划分,划分时注意每个类别都要有代表,别出现某个类别验证集里一张图都没有的情况。

3. 训练流程拆解:从数据加载到模型保存

3.1 数据增强与 DataLoader 配置

水果图片的拍摄角度、光照、背景差异很大,数据增强是防过拟合的第一道防线。

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)), # 随机裁剪缩放 transforms.RandomHorizontalFlip(p=0.5), # 水平翻转 transforms.RandomRotation(15), # 随机旋转±15度 transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1), # 颜色抖动 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], # ImageNet 均值 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('fruit_dataset/train', transform=train_transform) val_dataset = datasets.ImageFolder('fruit_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)

Normalize里的均值和标准差必须和预训练模型一致,因为骨干网络是在 ImageNet 上用这套参数训练的,你换了参数,底层特征的分布就对不上,微调效果会打折扣。batch_size设 32 是显存和训练速度的折中,显存不够就降到 16 或 8,同时把学习率相应调小。num_workers在 Windows 上有时会报错,改成 0 用主进程加载即可。

3.2 替换分类头与冻结策略

以 ResNet50 为例,把原来的 1000 类输出换成你的水果类别数:

import torch.nn as nn from torchvision import models num_classes = len(train_dataset.classes) # 自动获取类别数 model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) # 冻结所有卷积层参数 for param in model.parameters(): param.requires_grad = False # 替换最后的全连接层 model.fc = nn.Sequential( nn.Linear(model.fc.in_features, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, num_classes) ) # 只训练新加的分类头 optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)

冻结之后只有model.fc的参数会被更新,训练速度快,也不容易过拟合。等分类头收敛后,可以解冻最后一个残差 block 做精细微调,学习率降到 1e-4 甚至 1e-5。这一步是准确率能不能从 90% 推到 93% 的关键,但解冻太多层反而会破坏预训练特征,血泪经验是先从最后一层开始试。

3.3 训练循环与学习率调度

import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) criterion = nn.CrossEntropyLoss() # 余弦退火,学习率从 1e-3 平滑降到 1e-6 scheduler = CosineAnnealingLR(optimizer, T_max=30) best_acc = 0.0 for epoch in range(30): 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() # 验证阶段 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() acc = correct / total scheduler.step() print(f'Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}, Val Acc: {acc:.4f}') # 保存最佳模型 if acc > best_acc: best_acc = acc torch.save(model.state_dict(), 'best_model.pth')

CosineAnnealingLR让学习率按余弦曲线下降,前期大步走、后期小步收敛,比固定学习率稳。T_max设成总 epoch 数,这里 30 轮对应 30。保存模型时只存state_dict而不是整个模型对象,文件更小,加载时也更灵活。训练过程中如果验证准确率连续几轮不涨,可以提前停,别硬跑满。

3.4 四个模型的对比实验

资源里四个骨干都跑了,如果你想复现对比,把上面的models.resnet50换成对应的即可:

# VGG16 model = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) model.classifier[6] = nn.Linear(4096, num_classes) # MobileNetV2 model = models.mobilenet_v2(weights=models.MobileNet_V2_Weights.IMAGENET1K_V1) model.classifier[1] = nn.Linear(model.last_channel, num_classes) # DenseNet121 model = models.densenet121(weights=models.DenseNet121_Weights.IMAGENET1K_V1) model.classifier = nn.Linear(model.classifier.in_features, num_classes)

注意每个模型替换分类头的位置不一样:VGG16 在classifier[6],MobileNetV2 在classifier[1],DenseNet121 直接换classifier。改错了不会报错,但训练时分类头没被替换,输出还是 1000 类,loss 会一直降不下去。这是新手最容易翻车的地方,改完打印一下模型结构确认。

4. 推理与部署:模型怎么用起来

4.1 单张图片推理

训练完拿到best_model.pth,写个推理脚本验证效果:

from PIL import Image import torch from torchvision import transforms, models # 加载模型结构,必须和训练时一致 model = models.resnet50(weights=None) model.fc = nn.Sequential( nn.Linear(model.fc.in_features, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, num_classes) ) model.load_state_dict(torch.load('best_model.pth', map_location='cpu')) model.eval() # 预处理,和验证集保持一致 preprocess = 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]) ]) img = Image.open('test_apple.jpg').convert('RGB') input_tensor = preprocess(img).unsqueeze(0) # 加 batch 维度 with torch.no_grad(): output = model(input_tensor) prob = torch.softmax(output, dim=1) conf, pred = torch.max(prob, 1) print(f'预测类别: {train_dataset.classes[pred.item()]}, 置信度: {conf.item():.4f}')

load_state_dict时如果报 key 不匹配,八成是模型结构没对上,检查分类头的层数和顺序。map_location='cpu'让你在没有 GPU 的机器上也能加载。unsqueeze(0)是给单张图补上 batch 维度,模型默认接受四维输入。

4.2 批量测试与混淆矩阵

答辩时老师常问"哪类容易分错",提前跑个混淆矩阵心里有数:

from sklearn.metrics import classification_report, confusion_matrix import numpy as np model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for images, labels in test_loader: images = images.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) print(classification_report(all_labels, all_preds, target_names=test_dataset.classes)) cm = confusion_matrix(all_labels, all_preds) print(cm)

classification_report会给出每个类别的精确率、召回率和 F1,比一个总准确率有说服力得多。混淆矩阵能看出哪些类别互相混淆,比如青苹果和青柠、橙子和橘子,这些在特征上确实接近,可以在文档里主动说明并给出改进思路。

4.3 模型导出与轻量化

如果要把模型部署到 Web 或移动端,可以考虑导出 ONNX:

dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export(model, dummy_input, 'fruit_model.onnx', input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch_size'}})

dynamic_axes让 batch 维度可变,推理时可以一次传多张图。ONNX 格式能被 ONNX Runtime、TensorRT 等推理引擎加载,速度比原生 PyTorch 快不少。MobileNetV2 导出的 ONNX 模型通常只有十几 MB,塞进小程序或 App 后端都够用。

5. 避坑与排查:那些文档里不会写的问题

5.1 显存不够,训练中途崩

现象是跑几个 batch 后报CUDA out of memory。原因通常是 batch_size 太大,或者验证阶段没加torch.no_grad()导致计算图一直累积。解决办法:把 batch_size 降到 16 或 8,验证和测试代码块外面套with torch.no_grad():,训练循环里每个 batch 结束后optimizer.zero_grad()别漏。如果还不行,用torch.cuda.empty_cache()手动清一下缓存。

5.2 验证准确率不动,loss 不降

现象是训练十几轮,验证准确率一直在随机水平(比如 10 类就是 10% 左右)。原因多半是分类头没替换成功,模型还在输出 1000 类,或者标签和输出对不上。解决办法:打印model结构确认最后一层输出维度等于类别数;检查ImageFolder读到的classes列表和实际文件夹是否一致;确认criterion用的是CrossEntropyLoss而不是BCELoss。

5.3 训练准确率高但测试低

现象是训练集 99%,测试集 70%。这是典型过拟合。原因可能是数据增强太弱、模型参数量相对数据量太大、或者训练轮数过多。解决办法:加强数据增强(加 RandAugment 或 MixUp),换更轻量的骨干如 MobileNetV2,加 Dropout 和权重衰减,早停。别一味追求训练准确率,验证集才是真实水平。

5.4 推理时类别对不上

现象是预测结果总是某几个类别,或者置信度普遍很低。原因是推理时的预处理和训练时不一致,比如忘了Normalize,或者Resize的尺寸不对。解决办法:把训练时的val_transform原封不动复制到推理脚本里,别凭记忆重写。Image.open之后记得.convert('RGB'),有些 PNG 带透明通道,直接转 Tensor 会报错。

5.5 换数据集后准确率暴跌

现象是拿自己的水果照片测试,准确率远低于测试集。原因是训练数据和你的实际场景分布不一致,比如训练集都是白底商品图,你拍的是果园实景。解决办法:要么在训练集里加入实景图做微调,要么在推理前做背景分割。这个问题没有银弹,只能靠数据覆盖。

6. 进阶技巧:把 93% 再往上推一推

如果你已经跑通了基线,想让准确率再好看一点,可以试试这几个方向。第一个是解冻更多层做渐进式微调:先只训分类头 10 轮,再解冻最后一个 block 训 10 轮(学习率降到 1e-4),最后解冻倒数第二个 block 训 5 轮(学习率 1e-5)。这个策略在多个数据集上都能稳定涨 1 到 2 个点,代价是训练时间翻倍。

第二个是测试时增强(TTA):对同一张测试图做多次不同变换(翻转、裁剪),把预测概率平均。代码不复杂,在推理脚本里加个循环就行:

def predict_with_tta(model, img, n=5): probs = [] for _ in range(n): aug = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) tensor = aug(img).unsqueeze(0) with torch.no_grad(): out = torch.softmax(model(tensor), dim=1) probs.append(out) return torch.mean(torch.stack(probs), dim=0)

TTA 的代价是推理时间变成 n 倍,但准确率通常能涨 0.5 到 1 个点,答辩演示时值得开。

第三个是类别不平衡处理:如果某些水果样本特别少,用WeightedRandomSampler给少样本类别更高采样权重,或者在 loss 里加类别权重。这个改动小,但对小类别召回率提升明显。

最后一个习惯:每次改完超参数,别只看最终准确率,把训练曲线、混淆矩阵、分类报告都存下来。答辩时老师问"你怎么调的",你能拿出对比图,比空口说"我试了很多参数"有说服力得多。从那以后我每次跑实验都强制走一遍"保存日志 + 画曲线 + 存最佳权重"的流程,后悔药这东西,能提前备就提前备。希望这份拆解帮到你,资源里的文档和代码配合着看,跑通不难。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进