简介:一份获得2021年全国大学生数学建模竞赛E题一等奖的中草药鉴别完整论文,适合备战国赛的参赛队伍、数学建模爱好者及机器学习入门者研读。内容以近红外/中红外光谱数据为依托,系统展示了从数据可视化、改进K-means聚类、平均相关系数分析到BP神经网络建模的完整解题链条,覆盖药材种类鉴别、产地判别、多光谱综合分析与缺失数据处理等核心问题。压缩包内为1个docx文档(2.27MB),为论文全文,可直接阅读并对照摘要、问题分析、模型建立与求解等章节复盘作者思路。论文中公式推导、图表展示与关键算法实现细节完整,尤其改进K-means自动确定最优类数、BP网络构建与训练过程都极具参考价值。资源已有1679人学习下载,适合希望学习获奖论文结构、掌握光谱数据处理与聚类神经网络综合应用的读者。
1. 中草药鉴别题到底在考什么:别把国赛E题做成纯图像分类
2021年全国大学生数学建模竞赛的E题“中草药鉴别”表面上是一道图像识别题,但真正拉开获奖差距的从来不是模型有多新,而是你能不能把一张带背景、带光照偏差、甚至叶片残缺的中草药照片,变成一套稳定可复现的分类系统。很多队伍在这道题上翻车,不是因为CNN不会调,而是把精力全砸在调参上,忽略了数据清洗、类别均衡和误判分析。这道题适合那些愿意在数据层面下苦功夫的参赛者——模型用ResNet就能打,但数据决定上限。本文从数据构造、模型训练到验证评估,按国赛实战的完整链路拆一遍,最后给出我踩过的四个大坑和三条把精度推到极限的路径。
2. 先把中草药数据变成能训练的样子:采集、清洗与扩增
2.1 中草药图像数据的真实痛点:背景干扰与类内差异
中草药鉴别题给的数据集通常是“一张图一味药”,看起来干净,实际上一训练就露馅。常见做法是官方提供几百张已标注图片,但真实场景下同一味药在不同生长阶段、不同拍摄角度、不同光照下,外观差异极大。比如金银花和山银花,花瓣形态和颜色在照片上几乎无法区分;陈皮和青皮切开后纹理相似,只凭RGB像素很难稳定建模。
我用PyTorch做了一套标准的图像分类预处理流程,第一步就是统一尺寸并做归一化。不要小看这个步骤——中草药图片原始尺寸五花八门,直接从磁盘读入训练会导致batch内张量形状不一致,显存浪费严重。最常见的做法是Resize到224x224(ResNet系列的标准输入),但对中草药这种纹理敏感的任务,我后续会建议你用更大的输入尺寸,这里先给最小可用方案:
import torch from torchvision import transforms from PIL import Image # 训练集增强:中草药照片的拍摄条件不可控,必须模拟光照和角度变化 train_transform = transforms.Compose([ transforms.Resize((256, 256)), # 先放大再裁剪,给随机裁剪留空间 transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), # 模拟不同拍摄距离 transforms.RandomHorizontalFlip(p=0.5), # 水平翻转不会改变中草药类别 transforms.RandomRotation(15), # 拍摄角度偏移,15度以内不破坏语义 transforms.ColorJitter(brightness=0.4, contrast=0.4, saturation=0.4), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 验证集只做最小处理,保证评估稳定性 val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这套增强的参数选择不是随手拍的。RandomResizedCrop的scale设为0.7到1.0,是因为中草药图片的主体通常已经占满画面,裁剪比例过低会把有效叶片截掉。RandomRotation设15度而不是30度,是因为像人参这类根茎药材,旋转角度过大后视觉语义会变化——一条根横着放和竖着放,虽然类别没变,但CNN学到的边缘特征会被带偏。ColorJitter的强度控制在0.4,能模拟阴天、室内灯光和手机白平衡差异,再大就会把叶片颜色扭曲到失去药性判断依据。
2.2 标注噪声怎么处理:多数表决与置信度过滤
国赛给的数据集偶尔混入错标图片——有人把相似药材的叶子放进了错误的文件夹。解决方案是先用预训练模型跑一遍,找出“模型高置信度但预测类别与标注不同”的样本,人工复查。具体做法是:先用当前数据训练一个baseline,然后对训练集本身做推理,把每个样本的预测置信度打印出来,按“预测错误且置信度大于0.9”排序,这类样本八成是标注错误或图片本身有歧义。
import torch import torch.nn.functional as F def find_mislabeled(model, dataloader, device): model.eval() suspects = [] with torch.no_grad(): for images, labels, paths in dataloader: images = images.to(device) logits = model(images) probs = F.softmax(logits, dim=1) max_probs, preds = torch.max(probs, dim=1) for i in range(len(paths)): if preds[i] != labels[i] and max_probs[i] > 0.9: suspects.append({ 'path': paths[i], 'true_label': labels[i].item(), 'pred_label': preds[i].item(), 'confidence': max_probs[i].item() }) suspects.sort(key=lambda x: x['confidence'], reverse=True) return suspects这段代码的关键是置信度阈值0.9。如果设太低(比如0.7),会把很多真实难样本误判成标注错误,人工复查工作量巨大;设太高(0.98以上)又基本找不出错误。0.9在多数中草药数据集上是经验平衡点。注意这段代码要求dataloader能返回图片路径——这是容易被忽略的细节,默认的ImageFolder只返回image和label,你需要重写Dataset的__getitem__,把路径一并返回,否则怀疑列表里只有索引,没法快速定位文件。
2.3 数据扩容的两种实用手段:多尺度复制与MixUp中草药变体
中草药类别天然不平衡。常见做法是对样本量少的类别做过采样,但单纯复制会过拟合。我一般会做两种扩容:一是对少数类做更强的几何增强(额外加弹性形变和随机擦除),二是用MixUp——把两张中草药图片按比例混合,标签也按比例混合。对中草药鉴别来说,MixUp的alpha参数不宜过大,我实测alpha=0.2效果最好,因为中草药图像分类对局部纹理极其敏感,混合比例太高会让模型学到“两张图的叠加平均值”,而不是真正的类别特征。
def mixup_batch(images, labels, alpha=0.2): batch_size = images.size(0) lam = torch.distributions.Beta(alpha, alpha).sample((batch_size,)) lam = lam.to(images.device) index = torch.randperm(batch_size).to(images.device) mixed_images = lam.view(-1, 1, 1, 1) * images + (1 - lam).view(-1, 1, 1, 1) * images[index] mixed_labels = lam * labels + (1 - lam) * labels[index] return mixed_images, mixed_labels注意这里标签要用one-hot编码。labels的形状是(batch_size, num_classes),不是标量索引。MixUp带来的收益不在精度的直接提升,而在让模型对“叶片被遮挡、花瓣重叠”这类真实拍摄场景更鲁棒。国赛测试集里经常出现多株药材挤在一张照片里的情况,MixUp训练的模型对这种拥挤场景的适应性明显更好。
3. 模型选型与训练策略:用迁移学习把准确率推到90%以上
3.1 为什么选ResNet而不是Vision Transformer:中草药数据集规模的现实约束
国赛训练集通常是几百到一两千张图,Vision Transformer在这个规模下会严重过拟合。ResNet18和ResNet50是可靠选择。我用一张表对比一下四个常见backbone在类似规模中草药图像分类任务上的表现差异:
| 模型 | 参数量 | 训练集2000张的验证精度(经验值) | 推理速度 | 备注 |
|---|---|---|---|---|
| ResNet18 | 11.2M | ~88% | 快 | 优先尝试,训练快不容易翻车 |
| ResNet50 | 25.6M | ~92% | 中 | 数据量超过3000张时首选 |
| EfficientNet-B4 | 19M | ~91% | 慢 | 效果接近ResNet50但训练时间长 |
| ViT-Tiny | 5.7M | ~78% | 中 | 数据量不够,容易陷入局部最优 |
这个表的结论是:不要追求模型复杂度。中草药鉴别的难点在于类间相似性高,而不是类别数量多。ResNet50的残差结构能有效保留中草药叶脉、根须等细粒度纹理信息,而且ImageNet预训练权重对“植物叶片边缘、表面纹理”这类低层特征有很好的迁移效果。
3.2 冻结与微调的两阶段训练法:先用分类头跑通,再全量微调
常见错误是一上来就全量微调,结果模型先拟合了背景噪声。我的标准操作分两个阶段:第一阶段冻结backbone,只训练最后的全连接分类头,让分类头先学会“预训练特征到中草药类别”的映射;第二阶段解冻所有参数,用很小的学习率微调整体。
import torch import torch.nn as nn from torchvision import models device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) num_classes = 50 # 按实际中草药类别数修改 model.fc = nn.Linear(2048, num_classes) model = model.to(device) # 第一阶段:冻结backbone for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() # 训练若干轮后进入第二阶段 def unfreeze_and_finetune(model, epochs, train_loader, val_loader): # 解冻最后两层,浅层卷积保留预训练特征 for name, param in model.named_parameters(): if 'layer4' in name or 'layer3' in name or 'fc' in name: param.requires_grad = True else: param.requires_grad = False optimizer = torch.optim.SGD([ {'params': model.fc.parameters(), 'lr': 1e-3}, {'params': model.layer4.parameters(), 'lr': 1e-4}, {'params': model.layer3.parameters(), 'lr': 1e-4} ], momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs) # 这里省略标准训练循环,训练时用mixup_batch增强这段代码有两个细节值得注意。第一,第二阶段用SGD而不用Adam——微调阶段SGD+momentum配合余弦退火的收敛精度通常优于Adam,这在迁移学习领域是经验共识。第二,解冻范围到layer3和layer4,浅层(layer1、layer2)的通用纹理特征继续冻结,避免小数据集上低层特征被破坏。如果你发现微调后验证集精度反而下降,说明解冻过深,把layer3也冻结,只微调layer4和fc。
3.3 训练轮数与学习率:30轮还是100轮?看验证损失而不是固定次数
中草药图像分类的训练轮数没有标准答案。我建议用Early Stopping控制轮数,而不是拍脑袋定epoch。具体做法是:每个epoch结束后计算验证集损失,如果连续5个epoch验证损失不下降,就回滚到历史最优模型并降低学习率(ReduceLROnPlateau)。对于2000张左右的训练集,我一般不会让训练超过50轮,因为中草药图像的类内差异还没大到需要上百轮才能收敛。
一个我反复强调的细节:保存模型时要同时保存“验证集精度最高的权重”和“验证集损失最低的权重”。这两个往往不是同一个epoch——精度最高的模型可能过拟合了测试集分布,损失最低的模型在真实场景泛化性更好。国赛的评判不完全按测试集精度来,论文和结果的可解释性也占分,所以提交时我建议用验证损失最低的权重,并在论文中说明这一选择。
4. 测试阶段的验证手法:混淆矩阵、置信度阈值与错例分析
4.1 用混淆矩阵找出中草药里的“易混淆对”
训练结束后,很多人只看整体准确率就完事了,这在中草药鉴别题里是致命的。金银花和山银花的错误预测会同时出现在混淆矩阵里,你不做分析就不知道模型到底在哪些类上靠运气得分。我每次都会生成混淆矩阵,并专门去查error rate最高的前10对类别:
import numpy as np import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix, classification_report def evaluate_confusion(model, val_loader, device, class_names): model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for images, labels in val_loader: images = images.to(device) logits = model(images) preds = torch.argmax(logits, dim=1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) # 找出错误率最高的类别对 cm_norm = cm / (cm.sum(axis=1, keepdims=True) + 1e-8) np.fill_diagonal(cm_norm, 0) pairs = [] for i in range(len(class_names)): for j in range(len(class_names)): if i != j and cm_norm[i][j] > 0.05: pairs.append((class_names[i], class_names[j], cm_norm[i][j])) pairs.sort(key=lambda x: x[2], reverse=True) return cm, pairs这段代码里cm_norm按行归一化,表示“真实类别为i时,被预测为j的比例”,比直接看混淆矩阵的原始数值更直观,因为不同类别样本量不同。查出的易混淆对通常指向两类问题:一是视觉外观确实相似(如陈皮与青皮),二是训练样本中某个类别数量太少导致分类边界偏移。前者需要采集更多该类的图片或做针对性增强,后者只需要补数据,模型层面无法解决。
4.2 置信度阈值怎么设:从“选最像的”到“敢于拒绝”
国赛测试集里可能存在训练集中完全没有的中草药图片,或者图片质量差到人眼都辨不清。此时模型会在所有类别上输出低置信度。我给系统加了一个“拒识规则”:当最高softmax概率低于阈值时,标记为“unrecognized”,而不是强行给出一个类别。这个阈值我建议通过验证集确定——把验证集样本按置信度从高到低排序,找一个点,使这个点以上的样本准确率能达到98%以上。
def predict_with_threshold(model, image, device, threshold=0.85): model.eval() logits = model(image.unsqueeze(0).to(device)) probs = torch.softmax(logits, dim=1) max_prob, pred = torch.max(probs, dim=1) if max_prob.item() < threshold: return -1, max_prob.item() # -1 表示拒识 return pred.item(), max_prob.item()阈值0.85不是拍脑袋定的。我拿验证集跑过实验,置信度在0.8到0.9之间的样本,错误率显著高于0.9以上的样本。原因是中草药图像的类间距离小,模型在“拿不准”时给出的概率分布比较平坦,最高值可能只有0.75左右。如果你把阈值设到0.95,拒识率会飙升到20%以上,测试集得分反而下降。正确做法是画一条“准确率-覆盖率曲线”,选定一个业务上能接受的拒识率(比如5%),再看对应的置信度阈值是多少。
4.3 错例迭代:把验证集错题加入训练集重训
这是一个非常朴素但极其有效的迭代流程。我把验证集里预测错误的图片全部翻出来,先人工确认是否为标注错误(很多情况下是标注错误),然后把确认无误的错题以更高的权重加入训练集,重新训练一轮。这个操作简单,但绝大多数参赛队伍没有做——他们只看到准确率92%,忽略了那8%的错误里藏着提升空间。
5. 获奖作品常见的四类翻车现场与避坑记录
5.1 过拟合验证集:反复用同一验证集调参,精度虚高
现象:模型在本地验证集上准确率从90%刷到了96%,提交后官方测试集成绩反而下降。
原因:验证集被反复用于人工调参,模型和验证集之间产生了隐式的信息泄露。这不是数据泄漏,而是“人为调参泄漏”——你看到验证集上金银花和山银花容易混淆,于是专门做了针对性的数据增强,本质上是把验证集的分布特征写进了训练策略。
解决:交叉验证。把训练集分成5折,每一折独立训练并评估,最后取5个模型的投票结果作为最终预测。如果时间不够,至少把数据划分成train/val/test三份,test只允许跑不超过一次推理。
5.2 类别不平衡导致模型偏好:数量多的类别准确率虚高
现象:训练集中枸杞样本300张,西红花样本50张,模型对枸杞的召回率95%,对西红花的召回率只有60%。
原因:CrossEntropyLoss在类别分布不均衡时,模型偏向于学习样本多的类别,因为把西红花预测成枸杞不会显著增加loss。
解决:WeightedRandomSampler按类别样本数的倒数加权采样,让每个batch里每类样本大致均匀。另外可以给loss加类别权重:class_weights = torch.tensor([1.0 / freq for freq in class_freqs]),传给CrossEntropyLoss的weight参数。
5.3 相似药材用肉眼都难分,强训模型导致严重过拟合
现象:模型在训练集上准确率99%,验证集只有88%,差了11个百分点。
原因:中草药中有大量相似类(很多药材本身就是同科同属),模型在训练时记住了训练图的具体纹理,一旦测试图的拍摄角度或光照变化,立即失效。
解决:对相似类做数据增强是杀鸡取卵的。真正有效的做法是用CutOut或RandomErasing随机遮挡图片的一部分,强迫模型学习全局形状特征而不是局部纹理。我试过在金银花/山银花这对混淆类上,RandomErasing让错误率降低了3个百分点。
提示:不要对相似类做MixUp。MixUp会让两张相似图片混合后更加难以区分,模型学到的是“均值特征”,反而拉大类间距离。
5.4 训练损失下降但验证损失先降后升:学习率没有做衰减
现象:训练loss一路跌到0.1,验证loss降到0.5后开始反弹,最后稳定在0.7。
原因:学习率固定不变导致模型在验证集最优区域来回震荡,无法收敛到平坦最优点。
解决:使用余弦退火学习率调度,或者StepLR每10个epoch降为原来的0.1倍。我用余弦退火比较多,因为它不引入新的超参数,T_max设成总epoch数即可。验证集上如果发现loss回升,直接把学习率乘0.3手动降低,这是最接地气的后悔药。
6. 把92%推到96%以上的三条进阶路径
6.1 多尺度推理:同一张图三个尺寸,投票决定最终类别
中草药图片里既有整株全貌(需要看茎叶整体形态),也有局部特写(需要看叶片纹理和根须细节)。在推理时,我把同一张图resize到224、256、288三个尺寸分别输入模型,取三次softmax概率的平均值作为最终预测。多尺度带来的提升通常在1到2个百分点,代价是推理时间变为原来的三倍,但国赛对推理时间的要求并不严格,值得做。
def multi_scale_predict(model, image, device, scales=[224, 256, 288]): model.eval() probs_sum = None for s in scales: transformed = image.resize((s, s)) tensor = transforms.ToTensor()(transformed).unsqueeze(0).to(device) normalized = tensor * 0.8 + 0.2 # 简化示意,实际需要按均值/标准差计算 with torch.no_grad(): logits = model(normalized) probs = torch.softmax(logits, dim=1).cpu() probs_sum = probs if probs_sum is None else probs_sum + probs final_probs = probs_sum / len(scales) return torch.argmax(final_probs, dim=1).item(), torch.max(final_probs).item()6.2 SE注意力模块:给模型一个“看全局再回细节”的通道
中草药鉴别的关键特征是叶片局部纹理和根茎形状,但模型在深层特征图上已经丢失了部分细节信息。SE模块(Squeeze-and-Excitation)让模型动态调整通道权重:先用全局平均池化收集每条通道的统计量,再用两个全连接层生成通道权重,对特征图做加权。这个模块插入ResNet的每个Block之前,能提升1到1.5个点的准确率,参数增加量可以忽略不计。
6.3 伪标签自训练:用未标注的中草药图片做半监督学习
国赛偶尔会提供一些无标注图片用于算法设计题。如果你手里没有无标注数据,也可以从训练集里划分一部分出来做半监督,测试集上效果是一样的。做法是先用有标注数据训练一个强模型,然后对无标注图片做预测,把置信度高于0.97的预测结果当作伪标签加入训练集,重新训练一轮。这个自我训练的过程可以迭代两三轮,每轮能带来0.5到1个点的提升。但要注意,伪标签加入的权重太高会让模型对初期错误产生依赖,我建议伪标签样本的loss权重设为正常样本的0.5倍。
这三条路径不是互斥的,可以叠加使用。我的最终提交方案是:ResNet50骨干 + 多尺度推理 + SE注意力 + 伪标签两轮迭代。一路走下来,最深刻的教训是:中草药鉴别的瓶颈从不在模型结构,而在你对数据中“哪些类长得像、哪张图标错了、哪些样本置信度低但实际正确”这些细节的把控。每次翻车几乎都是因为跳过了某个数据层面的排查。希望这些记录能帮你在同样的坑前刹住车。
本文还有配套的精品资源,点击获取