简介:2021年全国大学生数学建模竞赛E题“中草药鉴别”一等奖获奖作品,面向数学建模参赛者、机器学习初学者及中草药光谱分析研究者。作品系统展示了从光谱数据可视化、改进K-means聚类到BP神经网络建模的完整实战思路,覆盖红外光谱特征分析、产地鉴别、多光谱综合判断与缺失数据补全等关键问题,既有模型原理讲解,也有Matlab与Python实现要点,可作为赛题复现与算法选型的直接参考。资源包为1个docx格式论文文档,大小2.27MB,结构清晰、公式完整,含摘要、问题重述、模型假设、符号说明、模型建立与求解等完整章节,适合边读边对照练习。目前已有1681人学习下载,适合希望提升建模实战能力、快速理解获奖论文框架与实现细节的读者。
1. 2021年国赛E题中草药鉴别:为什么这道题成了当年的“分水岭”
2021年全国大学生数学建模竞赛E题中草药鉴别,表面上是给一堆中药材图像做分类,实际上考的是数据工程和模型稳健性的组合拳。当年这道题把很多队伍“训练集精度拉满、验证集翻车”的毛病暴露得干干净净,也正因如此,它成了不少参赛队伍从省奖走向国奖的分水岭。这篇文章写给第一次碰图像类建模题的新手,也写给能跑通模型但没拿到理想奖项的熟手。我会把从数据预处理、模型选型、参数调优到论文图表组织的整套流程拆开讲,照着做就能复现一个稳定上分的中草药识别基线。
2. 从图像到数据:E题中草药鉴别的预处理与数据增强
2.1 先看数据再谈模型:显微图像的三个特征
很多队伍拿到E题第一反应就是找预训练模型开跑,这恰恰是本末倒置。中草药显微图像和我们平时做猫狗分类用的自然图像不一样,它有三个特征直接影响后面所有环节。
第一个特征是类间相似度高。不同药材的组织碎片在低倍镜下可能几乎一模一样,区别往往只在某些导管、纤维或晶体形态上,这种“相似类”会让模型学到一大堆噪声特征。第二个特征是背景干扰明显。部分图像上带着标签条、盖玻片边缘甚至气泡,如果直接resize送进网络,模型会分一部分精力去学习这些背景伪影。第三个特征是批次差异大。同一种药材在不同制片批次下的颜色、亮度和杂质分布完全不同,训练集和测试集如果来自不同批次,模型很容易翻车。
我一般会花至少一小时做数据体检而不是直接建模。第一步是写一个脚本把每张图像的尺寸、通道数、平均亮度全部打出来,再随机抽九张图拼成一个网格可视化,肉眼确认有没有标注信息混在图片里。这一步能提前暴露两个问题:图片尺寸不统一,以及某些类别样本严重偏少。很多数学建模竞赛优秀论文里都会放一张“数据探索”图,评委很吃这一套,因为它说明你是按工程问题在做,不是套模板。
2.2 针对性数据增强:把“相似类”撑开的几种做法
明确了数据特征之后,预处理的目标就很清晰:消除背景干扰、统一输入分布、用增强手段放大类别间差异。先看一个去背景的预处理函数,这里用OpenCV实现。
import cv2 import numpy as np def load_and_preprocess(img_path, target_size=(224, 224)): # 读取图像并统一为RGB img = cv2.imread(img_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 转灰度后做阈值分割,去掉白色背景 gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) _, mask = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV) # 闭运算去掉细小孔洞,避免背景边框残留 kernel = np.ones((5, 5), np.uint8) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 按mask包围盒裁剪主体区域后缩放到统一尺寸 x, y, w, h = cv2.boundingRect(mask) crop = img[y:y + h, x:x + w] resized = cv2.resize(crop, target_size, interpolation=cv2.INTER_AREA) return resized这个函数的核心是阈值分割和包围盒裁剪两步。阈值200是一个经验值,如果背景偏灰就降到180,如果药材本身有白色组织块,THRESH_BINARY_INV反而会把白斑当成背景抠掉,所以务必先可视化一两张mask再批量跑。resize时用INTER_AREA而不是默认的双线性插值,因为显微图像里纹理细节比较多,INTER_AREA对缩小图像更保真。
去背景做完之后,再接数据增强。E题的训练数据量通常不大,而中草药类间差异又很细,增强参数调得太猛会把纹理细节抹掉,调得太轻又学不到不变性。我常用的是一套保守配置:
from torchvision import transforms train_transforms = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomApply( [transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2)], p=0.8 ), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这里最容易出问题的是RandomResizedCrop的scale参数。scale=(0.7, 1.0)表示裁剪区域占原图的70%到100%,低于0.7会把显微结构里的小导管裁掉,模拟的不是“换个视野”而是“换了个目标”。ColorJitter配置对应的是染色批次差异,亮度0.3、对比度0.3足够覆盖常见批次波动,调太大会让模型把颜色当噪声。Normalize的mean和std直接用ImageNet统计值,这是迁移学习的标准做法,不要自己算,自己算反而会和预训练权重的分布不一致。
2.3 先做一个数据体检脚本:尺寸、亮度与类别分布检查
模型是黑匣子,数据不是。跑模型之前,先用下面这个脚本把训练集的底细摸清:
import glob import os import numpy as np from PIL import Image train_dir = "./data/train" class_names = sorted(os.listdir(train_dir)) for cls in class_names: paths = glob.glob(os.path.join(train_dir, cls, "*.jpg")) if not paths: # 有的数据集是tif或png,先检查后缀再改glob paths = glob.glob(os.path.join(train_dir, cls, "*.tif")) sizes, brightness = [], [] for p in paths[:200]: # 每类抽200张体检,省时间 im = Image.open(p).convert("RGB") sizes.append(im.size) brightness.append(np.array(im).mean()) print(cls, "数量:", len(paths), "尺寸范围:", (min(s[0] for s in sizes), min(s[1] for s in sizes)), "->", (max(s[0] for s in sizes), max(s[1] for s in sizes)), "平均亮度:", round(np.mean(brightness), 1))这个脚本五分钟内能帮你回答三个问题。第一是类别数量分布,如果某类只有几十张而另一类有上千张,后面必须处理类别不平衡。第二是尺寸差异,如果有的图是416x416有的是224x224,直接resize到224会丢失一部分显微细节,此时可以考虑先把长边统一缩放到256再做中心裁剪。第三是亮度差异,如果同类药材的亮度标准差超过40,说明染色批次差异很大,可以考虑对每个样本做标准化或使用Stain Normalization这类医学图像专用预处理。参数上,抽样200张是常规折中,样本本身不到200的就全量检查。
3. 模型选型与基准线:用迁移学习搭一个能跑通的中草药识别模型
3.1 为什么选ResNet和EfficientNet:细粒度分类的选型逻辑
E题这种中草药识别本质上属于细粒度图像分类,类间差异小、类内差异大。选模型时我只看三个指标:参数量、显存占用、以及预训练权重的通用性。下面这张表是常见选择:
| 模型 | 参数量 | 显存占用(batch=32, 输入224) | 适用场景 |
|---|---|---|---|
| ResNet18 | 11.7M | 约2.1GB | 快速基线,验证流程 |
| ResNet50 | 25.6M | 约3.7GB | 精度和速度均衡,首选 |
| EfficientNet-B0 | 5.3M | 约2.4GB | 显存受限时优选 |
| EfficientNet-B3 | 12.3M | 约3.8GB | 精度优先,显卡够用 |
| Swin-T(可选) | 28M | 约4.5GB | 数据量充足时再考虑 |
2021年E题的通用做法是ResNet50加EfficientNet-B0双线并跑。ResNet50是迁移学习里被验证最多的骨架,任何异常都能在网上找到解决方案;EfficientNet-B0则在相同显存下给了更高精度上限。Transformer类模型不建议在数据量不明确时直接上,E题这种几百到几千张规模的数据集,ViT容易过拟合,Swin-T需要更多调参经验才能压过CNN。如果显卡显存只有6GB,优先EfficientNet-B0,它是性价比最高的兜底。
选型逻辑还要考虑一个实际问题:写论文时的说服力。数学建模优秀论文里,评阅老师关注的是“你选的模型是否匹配问题特点”。E题的难点是类间相似,因此笔头上要强调ResNet50的残差结构能保留底层纹理信息,EfficientNet的复合缩放能平衡分辨率、深度和宽度。哪怕最后两个模型精度差不多,能在论文里把选型理由写清楚,本身就是加分项。
3.2 最小可复现的训练脚本:换掉分类头,冻结前几层
选完模型,接下来是搭建最小训练闭环。以下代码以ResNet50为例,包含加载预训练权重、替换分类头、冻结底层三个关键动作。
import torch import torchvision.models as models # 加载ImageNet预训练权重,替换分类头 model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) num_classes = len(class_names) # 根据训练集目录动态获取 model.fc = torch.nn.Linear(model.fc.in_features, num_classes) # 冻结layer2及以下,只微调layer3/layer4和分类头 for name, param in model.named_parameters(): if name.startswith("layer3") or name.startswith("layer4") or name.startswith("fc"): param.requires_grad = True else: param.requires_grad = False # 训练循环:只更新requires_grad为True的参数 criterion = torch.nn.CrossEntropyLoss() optimizer = torch.optim.SGD( filter(lambda p: p.requires_grad, model.parameters()), lr=0.01, momentum=0.9 ) for epoch in range(30): model.train() for images, labels in train_loader: images, labels = images.cuda(), labels.cuda() optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() # 每个epoch结束后做一次验证,单独保存val_loss最低的checkpoint这段代码有三个容易忽略的细节。第一个是filter(lambda p: p.requires_grad, model.parameters()),如果不做这个过滤,冻结的层仍然会在反向传播时更新,冻结形同虚设。第二个是ResNet50预训练权重版本选择,IMAGENET1K_V2比V1精度更高,而且接口写法在torchvision 0.13以上才稳定,如果你的环境报错找不到weights参数,就说明torchvision版本太老。第三个是num_classes要用len(class_names)动态获取,别手写死,E题某些获奖作品里就出现过因为类别数写错导致训练白跑的事故。
对于第一次跑E题的队伍,我的建议是先用10%的数据跑通整个流程,验证集只看损失曲线和精度,确认没问题再上全量数据。这个“快速验证”习惯能帮你过滤掉八成低级错误:路径写错、标签错位、数据加载OOM。等流程稳定后,再按第4章的参数调优策略去上分。
4. 训练参数与调优:中草药识别上分的四个关键旋钮
4.1 学习率策略:线性预热加余弦退火
固定学习率跑到底是最常见的翻车方式。E题的训练数据量不大,学习率稍微大一点就会在早期震荡,小一点又收敛太慢。我常用的配置是线性预热加余弦退火,用LambdaLR实现:
import torch from torch.optim.lr_scheduler import LambdaLR epochs = 30 warmup_epochs = 3 base_lr = 0.01 def lr_lambda(epoch): if epoch < warmup_epochs: # 线性预热:从0.0001逐步爬到base_lr return (epoch + 1) / warmup_epochs else: # 余弦退火:从base_lr平滑衰减到0.001 progress = (epoch - warmup_epochs) / (epochs - warmup_epochs) return 0.5 * (1 + torch.cos(torch.tensor(progress * 3.14159))) scheduler = LambdaLR(optimizer, lr_lambda=lr_lambda)为什么要预热?因为刚换上的分类头是随机初始化的,梯度噪声大,如果一开始就用0.01的学习率,底层预训练权重的梯度会被污染。3个epoch的warmup足够让分类头学会基本的类别分布。余弦退火则解决了后期学习率衰减的问题:E题这种小数据训练到15个epoch之后,loss曲线会进入平台期,固定学习率会在平台期来回震荡,余弦退火能让权重稳定落入更平滑的极小值。
参数上,微调ResNet50时base_lr取0.01是SGD的常见值,如果换Adam,base_lr要降到0.0001左右。epochs根据早停情况灵活调整,30是初始值,如果验证集还在涨就继续跑,别机械地停在30。
4.2 类别不平衡:用WeightedRandomSampler还是Focal Loss
E题的数据集大概率存在类别不平衡,某个药材的样本只有几十张,另一个有上千张。处理类别不平衡有两个常用方案:采样端和损失函数端。先看采样端的做法,用WeightedRandomSampler:
from torch.utils.data import WeightedRandomSampler # 假设train_labels是每个样本的类别索引 class_counts = torch.bincount(torch.tensor(train_labels)).float() class_weights = 1.0 / class_counts # 每个样本的采样权重 = 其类别权重的倒数 sample_weights = class_weights[train_labels] sampler = WeightedRandomSampler( sample_weights, num_samples=len(train_labels), replacement=True ) train_loader = DataLoader(train_set, batch_size=32, sampler=sampler)replacement=True表示允许同一个样本在一个epoch内被重复采样,这样少数类每个epoch都能被看到多次。如果replacement=False,少数类仍然只能被采样一次,等于没加权重。另一个常见替代是给CrossEntropyLoss传weight参数:
ce_weight = class_weights / class_weights.mean() # 归一化 criterion = torch.nn.CrossEntropyLoss(weight=ce_weight.cuda())这个方案的好处是零额外代码,坏处是少数类的梯度被放大后容易过拟合。我的经验是:先看训练集的category分布,多数类和少数类相差超过10倍时用WeightedRandomSampler;相差在5倍以内时直接给CrossEntropyLoss加weight就行。Focal Loss适合“难样本集中的场景”,但E题的主要矛盾是样本分布而不是难易程度,所以不优先推荐,调gamma反而增加工作量。
4.3 交叉验证与留出法:验证集怎么切才不被评阅老师质疑
数学建模论文里最容易被评委挑刺的就是验证集划分。很多队伍直接random split,导致同一个药材的不同视野图像同时出现在训练集和验证集里,这叫跨样本泄露。E题的数据组织通常是一个药材多个视野,正确做法是按药材分组划分:
from sklearn.model_selection import StratifiedKFold # 假设group_labels表示每个样本所属的药材组 # 先按组聚合,再对组做分层划分 unique_groups = np.unique(group_labels) group_classes = [group_to_class[g] for g in unique_groups] skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) for fold, (train_g_idx, val_g_idx) in enumerate(skf.split(unique_groups, group_classes)): train_groups = unique_groups[train_g_idx] val_groups = unique_groups[val_g_idx] # 再根据group到样本的映射生成train/val索引这里的核心思路是“分层对象是组而不是样本”。如果按样本做StratifiedKFold,同一个药材的多个视野会被拆到不同折里,模型在训练时已经见过同一药材的近似视角,验证集精度会虚高。很多获奖作品都会在论文里写明“按拍摄对象分组划分”,评阅老师一眼就能看出作者理解数据结构的深度。
顺带提醒,数据标准化也要在划分之后做。先在整个数据集上算均值和方差再做划分,等于验证集的信息提前泄露给了训练集,这在严格意义上也属于数据泄露。如果要用数据集统计值做标准化,先划分训练集和验证集,只统计训练集的部分。
5. 中草药鉴别的常见坑:五条实测排错记录
这一章全是血泪经验。以下每条我都踩过或帮别人排查过,按“现象-原因-解决”写,方便你对照排查。
5.1 数据泄露:验证集里混进了增强样本
现象:训练精度涨到99%,验证集精度却卡在60%出头,而且无论怎么调参数都上不去。
原因:最常见的是把数据增强写在了全量数据上,导致验证集和训练集来自同一个增强流程;另一种情况是数据集目录结构混乱,训练集和验证集文件夹有重叠。还有一类隐蔽的:做归一化时用了全量数据的均值和标准差。
解决:训练和验证的transforms必须分开定义,train用完整增强流程,val只做Resize和Normalize。归一化统计量只从训练集算,然后应用到验证集。更彻底的办法是每次加载数据前打印train和val的文件路径集合,检查交集是否为空。看似简单的检查,拦住了大部分人。
5.2 类别不平衡:准确率很高但F1很难看
现象:上交的论文里写了总体准确率90%,但评委追问每一类的识别率时,才发现某个药材类别的recall只有20%。
原因:交叉熵损失被多数类主导,模型把所有不确定样本都判成多数类就能拿到高准确率。E题这种类别数量可能有十几种的题,少数类被牺牲是常态。
解决:先打印混淆矩阵,看每一类的precision和recall,而不是只看总体acc。然后按4.2节的方法做加权采样或加权损失。特别注意:写完加权之后,验证时不能只看总体准确率,要看各类别的平均F1。数学建模优秀论文里,结果表通常按“每一类的precision-recall-F1”来展示,这就是评委认可的表达方式。
5.3 迁移学习的“玄学”:同一种子结果差两个点
现象:固定了random_state,两次训练结果还能差1到2个百分点,导致复现失败。
原因:PyTorch的某些CUDA卷积操作是非确定性的,DataLoader的多线程数据读取顺序每次也不同;更隐蔽的是,如果在fp16混合精度模式下训练,累加顺序不同会导致微小但不可忽略的数值差异。
解决:在训练前设置torch.backends.cudnn.deterministic = True和torch.use_deterministic_algorithms(True),并把DataLoader的worker数设为0。注意这个设置会让训练慢20%左右,所以只在提交最终结果前做一次“确定性复跑”,平时调参不用开。如果是混合精度训练引入了不确定,就把mixed_precision关掉再复跑一次。
5.4 显存不够:Batch Size一调大就OOM
现象:Batch Size设成64直接CUDA out of memory,换EfficientNet-B0还是不够。
原因:E题图片虽然被resize到224,但中间特征图的显存占用和batch大小线性相关;类别数多时分类头也会占用额外显存。
解决:先用batch_size=16跑通,再用梯度累积模拟大batch。梯度累积的写法是:optimizer.zero_grad(),loss.backward(),每隔accum_steps步才执行optimizer.step()。accum_steps=4时等效batch_size=64,但显存占用只有1/4。如果还OOM,就把输入尺寸降到192,中草药细粒度特征在192和224之间的精度差距通常小于0.5个点,显存却能省下不少。如果这三步都做了还OOM,说明显卡实在太小,果断换EfficientNet-B0并把Resize降到160。
5.5 训练损失持续下降但验证精度不涨:过拟合信号
现象:第10个epoch开始,训练loss一路向下,验证loss在第15轮见底后开始回升,验证精度同步下降。
原因:E题数据量小,模型容量大,增强强度又不够,网络开始记住训练集中特定的组织碎片和杂质位置,也就是死记硬背。
解决:先在分类头前加Dropout,常见做法是model.fc = nn.Sequential(nn.Dropout(0.5), nn.Linear(in_features, num_classes))。然后调强数据增强,把RandomResizedCrop的scale下限从0.7降到0.6,但不要低于0.5。最后加早停,监控验证loss,连续10个epoch没有改善就保存验证loss最小的checkpoint并终止训练。注意早停保存的是val_loss最小那一步的权重,不是最后一次epoch的权重,这个坑让很多人白训了一整晚。
6. 获奖论文的最后一公里:用混淆矩阵和TTA把结果做实
6.1 测试时增强(TTA):稳定边界样本的常用技巧
模型训练完成后,预测阶段还有一个免费提分操作:测试时增强(TTA,Test-Time Augmentation)。基本原理是同一张测试图预测多次,每次用不同的增强方式,最后把softmax概率取平均再取argmax。这样能纠正那些“差一点点就对了”的边界样本。
def predict_tta(model, img, n_aug=4): model.eval() all_probs = [] with torch.no_grad(): for _ in range(n_aug): # 每次用不同的随机增强,val_transforms里不包含RandomCrop aug = val_transforms(img) out = torch.softmax(model(aug.unsqueeze(0)), dim=1) all_probs.append(out) avg_prob = torch.stack(all_probs).mean(dim=0) return avg_prob.argmax(dim=1).item()这里最关键的是val_transforms里不能有RandomResizedCrop这类位置变化大的操作,否则多次预测对应的根本不是同一个视野,平均之后把特征抹平了。常规配置是RandomHorizontalFlip加RandomVerticalFlip,n_aug取4或8,多了收益递减。TTA不会提高模型上限,但能让结果更稳定,这在写论文时可以直接说“通过测试时增强降低了单次预测的随机波动”,是评阅老师看得懂的表述。
6.2 混淆矩阵与错分样本分析:把论文写“实”
最后一步是把结果“说圆”。很多数学建模优秀论文的败笔在于只放一张精确率图,没有任何错误分析。而E题这种图像分类题,评阅老师最想看到的恰恰是你对错分样本的解释。
用sklearn画标准化混淆矩阵,重点看对角线之外哪些类别互相混淆。比如“当归”被错分成“独活”,就抽出三张错分样本放在论文里,用一句话说清原因:两者韧皮部形态在低倍镜下相似,需要更高倍率特征才能区分。有时候还可以用Grad-CAM做一张热力图,说明模型到底是看了组织块还是背景杂质。这些素材不需要额外实验,只是把训练过程中的验证集预测结果存下来即可。
我现在做中草药类项目的习惯是:不管题目要求什么,先做一个最小闭环,再全量训练,最后永远配一张混淆矩阵和一张TTA对比表。这个习惯帮我躲过了不少返工,也让我省的“差一点就解释不清楚”变成“为什么咱们的结果站得住”。如果你正在准备国赛或后续的建模比赛,这套流程可以直接平移,祝你好运,希望帮到你。
本文还有配套的精品资源,点击获取