又到了一年毕设季,后台收到不少同学问“基于深度学习的XX识别”这类题目到底怎么做。题面看着就一个“识别”,真正动手你会发现难点根本不在训练,而在前面的数据准备和后面的模型调优。这篇文章我把做“Python+CNN训练识别中药”这个毕设项目时的完整思路、踩坑记录和能直接复用的代码都写出来,给正在做类似图像分类题目的同学一个参考。
1. 接到中药识别题目的第一件事:先搞清楚CNN到底在学什么
1.1 “识别中药”本质上是一个图像分类问题
从机器学习角度看,这个题目要做的事情非常明确:输入一张中药饮片或药材的图片,让模型输出它是哪一味药。这是一个标准的图像多分类任务,技术流程分成四段:图像采集与预处理、卷积神经网络提取特征、全连接层完成分类、输出各类别概率。
很多同学上来就直接调模型、跑训练,结果Loss不下降也不知道为什么。我觉得先想明白CNN在学什么是更重要的事。卷积神经网络在这里做的工作,可以理解为“用一层层卷积核去扫描图片”,低层卷积核学到的是边缘、纹理、颜色块这些基础特征,深层卷积核把这些基础特征组合成更高级的、属于某一类药材的判别特征。比如当归的横切面有放射状纹理,枸杞表面有褶皱,菊花的花瓣形态独特——CNN做的事就是自动找到这些特征,而不是像传统图像识别那样靠人去设计颜色直方图、纹理算子。
1.2 中药图像分类和猫狗分类不是一回事
用现成框架跑通一个猫狗分类教程很容易,因为ImageNet里本来就有大量猫狗图片,预训练模型对这些类别的区分能力已经很强。但中药识别是另一个难度级别,我自己做下来深有体会:
第一,类间差异小。很多中药饮片切片之后外观非常相似,比如两种根茎类药材横截面都是黄白色,只是纹理疏密不同。这对CNN的特征提取能力要求更高,浅层小网络往往学不到足够细微的区分特征。
第二,类内差异大。同一味药,药材形态、切片厚度、干燥程度、拍摄光线都会带来巨大的外观差异。同一种枸杞,产地不同颜色深浅都不一样。模型要把这些差异统统“容忍”掉,只抓住真正用于判别的核心特征。
第三,相似背景干扰。实验桌上、牛皮纸袋里、塑料密封袋中拍的图,背景差异极大。如果训练数据里只有单一背景,模型很容易把背景特征当成分类依据,换一个场景就废了。
所以拿到题目后不要急着敲代码,先把这个任务拆成“数据有没有搞头、模型用什么规模、评估怎么才算过”三个问题,后面才能走得顺。
2. 数据集处理:比模型选型更决定成败的一步
2.1 数据从哪来、怎么整理
训练数据是这个项目的地基。常见来源有两种:自己拍摄采集,或者用公开的中药图像数据集。自己拍的话,尽量固定拍摄角度和距离,但要有意保留一些光照变化和背景变化,不然模型学出来太“娇气”。用公开数据集则要注意类别目录结构,一般按这个格式组织:
dataset/ ├── train/ │ ├── 金银花/ │ ├── 枸杞/ │ └── 菊花/ ├── val/ └── test/PyTorch的ImageFolder类可以直接按这种目录结构加载数据,它会自动把每个子文件夹名称作为类别标签。训练集、验证集、测试集的划分比例我建议6:2:2,而且要保证每个类别的图片在这三个集合里都均匀分布,不能有类别的图片全堆在训练集里。
2.2 预处理三步:缩放、增强、转张量
做中药识别,预处理环节有几个关键选择:
缩放尺寸。输入CNN的图片一般统一到224×224,这是主流分类网络的默认输入尺寸。药材图片拍摄时往往有大量背景留白,直接用Resize会把药材主体压缩变形,更好的做法是用CenterCrop或者先Resize到256再RandomCrop到224,相当于给模型一个“以主体为中心”的视图。
归一化。ImageNet预训练模型要求输入标准化到特定均值和标准差:mean=[0.485, 0.456, 0.406]、std=[0.229, 0.224, 0.225]。这组数值是ImageNet数据集上统计出来的RGB通道分布,迁移学习时沿用即可。如果从零训练自己的网络,这组值也能用,不用改。
数据增强策略。中药图片增强要格外小心,不能过度。我见过有同学对药材图片做随机旋转90度、水平翻转、亮度抖动后,模型在训练集上表现很好,验证集上却崩了——原因是某些药材有方向性特征,比如叶片类药材正反面的纹理不同,过度旋转会产生大量不符合真实场景的样本,模型学歪了。实际项目里比较稳妥的是这样几种:
- 随机水平翻转(概率0.5)
- 随机旋转10~15度(小幅,不转90度)
- 亮度、对比度轻微抖动(±20%)
- 随机仿射变换(平移、缩放轻微组合)
我用torchvision.transforms组合的增强管道长这样:
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=15), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])验证集和测试集不做增强,只做缩放和归一化,这样评估结果才反映模型对真实图片的泛化能力,而不是增强之后的概率分布。
2.3 类别不均衡是中药数据的常态
药材数据很容易不均衡:常见药材图片多,冷门药材可能只有几十张。CNN在类别不均衡的数据上训练,模型会倾向于把预测结果偏向样本量大的类别,因为整体Loss主要由大类样本贡献。
处理方法推荐两个:一是对样本少的类别做额外数据增强,比如对该类图片重复采样并加入更强的随机扰动;二是在WeightedRandomSampler里按类别样本数的倒数设置采样权重,让每个batch里各类别出现频率更均衡。我当时用的后者,实现也不复杂:
from torch.utils.data import WeightedRandomSampler labels = [samples[i][1] for i in range(len(samples))] class_counts = torch.bincount(torch.tensor(labels)) weights = 1.0 / class_counts[labels].double() sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True)效果上,类别不均衡问题明显缓解,小样本类别的recall提升显著。但要注意,replacement=True会让某些小类别样本在不同epoch里反复出现,训练轮数不宜设太大,否则容易过拟合。
3. 模型搭建:不是所有CNN都能直接拿去识别药材
3.1 从零写网络还是用预训练模型
这是毕设项目里最纠结的问题之一。从零写一个CNN可以完整展示网络结构的设计过程,答辩时能讲的东西多;但中药识别这种细粒度分类任务,数据量通常有限,从零训练小网络的收敛效果往往不理想,最终准确率可能只有70%上下。
我的建议是两步走:先搭一个小型CNN把完整训练流程跑通,保证“能运行、能出结果”;然后用迁移学习加载在ImageNet上预训练过的模型(比如ResNet18或ResNet50),只重新训练最后一层全连接,这样能大幅提升识别准确率,而且代码量增加很少,答辩时还能讲“利用预训练知识迁移”的亮点。
我当时就是先用自研小网络验证了DataLoader和训练循环没问题,再切换ResNet18做正式训练。两条路都值得体验一下,进度反而更快。
3.2 搭建ResNet18迁移学习模型
用PyTorch加载预训练ResNet18并改成中药分类模型,核心代码非常短:
import torch.nn as nn from torchvision import models def build_model(num_classes, use_pretrained=True): model = models.resnet18(pretrained=use_pretrained) # ResNet18最后的全连接层输入维度是512 in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) return model关键点在于替换model.fc:原模型是针对1000类ImageNet设计的,最后全连接层输出维度是1000,我们要改成自己的类别数。替换后,模型前面的卷积层保留了ImageNet学到的通用视觉特征(边缘、纹理、形状),只有最后全连接层从头训练,训练速度和最终准确率都会理想很多。
3.3 冻结特征层还是微调全部参数
这里是第二个容易纠结的地方。常见做法有两种:
冻结backbone,只训练全连接层。把前面所有卷积层参数设为requires_grad=False,训练时只更新fc层。优点是训练极快,小数据量也不容易过拟合,缺点是模型学不到中药特有的细粒度特征,准确率上限偏低。
全参数微调(fine-tune)。加载预训练权重后,所有层都参与训练,只是初始学习率设小一点(比如1e-4)。这是中药识别场景下效果更好的方案,因为药材图片和ImageNet自然图像分布差异较大,让深层卷积特征也适应药材数据,识别能力会提升一截。
我当时选了全参数微调,用验证集对比过:冻结特征层版本准确率约82%,全参数微调版约91%,提升明显。代价是训练时间更长、过拟合风险更高,所以配合了数据增强和早停策略。
3.4 自研小CNN的结构参考
如果有同学想从零写网络练手,我推荐一个结构简单但能用的基线:
import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes): 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.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(128, 256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x三个卷积块加两个全连接层的结构,能跑通流程但不是精度上限。注意两点:每个卷积后接BatchNorm2d可以显著加速收敛,缓解梯度消失;AdaptiveAvgPool2d((1,1))代替Flatten前的固定尺寸约束,这样网络对输入尺寸的适应性更强。
4. 训练过程实录:学习率、过拟合和那些只有跑过才知道的事
4.1 损失函数与优化器选择
中药识别是多分类任务,损失函数直接用nn.CrossEntropyLoss(),它内部把Softmax和交叉熵计算合在一起了,输出层不需要额外加Softmax。预测时取输出向量最大值对应的索引就是预测类别。
优化器我推荐Adam,默认参数lr=0.001起步,训练稳定、收敛快。做迁移学习时有个经验:预训练模型微调的学习率通常要比从零训练小一个数量级,我用的是lr=1e-4。等训练后期Loss下降趋于平缓时,切换成CosineAnnealingLR学习率调度,让学习率周期性地衰减再回升,有助于跳出局部极小值点。
损失曲线是判断训练状态最重要的信号。正常情况下训练Loss应该持续下降并趋于平稳,验证Loss先下降后略有上升,这说明过拟合开始出现,需要配合早停策略。
4.2 一次典型的过拟合排查记录
我在训练自研小CNN时遇到过非常典型的过拟合问题:训练集准确率到了95%,验证集却卡在72%上不去。当时的排查过程很有参考价值。
第一步,检查数据预处理。确认验证集没有做随机增强,只有缩放和归一化,排除了评估时受增强噪声干扰的可能。
第二步,检查数据泄露。我怀疑训练集和验证集里混入了完全相同或几乎相同的图片。中药图片数据集中常有连拍图,同一味药换不同角度拍几张,视觉上差异极小。我按文件哈希找出了训练集和验证集之间的重复图片,把重复样本全部归入训练集,验证集重新采样,验证准确率小幅提升到76%,问题部分缓解。
第三步,增强模型的正则化。在分类器前加Dropout(0.5),并加入WeightDecay=1e-4。这一步后验证集准确率到78%,提升不大。
第四步,换更强大的backbone。从自研小CNN换成预训练ResNet18全参数微调,验证集准确率直接跳到89%。这才明白之前的主要瓶颈不是过拟合,而是自研网络的特征提取能力太弱,学不到药材的细粒度判别特征。
这个排查过程说明一件事:当训练集和验证集差距过大时,不要只盯着正则化操作,先确认模型容量是否匹配任务难度。模型太弱,怎么防过拟合都是治标不治本。
4.3 训练循环的关键细节
训练循环里最容易出错的细节有三个:
模型模式切换。训练阶段要调用model.train(),让Dropout和BatchNorm按训练模式运行;验证和测试阶段必须调用model.eval(),否则BatchNorm会用到错误的统计量,预测结果波动很大。这个坑我见过太多次了。
验证阶段关闭梯度计算。用with torch.no_grad():包裹验证代码块,能省内存和计算时间。
保存模型参数。我建议保存状态字典而不是整个模型:
torch.save(model.state_dict(), "herbs_resnet18.pth")这样加载的时候只需重新实例化模型再load_state_dict,跨环境部署更灵活。同时每次验证集准确率刷新到最高时都保存一份快照,防止训练过程中后期崩掉丢失最佳权重。
4.4 训练的超参数参考
我给出一组经过验证可复用的参数设置,供参考:
| 超参数 | 自研小CNN | ResNet18微调 |
|---|---|---|
| 优化器 | Adam | Adam |
| 初始学习率 | 1e-3 | 1e-4 |
| Batch Size | 32 | 32 |
| Epochs | 50(带早停) | 30(带早停) |
| 权重衰减 | 1e-4 | 1e-4 |
| 学习率调度 | CosineAnnealingLR | CosineAnnealingLR |
Batch Size的选择要综合考虑显存和BatchNorm表现。我试过64,收敛更快但GPU显存吃紧,小数据集上32和64最终精度差距不大,32更稳。
5. 从“训练完”到“能交差”:模型评估与识别Demo的落地环节
5.1 不能只看准确率,要看分类报告和混淆矩阵
毕设答辩时老师问得最多的问题之一就是“你这个模型对不同药材的识别能力到底怎么样”。单看整体准确率回答不了这个问题,一定要看每个类别的精确率、召回率和F1分数,以及混淆矩阵。用sklearn处理很简单:
from sklearn.metrics import classification_report, confusion_matrix report = classification_report( all_labels, all_preds, target_names=class_names, digits=4 ) print(report) cm = confusion_matrix(all_labels, all_preds)我从混淆矩阵里发现了一个有意思的误判模式:模型把A药材错判成B药材的比例显著偏高。查了一下是因为两者切片后颜色、纹理高度相似,属于“合理误判”。针对这个情况我做了两件事:一是增加这两种药材的训练图片数量;二是在数据增强时对这两种药材做更小幅度的色偏扰动,强迫模型关注形状和纹理特征而不是颜色。调整后A药材的召回率从84%提到了92%。
5.2 写一个可以实际用的中药识别脚本
训练完成后,把模型封装成一个独立的预测脚本,既能作为项目交付成果演示,也方便自己平时验证图片:
import torch from PIL import Image from torchvision import transforms from torchvision import models import torch.nn as nn class_names = ["金银花", "枸杞", "菊花", "当归", "甘草"] # 根据实际类别修改 transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def load_model(num_classes, weight_path): model = models.resnet18() in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) model.load_state_dict(torch.load(weight_path, map_location="cpu")) model.eval() return model def predict_image(model, image_path): img = Image.open(image_path).convert("RGB") tensor = transform(img).unsqueeze(0) with torch.no_grad(): outputs = model(tensor) probs = torch.softmax(outputs, dim=1).squeeze(0) top_idx = torch.argmax(probs).item() top_prob = probs[top_idx].item() return class_names[top_idx], top_prob if __name__ == "__main__": model = load_model(num_classes=len(class_names), weight_path="herbs_resnet18.pth") name, prob = predict_image(model, "test_image.jpg") print(f"识别结果为:{name},置信度:{prob:.4f}")这个脚本所有环节都用CPU也能跑,预测单张图片耗时几十毫秒,演示完全够用。如果觉得命令行不够直观,可以再加一层简单的图形界面,用tkinter做一个“选择图片→显示预测结果与置信度”的小窗口,代码量不大但演示效果好很多。
5.3 评估时容易被忽略的两个小问题
第一个是归一化统计量。中药图片可能偏暗偏黄,不像自然图像那样色彩分布均匀,但归一化时用ImageNet的mean/std仍然可行,因为这只是对输入分布做标准化,CNN的卷积核会在后续层自己调整特征分布。
第二个是预测置信度的解读。单张图片预测置信度达到99%并不稀奇,但低置信度样本往往更有分析价值。我在测试集中挑出了置信度低于0.5的错误样本,发现基本都是拍摄模糊或药材残缺不全的图片。这在真实应用场景里就是“模型不知道自己在说什么”的信号,可以向用户提示“请重新拍摄更清晰的照片”。
最后再分享一个实战小技巧
做中药识别这类细粒度图像分类项目,很多人忽略了一个低成本高回报的操作:查看模型预测错误的具体图片。训练结束后,把所有测试集错误样本连同预测置信度输出到一个文件夹里,按置信度排序浏览一遍。你会发现错误样本集中在几类情况里:背景复杂的、光照过暗的、与相似药材并排摆放的。把这些发现写进论文的“错误分析”章节,整个项目的深度立刻就不一样了。
这个项目从数据处理到模型部署,核心就一句话:把普适的CNN技术适配到中药图像的特殊性上。数据上解决类间相似、类内多样的问题,模型上借迁移学习的力量,评估上用细粒度的指标看透模型的真实能力。做到这一步,不管毕设题目怎么包装,你都能拿出有说服力的成果。