之前帮A同学准备毕业设计的时候,就选了"基于CNN卷积神经网络的橘子是否新鲜识别"这个题目。很多同学一听到"深度学习""卷积神经网络"就下意识觉得高不可攀,但真正做完这个课题你会发现:这个题目的技术门槛其实适中,真正决定项目质量的,从来不是模型本身有多炫,而是数据怎么建、预处理有没有坑、训练效果不对时能不能快速定位问题。这篇文章会把整条链路完整拆开讲一遍,包括数据采集、网络结构选择、训练踩坑实录、评估和答辩准备,无论你是正在选毕设题目,还是代码写到一半卡住了,都可以直接对照着查。
1. 课题定调:橘子新鲜识别到底在做什么
1.1 为什么选这个题目:看着简单,实则环环有坑
A同学最初在"车牌识别"和"橘子新鲜识别"两个题目之间犹豫。车牌识别在开源社区已经有非常成熟的方案,拿预训练模型改一改就能出结果,但这也意味着很难讲出"你自己做了什么";橘子新鲜识别则不同,没有太多现成方案可以照搬,数据要自己拍、新鲜度特征要自己分析、模型效果要自己调,整个链路跑下来,能产出的实验记录和对比分析远多于前者。
从实际场景看,农产品外观品质检测是生鲜分选、超市自助结算、物流仓储质检都用得上的方向。一个能判断橘子是否新鲜的模型,稍加改造就能迁移到苹果、番茄、土豆等品类,这个小题目背后的工程价值是完整的。从工作量看,它也恰好卡在"不会太简单"和"不至于做不完"之间:二分类任务不需要标注边界框、不需要做语义分割,一个人用三个月完成闭环是可行的。
1.2 任务本质:图像二分类,别自己把题目做复杂
不少同学拿到题目第一反应是"要不要先做目标检测,把橘子框出来再判断"。这个思路本身没有错,但对本科毕业设计而言属于过度设计。只要采集数据时保证橘子位于画面中心、且占画面面积的三分之一以上,问题就等价于一个标准的图像分类任务:输入一张RGB图像,输出"新鲜"和"不新鲜"两个类别的概率。
这里补充一个容易被忽略的细节:二分类既可以用Sigmoid输出加二元交叉熵损失,也可以用Softmax输出加交叉熵损失,两者在数学上本质等价。我在实际代码里直接使用nn.CrossEntropyLoss,因为PyTorch里预训练模型的输出层衔接得更自然,也少写一层激活函数,出错概率更低。还有一个建议:在论文开篇就要把"新鲜"限定为"基于外观的视觉新鲜度",包括表皮颜色、光泽、皱缩程度、霉斑面积等,不要试图覆盖糖度、水分这类化学指标,否则数据标注和结果评估都会失控。
2. 数据集是毕设真正的分水岭:自采、清洗与增强
2.1 自采数据:从市场买几袋橘子开始的完整流程
很多同学问过:能不能直接在网上找现成的橘子图片数据集?我的建议是:除非你专门做跨域泛化研究,否则别用。网络图片的分辨率、拍摄角度、光照条件、背景杂乱程度完全不可控,模型学到的很可能不是橘子本身,而是"背景噪声",而且来源和版权也说不清楚。自己拍虽然累,但每张图的拍摄条件都心里有数,后面出任何问题都好排查。
实际操作分几步:先去市场买两袋橘子,一袋挑品相好的,一袋故意放一周甚至更久。想加速腐烂,可以把橘子放进温暖潮湿的纸箱,几天后就会出现表皮皱缩、局部霉斑、褐色水渍这些典型的不新鲜特征。拍摄用手机或普通相机都可以,关键是控制变量:背景用纯色纸板或桌面,光源用固定角度的自然光或LED灯,手机固定在三脚架上。同一个橘子换多个角度多拍几张,至少要覆盖两三个品种、多种腐烂程度,每类攒到400到600张。一周累计1200到1500张,足够跑通全流程。
2.2 清洗与目录组织:脏数据比少数据更可怕
拍完照片之后第一件事不是训练,而是逐张人工筛图。模糊的、过曝的、橘子占比过小的、背景混入人的,全部删掉。这一步很枯燥但非常值得:A同学第一次筛图时发现,有接近两百张照片因为手抖对焦不准,如果这些图混进训练集,模型在前几个epoch会一直震荡。
目录按分类任务最常用的方式组织,不需要任何标注工具,文件夹名就是标签:
dataset/ train/ fresh/ rotten/ val/ fresh/ rotten/ test/ fresh/ rotten/划分比例建议6:2:2,同时要特别注意:同一个橘子的不同角度照片只能出现在同一个集合里。不能上午拍的图在训练集、下午补拍的同一颗橘子进了测试集,否则相当于把同一道题的变体同时放进训练和测试,评估结果会虚高。这个细节我在答辩时专门讲过,老师的反馈是"考虑到了数据泄漏问题,不错"。
2.3 数据增强:把400张"用成"4000张
小数据集几乎离不开数据增强。训练集我配了一套增强管道:随机水平翻转、随机旋转15度、随机裁剪缩放、颜色抖动。其中颜色抖动对橘子这个任务尤其关键,因为不同光源下橘子表皮的颜色差异非常大,让模型见过更广的色彩范围,能显著提高泛化性。
import torchvision.transforms as T train_transforms = T.Compose([ T.Resize((224, 224)), T.RandomHorizontalFlip(p=0.5), T.RandomRotation(degrees=15), T.RandomResizedCrop(size=(224, 224), scale=(0.8, 1.0)), T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) valid_transforms = T.Compose([ T.Resize((224, 224)), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])为什么Normalize要用ImageNet的均值和标准差?如果你的模型加载了ImageNet预训练权重,这一步必须和预训练时的输入分布保持一致;如果完全自定义网络从零训练,也可以换成自己数据集统计出来的均值和方差。还有一条铁律:验证集和测试集只能做Resize和Normalize,不能加随机旋转、随机裁剪这类增强,否则每次评估结果都会来回抖动,无法横向比较实验。
3. CNN模型选型:自定义网络还是迁移学习
3.1 为什么CNN能"看懂"橘子:局部感知与参数共享
如果没有CNN,这个题目的传统做法是手工提取颜色直方图、纹理特征,再喂给SVM或随机森林。这套流程有两个致命问题:特征设计依赖经验,换一个橘子品种就崩;颜色直方图对光照极其敏感,稍微偏色就误判。CNN把"特征设计"变成了"特征学习",通过卷积核在图像上滑动,用局部连接捕捉纹理细节,用权值共享大幅减少参数量。
可以这样类比:全连接网络相当于让一个会计把整张报表的每个数字都背下来,而CNN像是一个先看局部小票、再看横纵汇总、最后查异常项的审计员。它存储效率高,而且对"橘子出现在画面稍微偏左偏右"这类位置变化不敏感,这正是图像分类需要的平移不变性。
3.2 两条路线:自定义网络与预训练微调怎么选
我给A同学推荐的是"双线并进":主体实验用预训练ResNet18做微调,同时保留一个自己写的小型CNN做对比。只依赖预训练模型,答辩时容易被问"你自己的网络结构设计在哪里";只写自定义网络,又容易因为数据量不足导致精度上不去。两条线并行,既能展示工程实现能力,又能展示对经典网络原理的理解。
自定义网络不需要太深,三层卷积加两层全连接已经足够:
import torch.nn as nn class FreshNet(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 28 * 28, 256), nn.ReLU(inplace=True), nn.Dropout(p=0.5), nn.Linear(256, 2), ) def forward(self, x): return self.classifier(self.features(x))输入224×224的图,经过三次最大池化后分辨率变成28×28,通道数从32翻到128。通道数逐层翻倍是CNN的经典设计逻辑:空间分辨率在下降,就用更多通道去表达更抽象的特征。BatchNorm在这里不是可选项,它能让深层网络梯度传播更稳定,对学习率的敏感度也会降低。Dropout放在全连接层前,是为了防止全连接层把局部特征全局化时产生过拟合。
3.3 预训练微调:用三行代码引入ResNet18
预训练模型部分代码更短:
import torchvision.models as models model = models.resnet18(pretrained=True) model.fc = nn.Linear(model.fc.in_features, 2)只改了最后一层全连接,把1000类输出换成2类。微调时我用的是"全参数微调",而不是把前几层冻结。很多人以为冻结浅层能防止过拟合,但橘子表皮纹理和通用图像特征差距不算大,在小数据集上全参数微调通常收敛更快,前提是数据增强要跟上。
用ResNet18而不是ResNet50,是我踩过一次坑之后得到的结论:在只有一千多张图片的数据集上,ResNet50的参数冗余会带来明显过拟合,最终精度反而低于ResNet18。深度是好事,但需要更多数据去喂饱它。
4. 训练实战:参数配置与三件踩坑事故的完整复盘
4.1 一套亲测可用的训练配置
训练部分先把参数表格化,后面任何一处改动都能说清楚来源。项目里的基准配置如下:
| 配置项 | 数值 | 说明 |
|---|---|---|
| 输入尺寸 | 224×224 | 与预训练模型输入保持一致 |
| Batch Size | 32 | 显存不足可降到16 |
| Epochs | 30 | 配合早停,防止过拟合 |
| 优化器 | AdamW | 收敛稳定,权重衰减更干净 |
| 学习率 | 1e-3 | 自定义网络;预训练微调建议1e-4 |
| 学习率调度 | CosineAnnealing | 后期自动降低学习率 |
| 损失函数 | CrossEntropyLoss | 二分类场景与BCE等价 |
| 早停 | patience=5 | 连续5轮验证集不提升即停止 |
为什么用AdamW而不是SGD?对小规模数据集和非凸优化问题,Adam类自适应学习率方法收敛更快,对学习率不敏感,适合学生阶段快速验证想法。CosineAnnealing的作用是让模型后期走得更稳,避免在最优解附近来回震荡。早停不是偷懒,它是防止过拟合最便宜的手段:验证集连续几个epoch不提升,往往意味着模型开始"背"训练集细节了。
4.2 踩坑一:训练集100%,验证集只有68%的完整排查
第一个坑是典型过拟合。A同学第一次用FreshNet训练,跑了30个epoch,训练集准确率冲到100%,验证集却只有68%。这不是偶然现象,而是小数据集加参数模型的经典症状。
排查链路要一层层来:先看loss曲线,训练loss一路降到接近0,验证loss在第12个epoch左右开始反弹,基本可以断定过拟合。再归因,数据量每类只有四五百张,模型全连接层又偏大,可拟合空间远大于数据提供的信息量。最后对症下药,我从四个方向同时调整:增强管道里把固定Resize换成RandomResizedCrop;全连接层加Dropout;模型换成预训练ResNet18;开启早停。改完验证集准确率从68%升到91%,立竿见影。
这里有一条经验:不要一次叠加所有trick,否则你根本说不清是哪个措施起了作用。一次只改一个变量,记录每次实验的结果,这份实验记录到答辩时会成为你最有说服力的材料。
4.3 踩坑二:Loss明明在下降,验证准确率却始终50%
第二个坑更隐蔽。某次训练中loss从0.9稳步降到0.3,但验证集准确率一直卡在50%附近,跟抛硬币差不多。遇到这种"训练正常但验证诡异"的情况,先别怀疑模型,绝大多数是数据或预处理出了问题。
排查过程是这样推进的:先打印一个batch的标签和对应图片路径,发现标签顺序没有问题;然后怀疑模型代码写错,换回最简单的两层网络,结果依旧;最后我把每个batch的第一张图存下来,用matplotlib显示,才发现橘子整体偏蓝。根因是自定义Dataset里用OpenCV读取图片,返回的是BGR三通道,但预处理沿用PIL的习惯,用RGB的ImageNet均值做Normalize,两边通道顺序不一致,模型看到的就是一批颜色错乱的"蓝橘子"。
这个问题在代码里极难发现,因为loss确实会下降,网络有能力去拟合错乱的颜色分布,只是学不到任何泛化特征。修复方法很简单:统一用PIL读图,或者归一化前先执行cv2.cvtColor(img, cv2.COLOR_BGR2RGB)。从那以后我给自己立了个规矩:任何图像项目先做可视化检查,再谈训练。
4.4 踩坑三:类别不均衡导致模型"偷懒"
第三个坑出现在想扩大腐烂样本多样性的时候。新鲜橘子样本拍到了800张,腐烂样本因为等不到那么多"自然变质",只有250张。模型收敛后准确率能到87%,看着不错,但看每类的单独指标就露馅了:腐烂类召回率只有31%,模型实际上把所有图都判成了新鲜。
背后原因是交叉熵损失在类别不均衡时天然偏向多数类:多数类的梯度贡献大,模型只要把全部样本判成新鲜,整体loss就已经很低了。解决方案有三个层次:最直接的是给损失函数加权重,让少数类的错误被放大;其次是重采样,让腐烂样本有更高的采样概率;更复杂的是换Focal Loss,但在小数据下不推荐。我实际只用了前两种,腐烂类召回率从31%拉到83%。
加权交叉熵的实现很简单:
class_counts = torch.tensor([800, 250], dtype=torch.float) weights = 1.0 / class_counts weights = weights / weights.sum() criterion = nn.CrossEntropyLoss(weight=weights.to(device))从此之后,我每训练一个模型都习惯看混淆矩阵,而不是只等终端里那个总准确率。
5. 评估、可视化和答辩:把准确率变成一套能讲清楚的故事
5.1 完整评估指标:比单独一个准确率靠谱得多
类别不均衡已经证明准确率具有欺骗性,所以正式评估时至少要看四类指标:Accuracy、Precision、Recall、F1,外加一张混淆矩阵。项目里一组代表性数据如下:
| 指标 | 数值 |
|---|---|
| Accuracy | 93.4% |
| Precision(腐烂类) | 91.2% |
| Recall(腐烂类) | 88.6% |
| F1(腐烂类) | 89.9% |
两个概念容易混淆但答辩必问:如果把"腐烂"定为正类,Recall表示真正腐烂的橘子里有多大比例被挑出来,漏检越少越好;Precision表示模型判成腐烂的橘子里有多大比例真的腐烂,误伤越少越好。生鲜分选场景更看重Recall,因为放走一个坏果会影响整批质量;零售结算可能更看重Precision,避免把好果误淘汰。正类如何选择本身就是一个值得论述的问题,答辩时能讲出这层权衡很加分。
5.2 对比实验:让选型逻辑有数据支撑
毕业论文里几乎必放对比实验,目的不是炫耀模型数量,而是证明你的选型判断。三个模型横向评测结果如下:
| 模型 | 参数量 | 测试集准确率 | 单张推理耗时 |
|---|---|---|---|
| FreshNet(自定义,从头训练) | 约450万 | 88.6% | 约10ms |
| ResNet18(预训练微调) | 约1100万 | 94.2% | 约30ms |
| ResNet50(预训练微调) | 约2500万 | 93.1% | 约70ms |
这个表格一眼能看出两个结论:预训练模型明显优于从头训的自定义网络;ResNet50参数是ResNet18的两倍多,精度反而更低。前者说明迁移学习在小数据场景的价值,后者说明模型复杂度不是越高越好。把这两条写进论文,答辩老师大概率会认可。如果实验时间充裕,还可以补一组传统SVM加颜色直方图特征的baseline,精度往往只有80%左右,更能衬托CNN的端到端优势。
5.3 Grad-CAM可视化:让模型自己解释判断依据
模型判断橘子新鲜到底看的是颜色还是霉斑?用Grad-CAM可以把判断依据直接画出来。它利用最后一层卷积特征图对类别置信度的梯度,加权求和得到一张热力图,叠加到原图上,就能看到模型重点关注哪些区域。
实际效果非常直观:新鲜橘子的热区主要集中在果蒂和光滑表皮;腐烂橘子的热区会精准落在霉斑和皱缩处。看到那几张热力图的时候,你会确信模型学的不是背景也不是塑料袋纹理,而是人类同样能理解的外观特征。实现上不需要自己写反向传播钩子,直接调用开源的Grad-CAM工具库就行,把模型和预处理函数接进去,就能输出热力图。这张图放进论文效果图部分,比十行文字都管用。
5.4 答辩高频问题:提前把答案想透
整理一下模拟答辩时反复被问的几个问题,做图像分类的同学大概率都会遇到。
"为什么不用传统机器学习?"回答重点放在CNN端到端学习特征、避免人工设计颜色直方图和纹理特征,再用对比实验数据说明差距。
"为什么只做二分类,不做三分类或者回归?"因为视觉新鲜度的中间态主观性强,标注一致性差;二分类对应工业分选里合格与不合格的实际需求。如果想深化,可以提出回归新鲜度分数作为未来工作方向。
"数据量这么少,模型会不会是死记硬背?"用数据增强、Dropout、预训练微调、独立测试集来证明泛化措施,同时大方承认测试集仍有5%到6%错误,指出这是数据规模限制,未来可以扩充多季节、多品种样本。
"换一种橘子或者换一台手机拍,还能用吗?"这是域适应问题。训练时已经用颜色抖动模拟了不同光照,但严格应对不同拍摄设备,需要目标域数据做微调。诚实回答比硬撑更有说服力。
5.5 演示环节:让一个Demo帮你把工作串起来
毕业答辩不一定要做完整系统,一个批量预测脚本加现场拍摄的几张照片就足够。核心逻辑很简单:加载保存的最优权重,对测试图片做和训练完全一致的预处理,输出概率和类别。写成函数后现场跑三张图:一张明显新鲜、一张明显腐烂、一张介于中间,正好用来引出"模型边界在哪里"的讨论。
有一点必须提醒:推理时的预处理管道要和训练时完全一致,包括Resize尺寸、Normalize的均值和标准差。不少人在训练时顺手写了预处理,演示时却漏掉Normalize,结果模型输出全是零点几的乱码概率,现场翻车是最尴尬的。
6. 复盘:做这类项目,真正拉开差距的从来不是模型
做完整个项目回看,最大的感受是所谓"深度学习项目",大部分时间都花在了模型之外。数据拍摄、清洗、通道顺序、类别权重、可视化、对比实验,这些环节加起来占了我七成精力,真正跑训练只占三成。但恰恰是这些"杂活",最后构成了论文里最有说服力的部分。所以如果你正打算做这个选题,不用焦虑自己的网络结构不够新、代码不够炫,踏踏实实把数据做好、把每个实验记录好,结果不会差。
再分享一个个人习惯:每次训练结束,把当时的配置、增强策略、最优epoch、验证集指标写进一张表格。一开始觉得麻烦,后来写论文和准备答辩时才意识到,这份记录是最省心的素材来源。还有一个保存模型的技巧:不要只留最后一个epoch的权重,要保存验证集loss最低的那一次,因为训练后期模型往往已经在过拟合边缘,最后一个epoch远不是最优解。这一点,足够帮你少赔很多次"就差一步"的遗憾。