简介:一套基于深度学习的中草药识别项目源码包,面向计算机相关专业毕业设计、课程设计以及图像识别入门研究者,聚焦运用图像识别技术辅助中草药鉴定的应用场景。项目以残差网络ResNet结合CBAM注意力机制为核心,面向10种中草药图像分类任务,并使用旋转、缩放、裁剪等数据增强策略提升模型泛化能力;整体实验基于PyTorch框架在Windows 10环境下完成,涵盖数据预处理、模型训练与评估全流程。压缩包为198.37MB,共2000个文件,含有1976张jpg样本图片、12个Python训练与评估脚本,以及Markdown说明文档、HTML训练报告、JSON配置等辅助文件,有助于梳理从数据组织、模型构建到结果分析的完整链路。已有207人学习/下载;附带的说明文档对算法实现步骤、训练过程、超参数调整及性能评估作了细致记录,为复现实验结果、搭建同类中草药识别系统或完成毕业设计提供了可直接参考的技术路线与代码基础。项目内附源码、图像样本与训练报告,便于对照源码与实验输出进行学习,也适合在其基础上扩展其他植物识别任务。
1. 中草药识别不是通用图像分类:先想清楚你要认什么
一位做药材仓储的朋友提了个很实际的需求:巡检员随手拍一张进货药材的照片,系统能立刻给出品种和置信度,把批次信息自动归档。听起来就是个图像分类问题,但真上手做才发现,中草药识别和常见的猫狗分类完全是两种难度。通用分类只要区分“这是猫还是狗”,中草药识别的难点在于细粒度——薄荷和留兰香长得几乎一样,金银花在不同炮制阶段颜色差异巨大,同一味药材的根、茎、叶、花拍出来是四张完全不同的图。这一步没想清楚,后面做的所有模型都只是在浪费时间。
这个标题指向的是一条完整的技术链路:从数据采集与清洗、细粒度分类模型的训练方案、到把模型导出为可部署的服务,最后再到置信度校准和坑位排查。它适合三类人:正在做药材质检或库存管理系统的开发者、做中草药科普类App或教学演示的团队、以及想在细粒度图像分类方向上找可落地课题的学生。本文给的不是一个跑完就完的玩具Demo,而是一套能迁移到真实项目里的工程思路。你会发现,中草药识别真正卡人的地方不在模型结构,而在数据边界、增强策略和部署时的那些细节。
2. 先给任务定边界:中草药数据采集、清洗与训练集划分
2.1 识别粒度决定数据规模:物种级 vs 部位级 vs 炮制品级
动手写代码之前,第一件事是把“识别目标”定义到可执行的粒度。我见过太多项目栽在不做粒度拆分上:采集数据时按“薄荷”一个类别收图,结果训练集里的薄荷叶、薄荷全草、干燥饮片混在一起,模型学到的根本不是品种特征,而是“绿色的是叶、棕色的是根”这种表面规律。这里有一个原则:一个类别标签,必须对应一种稳定可复现的拍摄对象形态。
常见的粒度设计有三种。物种级:只要识别出“这是薄荷”,不区分鲜品还是干品,适合科普类应用,数据最容易收集,但模型精度上限低。部位级:区分“薄荷叶”“薄荷茎”“薄荷根”,适合质检分拣,因为不同部位的有效成分含量差别很大。炮制品级:区分“生薄荷”和“炒薄荷”,这是最难的,需要专门采集炮制前后的成对样本。我的建议是:第一个版本先做部位级,把每个类别限定在单一形态上,跑通链路后再扩展。
粒度定完,紧接着要定类别名录。不要按药材商品名建类,要按植物学物种加形态部位组合建类。比如“菊花(头状花序)”“菊花(干燥饮片)”是两个类,而不是一个“菊花”类。分类数控制在30到60类起步比较合理,类别太少没有工程价值,类别太多标注成本和模型调试难度会指数上升。
2.2 用脚本完成数据检查与训练/验证集划分
数据收回来之后,第一步不是训练,而是检查。我习惯先跑一个完整性检查脚本,把三类问题暴露出来:损坏图片、空文件、重复图片。重复图片是中草药数据里最常见的坑——同一张图被不同渠道转存多次,如果不查重,验证集会跟训练集高度相似,模型的精度虚高得离谱,上线后立刻被打回原形。
下面这个脚本处理三个事:检查图片是否可正常解码、统计每个类别的样本数、计算感知哈希去重并打印近似重复的图片对。在实际项目X里,我用它一次性筛掉了近两千张重复图。
import os from PIL import Image import imagehash from collections import defaultdict # 配置数据根目录,结构为 root/类别名/*.jpg DATA_ROOT = "/path/to/raw_herbs" broken = [] size_record = defaultdict(int) hash_to_paths = defaultdict(list) for cls_name in os.listdir(DATA_ROOT): cls_dir = os.path.join(DATA_ROOT, cls_name) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): fpath = os.path.join(cls_dir, fname) try: with Image.open(fpath) as im: im.verify() # 只校验文件完整性,不加载像素 w, h = im.size # 过滤尺寸过小的图,模型输入224x224时小图没有信息量 if w < 224 or h < 224: broken.append((fpath, "small_size")) continue # 计算感知哈希,用于后续去重 with Image.open(fpath) as im: img_hash = imagehash.phash(im.convert("RGB")) hash_to_paths[str(img_hash)].append(fpath) size_record[(w, h)] += 1 except Exception as e: broken.append((fpath, str(e))) print(f"损坏或过小图片: {len(broken)}") for path, reason in broken[:20]: print(path, reason) print("尺寸分布 Top10:", sorted(size_record.items(), key=lambda x: -x[1])[:10]) dup_groups = [paths for paths in hash_to_paths.values() if len(paths) > 1] print(f"疑似重复组: {len(dup_groups)}")逻辑说明:verify()只检查文件头和解码信息,速度很快,适合全量扫描;感知哈希用imagehash.phash基于离散余弦变换生成,对轻微缩放和压缩不敏感,比直接比对文件MD5实用得多。参数说明:尺寸阈值设成224是因为后续模型输入就是这个分辨率,小于这个值的图要么是缩略图要么是截图,特征不足;重复组打印出来之后,人工确认要保留哪一张,不要自动删除,因为同一组里可能有一张是带标本夹的、一张是纯叶片的,形态信息不同。
去重和坏图清理完之后,再按类别做分层划分。分层的意思是:每个类别内部独立地按比例拆成训练集和验证集,而不是把全量数据打乱后再切。中草药数据的类别样本量天然不均衡,混合打乱再切会导致某些类别在验证集中只剩下几张图,评估结果波动极大。下面的代码在类别维度上做划分,并用随机种子固定结果,保证可复现。
import random import os import shutil random.seed(42) TRAIN_RATIO = 0.85 data_root = "/path/to/cleaned_herbs" out_root = "/path/to/split_herbs" for cls_name in os.listdir(data_root): cls_dir = os.path.join(data_root, cls_name) if not os.path.isdir(cls_dir): continue images = os.listdir(cls_dir) random.shuffle(images) n_train = int(len(images) * TRAIN_RATIO) train_imgs = images[:n_train] val_imgs = images[n_train:] for split, imgs in [("train", train_imgs), ("val", val_imgs)]: dest_dir = os.path.join(out_root, split, cls_name) os.makedirs(dest_dir, exist_ok=True) for img_name in imgs: src = os.path.join(cls_dir, img_name) dst = os.path.join(dest_dir, img_name) shutil.copy2(src, dst) print(f"{cls_name}: train {len(train_imgs)}, val {len(val_imgs)}")逻辑说明:先按类别遍历,在类别内部做洗牌和比例切分,再复制到目标目录。用copy2保留原始文件的元信息,方便追溯。参数说明:TRAIN_RATIO我用0.85,如果总样本量少于每类100张,建议提到0.9,保证验证集每类至少有10张图;随机种子固定为42,方便复现划分结果,避免每次跑脚本验证集都不一样,导致实验对比失真。
2.3 标注一致性的两个常见约定
标注是细粒度分类里最容易被低估的环节。中草药图像经常由多人协作标注,同一张图在不同人手里可能被标成不同类别。我一般的处理方式是做两份约定文档,一份是别名归一表,一份是排除清单。
别名归一表解决的是“一个东西多个名字”的问题。比如“北柴胡”和“柴胡”在多数项目里应归为同一类,但不同来源的数据里名字不一样,必须提前映射到同一个类别ID。排除清单解决的则是“这种东西看起来像草药但不在任务范围内”的问题:杂草、背景树叶、手指、桌面纹理,这些都要单列出来归入一个明确的“background/other”类,而不是任它们污染已有的类别数据。训练脚本里把排除类单独设为一类,类别数加一,推理时如果置信度最高的是排除类,就直接提示“未识别到药材”,这个设计比硬性给出一个错误答案要实用得多。
3. 模型选型与训练:为什么细粒度分类要用预训练微调
3.1 通用分类思路在中草药上效果打折的原因
如果直接把 ResNet 从头开始在几万张中草药图片上训练,结果通常很惨。原因有两个层面。数据层面,中草药数据集一般只有几千到几万张,且类别分布极不均衡,从头训练时低频类别的特征根本学不出来。特征层面,通用分类模型依赖全局纹理和轮廓,而中草药细粒度分类依赖的是局部微纹理——薄荷叶面的腺鳞、金银花花冠的绒毛、陈皮表面的油室,这些特征在整张图里只占几十个像素。全局池化会把它们平均掉。
所以主流的做法是迁移学习:加载在 ImageNet 上预训练好的权重,冻结前几层,只微调后面几层和分类头。预训练模型学到的基础纹理、边缘、颜色构成能力可以复用,我们要训练的是“如何把注意力放到关键局部部位上”。这是中草药识别项目中最可靠、最少踩坑的训练范式。
3.2 训练主流程:加载预训练模型、改分类头、跑微调
下面是一份完整的 PyTorch 训练脚本核心部分,适配中草药细粒度分类任务。backbone 选用预训练卷积网络,分类头改成目标类别数,训练时先冻结 backbone 只训练头部,再解冻后段做全量微调。
import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from torchvision import models, transforms from PIL import Image import os NUM_CLASSES = 62 # 类别数,含 background/other 类 BATCH_SIZE = 32 EPOCHS_FROZEN = 5 # 冻结阶段轮数 EPOCHS_FINETUNE = 15 # 微调阶段轮数 LR_HEAD = 1e-3 LR_BODY = 1e-5 IMG_SIZE = 224 # 训练增强:此处不做过强的几何增强,原因见 3.3 train_transform = transforms.Compose([ transforms.Resize((IMG_SIZE, IMG_SIZE)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=10), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize((IMG_SIZE, IMG_SIZE)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) class HerbDataset(Dataset): def __init__(self, root, transform): self.samples = [] self.classes = sorted(os.listdir(root)) self.cls2idx = {cls: i for i, cls in enumerate(self.classes)} for cls in self.classes: cls_dir = os.path.join(root, cls) for fname in os.listdir(cls_dir): self.samples.append((os.path.join(cls_dir, fname), self.cls2idx[cls])) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] img = Image.open(path).convert("RGB") return self.transform(img), label # 加载预训练权重,替换分类头 model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) in_features = model.fc.in_features model.fc = nn.Linear(in_features, NUM_CLASSES) # 冻结 backbone 前 6 层(layer1 之前),只训练分类头和 layer4 for name, param in model.named_parameters(): if not name.startswith("layer4") and not name.startswith("fc"): param.requires_grad = False device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device) train_ds = HerbDataset("/path/to/split_herbs/train", train_transform) val_ds = HerbDataset("/path/to/split_herbs/val", val_transform) train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=4) # 两个阶段用不同的学习率,先训头再训全身 head_params = [p for name, p in model.named_parameters() if name.startswith("fc")] body_params = [p for name, p in model.named_parameters() if not name.startswith("fc") and p.requires_grad] criterion = nn.CrossEntropyLoss() optimizer_head = torch.optim.Adam(head_params, lr=LR_HEAD) optimizer_body = torch.optim.Adam(body_params, lr=LR_BODY) def run_epoch(model, loader, optimizer, criterion, train=True): model.train(train) total_loss, correct, total = 0.0, 0, 0 for inputs, labels in loader: inputs, labels = inputs.to(device), labels.to(device) if optimizer is not None: optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) if train: loss.backward() optimizer.step() total_loss += loss.item() * inputs.size(0) correct += (outputs.argmax(1) == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total # 阶段一:只训练分类头 for epoch in range(EPOCHS_FROZEN): train_loss, train_acc = run_epoch(model, train_loader, optimizer_head, criterion, train=True) val_loss, val_acc = run_epoch(model, val_loader, None, criterion, train=False) print(f"Frozen Epoch {epoch+1}: train_acc={train_acc:.4f} val_acc={val_acc:.4f}") # 阶段二:解冻 layer4 及分类头做全量微调 for name, param in model.named_parameters(): if name.startswith("layer4") or name.startswith("fc"): param.requires_grad = True for epoch in range(EPOCHS_FINETUNE): train_loss, train_acc = run_epoch(model, train_loader, optimizer_body, criterion, train=True) val_loss, val_acc = run_epoch(model, val_loader, None, criterion, train=False) print(f"Finetune Epoch {epoch+1}: train_acc={train_acc:.4f} val_acc={val_acc:.4f}") torch.save(model.state_dict(), "/path/to/herb_resnet50.pt")逻辑说明:第一阶段冻结backbone,只训练新的分类头,让随机初始化的fc层先收敛到能正确映射特征的空间;第二阶段解冻layer4和fc,用极小的学习率微调,让高层特征逐步适应中草药的细粒度差异。这种两阶段策略比直接全量微调稳定得多,在类别数多、样本不均衡时尤其明显。参数说明:LR_BODY设为1e-5是经验值,比分类头低两个数量级,避免微调时破坏预训练特征的底层结构;EPOCHS_FROZEN=5是因为分类头收敛很快,5轮基本足够;如果训练集超过2万张,EPOCHS_FINETUNE可以加到25轮,但要配合早停,监控验证集精度在连续5轮不上升时保存最优权重。
3.3 面向药材形态的数据增强:从翻转和轻度旋转开始
中草药识别里,数据增强要特别克制。常见的公开代码里那一套随机旋转90度、随机裁剪、随机擦除,放在通用物体分类上没问题,放到中草药细粒度分类上会帮倒忙。原因在于:药材鉴别的关键特征有方向性。叶片腺鳞的排列方向、花瓣的覆盖关系、根茎的纹理走向,旋转90度后这些结构特征就被破坏了,模型学到的会是一个“允许各种角度出现的平均特征”,而不是真实的鉴别特征。
我一般用的增强策略只保留三项。水平翻转:中草药叶片正反面纹理有差异,但水平翻转不破坏上下结构,安全。轻度旋转:角度控制在正负10度以内,模拟拍摄时手机轻微倾斜。颜色扰动:亮度、对比度小幅调整,模拟不同光照环境。不要用随机擦除和Cutout——中草药识别中关键鉴别区域往往只占全图的几个百分点,擦除后可能恰好把鉴别区域抹掉,模型被迫去学背景。
ColorJitter的饱和度扰动参数我控制在0.1以内,不要用默认的0.5。中草药颜色信息在许多类别里是重要的鉴别维度——比如不同批次陈皮的色调差异,过度扰动颜色会把这个维度直接抹平。验证集上的变换只做Resize和归一化,不要加任何随机扰动。
4. 把训练好的模型变成可用的识别服务
4.1 导出 ONNX:固定输入尺寸、去掉训练态算子
训练完成之后,模型不能只停留在.pt文件里。实际部署时,我一般会把 PyTorch 模型导出为 ONNX 格式,再通过 ONNX Runtime 推理。这样做有几个实际收益:不依赖 PyTorch 的 Python 环境,可以部署在 CPU 服务器甚至移动端;推理延迟显著降低;模型文件被冻结成静态计算图,避免了训练态和推理态算子不一致的问题。
导出脚本的关键点有两个。第一,把模型切到eval()模式,并把所有 dropout、batchnorm 的随机行为关掉。第二,固定输入尺寸导出,不要用动态尺寸,ONNX Runtime 对动态尺寸支持不稳定,中草药识别场景里224x224的输入已经足够。导出时指定opset_version,太高的版本在旧版推理引擎上可能报错。
import torch import torch.onnx from torchvision import models import torch.nn as nn NUM_CLASSES = 62 model = models.resnet50(weights=None) in_features = model.fc.in_features model.fc = nn.Linear(in_features, NUM_CLASSES) model.load_state_dict(torch.load("/path/to/herb_resnet50.pt", map_location="cpu")) model.eval() dummy_input = torch.randn(1, 3, 224, 224, device="cpu") torch.onnx.export( model, dummy_input, "/path/to/herb_resnet50.onnx", input_names=["input"], output_names=["logits"], dynamic_axes=None, # 固定尺寸导出 opset_version=15, # ONNX Runtime 稳定支持区间内的版本 do_constant_folding=True ) print("ONNX exported")逻辑说明:load_state_dict加载的是第3章训练保存的权重,注意加载前要把分类头结构重建为相同尺寸。dynamic_axes=None表示所有维度都固定,这个设置让推理引擎可以做更激进的内存复用和算子融合。参数说明:opset_version设15是保守选择,兼容性好;如果你的目标运行环境是最新版本,可以设17,但不需要追新。
4.2 推理脚本:读图、预处理、拿到 Top-K 与置信度
部署端不一定要用 Python。但这里给出一份 Python 版的 ONNX Runtime 推理脚本,方便在服务端快速验证导出的模型是否正常。核心逻辑是:读图、缩放到224、归一化、推理、Softmax、取Top-K。
import onnxruntime as ort import numpy as np from PIL import Image # 类别名列表,顺序与训练时的 classes 排序一致 CLASS_NAMES = ["background/other", "薄荷(叶)", "薄荷(茎)", "..."] # 按实际类别填 session = ort.InferenceSession("/path/to/herb_resnet50.onnx", providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) def preprocess(img: Image.Image, size=224): img = img.convert("RGB") img = img.resize((size, size), Image.BILINEAR) arr = np.asarray(img, dtype=np.float32) / 255.0 mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.float32) arr = (arr - mean) / std arr = arr.transpose(2, 0, 1) # HWC -> CHW return np.expand_dims(arr, axis=0) def predict_topk(img: Image.Image, k=5): x = preprocess(img) logits = session.run(["logits"], {"input": x})[0] # shape (1, NUM_CLASSES) prob = np.exp(logits[0]) / np.exp(logits[0]).sum() top_idx = np.argsort(prob)[::-1][:k] return [(CLASS_NAMES[i], float(prob[i])) for i in top_idx] if __name__ == "__main__": # 命令行快速验证:传图片路径即可 import sys img = Image.open(sys.argv[1]) for name, score in predict_topk(img): print(f"{name}: {score:.4f}")逻辑说明:preprocess函数做的是与训练时验证集完全一致的预处理,这是部署上线后推理结果和离线评测不一致的最常见原因——训练时多做了某一步变换,部署时忘了。这里把预处理从模型外部显式实现出来,方便检查可复现性。参数说明:推理时输入(1, 3, 224, 224),对应导出时的dummy_input,任何尺寸不一致都会在session.run时报错;providers的列表顺序表示优先用GPU,没有GPU会自动回退CPU。
4.3 工程目录与模型更新的约定
中草药识别项目维护周期长,药材炮制工艺改动、拍摄设备更新、新批次样本补充都会要求模型迭代。我建议从第一天就按下面的目录组织工程。
herb_engine/ ├── configs/ # 类别名录、归一化表、排除清单 │ └── class_mapping.json ├── data/ # 原始数据与划分后的数据 ├── scripts/ │ ├── check_data.py # 第2章的检查脚本 │ ├── split_data.py # 第2章的划分脚本 │ ├── train.py # 训练脚本 │ └── export_onnx.py # 导出脚本 ├── deploy/ │ ├── herb_model_v3.onnx # 带版本号的模型文件 │ └── inference.py # 推理脚本 └── eval/ └── confusion_report.py # 定期跑混淆矩阵,观察类别混淆对模型文件名必须带版本号,我习惯在训练脚本里把每个epoch的验证准确率写进文件名,例如herb_resnet50_acc0.9134.onnx。不要覆盖旧模型,保留最近三个版本,方便线上出问题时快速回退。这个回退机制在模型迭代中相当于后悔药,尤其是当你加上新类别的样本后发现旧类别精度反而下降了,回退到上一版是最高效的止损手段。
5. 中草药识别最常见的坑:从数据翻车到部署玄学
5.1 训练集里混入大量“商品图”,上线后拍实物就废
现象:离线验证集准确率92%,上线跑了一周,真实拍摄图片的准确率只有74%。分析日志发现,模型在真实场景里主要把带背景、带阴影、带手部遮挡的图片判错。
原因:采集数据时图省事,从电商商品图或科普网站抓了一大批图片。这些商品图通常是白底、居中、光线均匀的摆拍图,模型学到了“白色背景+中心构图=某药材”的捷径,而不是药材本身的形态特征。真实场景里巡检员是手持拍摄,背景是塑料筐、纸箱、其他药材,颜色分布和商品图差异巨大。
解决:数据清洗时强制统计每张图的背景信息,最简单的方式是检查图片主体的占比和背景颜色分布,把背景纯白或纯灰的图权重降下来。更彻底的办法是部署后主动收集真实场景图片,人工标注后回灌训练集。这个坑基本每个做中草药识别的团队都会踩,没有任何模型技巧能弥补数据分布偏移,唯一的药方是让训练集贴近部署时的真实拍摄条件。
5.2 旋转增强把细粒度纹理搅没了
现象:训练时验证集准确率稳步上升,但混淆矩阵显示,“薄荷叶”和“留兰香叶”这对最难区分的类别错误率一直降不下去,比预期高了近10个百分点。
原因:训练增强里用了RandomRotation(degrees=90)。旋转90度后,薄荷叶片的腺鳞排列方向、叶脉走向与真实样本完全不同,模型在增强样本上被迫学“旋转不变特征”,但细粒度分类恰恰需要方向敏感特征。增强操作的强度超出了任务需要的范围。
解决:把旋转角度降到10度以内,并单独检查每个难区分类别对的准确率,而不是只看整体准确率。在验证集上额外按类别计算混淆矩阵,把训练增强的参数和类别对错误率关联起来看。这里给一个快速检查用代码片段,找出哪些类两两之间最容易混淆。
from sklearn.metrics import confusion_matrix import numpy as np # y_true, y_pred 分别是验证集的真实标签和预测标签(整数索引) cm = confusion_matrix(y_true, y_pred) np.fill_diagonal(cm, 0) # 去掉正确分类的对角线,只保留错误对 top_pairs = [] for i in range(len(cm)): for j in range(len(cm)): if cm[i][j] > 0: top_pairs.append((cm[i][j], class_names[i], class_names[j])) top_pairs.sort(reverse=True) for count, c1, c2 in top_pairs[:10]: print(f"{c1} -> 被预测为 {c2}: {count} 次")逻辑说明:混淆矩阵是细粒度分类项目里的必备检查工具,整体准确率会被头部高频类别拉高,掩盖少数困难类别的真实表现。参数说明:这里只取错误对的前10条,实际使用中优先关注那些出现次数多且类别形态接近的混淆对——它们往往指向数据采集缺口或增强策略不当,而不是模型容量不足。
5.3 类别不均衡导致Top-1偏移
现象:模型总是在输出概率分布上把一些高频类别(如常见的甘草、黄芪)排在前面,低频类别(如较冷门的雪上一枝蒿)即使输入图片特征非常明显,也排不到Top-3。
原因:交叉熵损失对类别频率敏感。训练集中高频类别占多数,优化器为了降低整体损失,会把决策边界往低频类别偏移。推理时低频类别的logits被系统性压低。
解决:最简单的做法是在损失函数里加类别权重,权重设为样本数的倒数再归一化。PyTorch 的CrossEntropyLoss(weight=...)直接支持。注意权重不要设成严格的1/count,那样会让低频类别的梯度占比过大,训练震荡。我一般用weight = 1 / np.sqrt(count),缓解不均衡但不过度补偿。这个细节在类别数超过40、样本分布悬殊时非常重要。
5.4 光照与底色:模型在看背景而不是看药材
现象:新一批验证图片是在暖黄色灯光下拍的,模型准确率直接掉了十几个点;把药材放到黑色绒布上拍摄,识别结果和白色背景下完全不同。
原因:模型把背景颜色当成了判别特征。这属于典型的“捷径学习”,在数据里大量样本都是同一类背景下拍摄时,模型发现预测时用背景颜色最容易降低损失,于是忽略了药材本身。
解决:数据采集阶段就要刻意收集不同底色、不同光照下的同一药材。如果不具备重新采集条件,用图像增强模拟:训练时把背景做随机替换,把药材主体从原图中抠出,贴到随机颜色的背景上。这个操作的实现成本不低,但效果比任何网络结构调整都立竿见影。线上部署时,在预处理里加一个基于亮度直方图的白平衡归一化,也能缓解光照不一致带来的影响。
5.5 导出ONNX后推理结果和PyTorch不一致
现象:PyTorch 里跑单张测试图,预测类别为 A,置信度0.88;导出ONNX后用ONNX Runtime跑同一张图,预测类别还是A,但置信度变成0.79,个别图片甚至类别都变了。
原因:最常见的原因是模型里有训练态算子。PyTorch 模型在eval()模式下 batchnorm 会使用累计均值方差,但如果你导出时模型仍然是train()模式,batchnorm 的统计量会随输入动态变化,导出的静态计算图在部署端无法复现。另外,如果导出前没有执行torch.no_grad(),某些算子会被动态图模式干扰。
解决:导出脚本里强制在model.eval()之后加with torch.no_grad():包住torch.onnx.export调用,然后写一组自动化对比脚本:准备20张测试图,分别跑PyTorch和ONNX Runtime,对比输出向量的最大差异。差异大于1e-2就要排查预处理一致性。绝大多数情况下,问题不在模型转换,而在预处理和数据标准化不一致。
6. 把识别做到能用的细节:置信度校准、难样本挖掘与可解释性
6.1 温度缩放校准置信度
中草药识别直接看原始Softmax概率会误导人。实际数据分布偏移时,模型的置信度经常偏乐观——一张模糊的叶片图,模型给“薄荷”打出0.93的高置信度,但人眼根本看不清。温度缩放是最简单有效的校准方法:在验证集上学习一个温度参数T,把Softmax的输入除以T,让输出概率分布更接近真实准确率。
import numpy as np from scipy.optimize import minimize # logits_val: 验证集所有样本的logits,shape (N, C) # y_val: 验证集真实标签 def optimize_temperature(logits_val, y_val): def nll_loss(T): T = max(T, 1e-3) scaled = logits_val / T log_probs = scaled - np.log(np.sum(np.exp(scaled), axis=1, keepdims=True)) return -np.mean(log_probs[np.arange(len(y_val)), y_val]) res = minimize(nll_loss, x0=1.0, method="L-BFGS-B", bounds=[(0.1, 10.0)]) return res.x[0] T = optimize_temperature(logits_val, y_val) print(f"最优温度: {T:.3f}") # 部署时:predict_proba = softmax(logits / T)逻辑说明:温度缩放只调整概率分布的锐利程度,不改变预测类别,所以不会影响准确率,只让置信度更可信。参数说明:T大于1时概率分布变平滑,T小于1时变尖锐。中草药模型因为训练数据噪声大,通常T在1.2到2.0之间。校准之后,可以设置一个可靠的置信度阈值,比如只有置信度超过0.6才输出具体品种,否则提示“请上传更清晰的照片”,这个交互设计在真实使用中比直接给一个吃不准的答案要好得多。
6.2 难样本回灌与增量训练
中草药识别项目是持续迭代的。上线一个月后,收集到的真实误判样本是最宝贵的训练资源。我现在的习惯是每两周做一次难样本复盘:把线上置信度低于阈值、或者预测结果与人工复核不一致的图片导出,聚类后由专业人员标注,增量训练时把这些样本按不超过总量20%的比例混入原训练集。
增量训练有两个地方要小心。一是不要把新样本直接覆盖旧数据,要用旧数据+新样本联合训练,否则模型会遗忘早期学到的特征。二是新样本的学习率要比初始训练低一个数量级,我用0.0001,防止新样本权重过大导致原有特征崩塌。这个过程比追求新奇网络结构带来的收益大得多,尤其当项目中草药类别数只有几十个时,数据质量永远是上限。
6.3 用类激活图定位模型在看哪里
给模型加可解释性,不是为了出论文,而是为了快速定位模型有没有学偏。Grad-CAM 类激活图能告诉我们:模型在做决策时,到底在看图像里的哪个区域。如果一张薄荷叶图片的激活区域全部落在背景桌面,那就要检查是不是5.4节里说的背景捷径问题。
import torch import cv2 import numpy as np from torchvision import models, transforms import torch.nn.functional as F # 加载第3节的模型(此处不重复构建结构),假设变量名 model model.eval() def grad_cam(img_tensor, target_class=None): img_tensor = img_tensor.unsqueeze(0).requires_grad_(True) output = model(img_tensor) if target_class is None: target_class = output.argmax(dim=1) one_hot = torch.zeros_like(output) one_hot[0, target_class] = 1 model.zero_grad() output.backward(gradient=one_hot) # 取 layer4 最后一层卷积输出的梯度 gradients = model.layer4[-1].conv3.weight.grad # 简化示例,实际应挂钩 forward hook # 完整实现需要注册 forward hook 获取激活图和梯度,这里给出思路框架 return None # 见下方说明逻辑说明:真正的Grad-CAM实现需要在这段代码基础上注册 forward hook 捕获指定层的激活图和梯度,上面这段只是最小示例框架。实际项目中,激活区域完全偏离药材主体基本锁定数据问题:要么是背景混淆,要么是标注错误。可视化调试比看损失曲线直观得多。不过注意一点:Grad-CAM 的定位结果只能作为诊断线索,不要用它作为推理结果的正式解释展示给用户——中草药鉴别是严肃场景,算法热力图可能误导判断。
我现在的习惯是:每次训练完模型,先放一批验证集激活图,花十分钟扫一遍,再做部署决策。这个习惯帮我筛掉了至少两个有明显背景倾向的模型版本。做中草药识别,最大的敌人不是模型容量不够,而是训练数据跟你想要的任务之间那些微妙偏差。希望这些思路能帮你少走一段弯路,让项目从“能跑”真正走向“能用”。
本文还有配套的精品资源,点击获取