VGG16迁移学习实现62种野外可食用植物分类实战

发布时间:2026/9/12 22:49:33
VGG16迁移学习实现62种野外可食用植物分类实战 简介基于VGG16的迁移学习图像分类实战项目提供完整可运行的六十二种大型野外可食用植物分类方案。项目自带四千三百张训练图与三百一十张测试图覆盖苜蓿、芦笋、葛根等类别并已内置训练好的权重加载即可进行预测。VGG16参数量约六千万代码采用余弦学习率自动衰减结果目录中存有最优权重、训练日志以及损失值与精度曲线便于复现分析。预测时运行预测脚本即可自动对推理文件夹下的所有图片进行预测并将前三个最大概率类别绘制在左上角。资源共两千个文件以JPG图像为主另有四个Python脚本、一个说明文档、一个配置文件包含训练集与测试集目录结构压缩包约七百六十八兆。目前已有七十四人浏览学习适合学习经典卷积网络结构、迁移学习微调或进行植物图像分类研究的初学者与开发者。要训练自己的数据只需按说明文档摆放好数据集即可无需手动调整参数。1. 为什么拿VGG16做62种野外可食用植物分类迁移学习更划算62种可食用植物样本在野外拍摄光线、遮挡、叶龄都会让同类图像差异很大如果从零训练卷积网络4300张训练图很难压住VGG16这种6千万参数的模型。这个项目用预训练权重做迁移学习让网络先理解边缘、纹理、叶脉等底层结构再用植物图像微调分类头。数据集训练集4300张、测试集310张类别数自动推导不用手动改超参数推理时把图片放进inference目录predict脚本会输出置信度最高的前三个类别。适合刚接触CNN图像分类、想做迁移学习落地的开发者也适合需要快速验证植物识别方案的工程师。2. 数据准备阶段62类植物数据集的目录结构、标签策略与增强参数2.1 62类数据的目录结构和标签是怎么组织的拿到压缩包后先看目录。data-train下面每个子文件夹对应一种植物文件夹名就是分类类别名data-test结构完全一致。图像文件名像Crimson-Clover35.jpg、Curly-Dock21.jpg、coltsfoot20.jpg文件名里的数字是样本编号不是标签真正决定标签的是文件所在目录。这个设计对torchvision的ImageFolder非常友好不需要单独写一份CSV做映射。常见做法是让文件夹名严格一致并且训练集和测试集里同类别的文件夹名必须完全相同。如果一边叫Crimson-Clover另一边叫crimson_cloverImageFolder会把它当成两个类别测试集的类别数就会变成63甚至更多后面的超参数自动生成也会跟着错。检查压缩包时最好先对照两边的目录列表确保只有62个目录。训练集约4300张测试集310张平均下来每类训练样本约69张、测试样本5张。这个测试规模对细粒度分类来说并不宽裕评估时不能只看整体准确率还要关注个别类别的召回。比如叶片细长的和叶片带锯齿的植物在图片上容易互相混淆某一类的5张测试图如果正好都是难样本单看整体准确率会漏掉这个信号。2.2 用ImageFolder读入类别数自动推导读取数据这一步可以直接用torchvision.datasets.ImageFolder它会扫描子文件夹并生成类别索引。这里transform先留空后面再补增强先把数据量和类别数确认清楚from torchvision import datasets, transforms train_root data-train test_root data-test # 先只做ToTensor方便确认路径和数量 base_transform transforms.Compose([ transforms.ToTensor() ]) train_dataset datasets.ImageFolder(train_root, transformbase_transform) test_dataset datasets.ImageFolder(test_root, transformbase_transform) num_classes len(train_dataset.classes) print(类别数:, num_classes) print(训练集样本数:, len(train_dataset)) print(测试集样本数:, len(test_dataset)) print(类别索引示例:, list(train_dataset.class_to_idx.items())[:5]) # 推理时需要反向映射 idx_to_class {v: k for k, v in train_dataset.class_to_idx.items()}这段代码里num_classes不是写死的62而是从目录扫描结果自动推导出来的。之后修改模型全连接层、生成类别标签、写日志都需要用到它。class_to_idx默认按文件夹名字典序排序推理时把索引映射回植物英文名防止预测结果只输出数字类别。如果扫描完发现类别数不是62先检查是否有多余的.zip文件或隐藏目录被当成子文件夹比如__MACOSX、.DS_Store这类系统文件在Windows上不常见但用压缩软件解压后偶尔会出现。ImageFolder会把它们当作类别目录所以解压后最好清理掉无关文件。2.3 数据增强参数表和transform代码野外植物图像最难处理的是光照、拍摄角度和背景杂乱。数据增强要尽量贴近真实环境但不能增强到让植物纹理失真。下面这组是我在这个数据集上常用的配置主要面向VGG16要求的224x224输入。增强项参数作用RandomResizedCropscale(0.6, 1.0), size224模拟遮挡和远近变化RandomHorizontalFlipp0.5左右翻转不改变植物类别RandomRotationdegrees15容忍拍摄姿态倾斜ColorJitterbrightness0.3, contrast0.3, saturation0.3适应野外光照差异NormalizeImageNet均值/方差匹配VGG16预训练权重分布对应的transform写成代码就是train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0), ratio(0.75, 1.333)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.3), 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), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])RandomResizedCrop的scale设成0.6到1.0比默认的0.08到1.0更保守。植物分类不像目标检测那样需要截取极小局部过强的裁剪会把叶脉和花萼这些关键细节裁掉。ColorJitter三项都设在0.3不用太大否则叶片颜色会偏到失真模型反而学不到植物本身的特征。很多人会把水平翻转默认打开但对植物图像来说这没问题因为野外可食用植物不会因为左右翻转就变成另一个物种。旋转度数设15而不是90因为大多数照片是竖直角度拍摄的旋转太多会让包含茎干朝向的信息变得混乱。验证集不要加随机增强只用Resize加CenterCrop保证每张测试图都进入相同的区域。2.4 类别不均衡检查虽然整个数据集看起来均衡但不同类别样本数未必完全一致。训练前可以花一分钟统计每个文件夹里有多少张图片发现明显偏少的类别时在日志里标记出来。偏少的类别在网络训练后期容易被多数类别淹没特别是像苜蓿、芦笋、葛根这类形态接近的类别。统计脚本可以直接复用globimport glob import os for split in [data-train, data-test]: class_counts {} for cls_path in sorted(glob.glob(os.path.join(split, *))): if not os.path.isdir(cls_path): continue class_name os.path.basename(cls_path) count len(glob.glob(os.path.join(cls_path, *.jpg))) class_counts[class_name] count print(split, len(class_counts)) print(min(class_counts.values()), max(class_counts.values()))这段代码只统计jpg避免把macOS生成的无扩展名文件也算进去。如果发现某些类别样本数低于50后面保存最优权重时就要额外注意因为测试集每类只有5张图验证指标会抖动最好结合3次以上的验证准确率取均值不要只看最后一次。3. VGG16网络结构选型与分类头替换60M参数里哪部分要冻结3.1 为什么选VGG16而不是ResNet或Transformer现在聊CNN图像分类很容易被带向ResNet、EfficientNet、Swin Transformer这些更新模型但具体到这个项目VGG16是更稳的选择。VGG16的结构非常规则五个卷积块每块由3x3卷积和2x2最大池化堆叠最后接三层全连接。它的参数量约6000万其中很大一部分集中在最后几个全连接层卷积层用来提取边缘、纹理、形状这类通用特征全连接层负责把特征映射成具体的分类决策。迁移学习的思路是预训练权重已经在ImageNet上学到了丰富的底层特征所以哪怕野外植物看起来和ImageNet里的类不完全相同底层卷积核依然有效。我们只需要调整分类头或者微调后面几层卷积。对于4300张训练图的规模直接训练整个CNN结构图里所有参数很容易过拟合而加载预训练权重之后模型已经具备很强的特征提取能力需要学习的参数变少了。Transformer图像分类模型虽然在很多公开榜单上更高但它对数据量和训练技巧的要求也更高。这个项目的交付重点是“下载后能直接跑植物分类结果能稳定复现”VGG16在CPU和低端GPU上都能流畅推理显存占用也可控现有的训练日志和权重文件也都围绕它生成。如果不做竞赛调参没必要为了“最新模型”牺牲可复现性。3.2 加载预训练权重并替换最后一层PyTorch里加载VGG16并替换分类器最后一层的方式很直接import torch import torch.nn as nn import torchvision.models as models num_classes 62 # 实际运行时从上一步自动推导 model models.vgg16(weightsmodels.VGG16_Weights.IMAGENET1K_V1) # VGG16的classifier是Sequential最后一层是Linear(4096, 1000) in_features model.classifier[6].in_features model.classifier[6] nn.Linear(in_features, num_classes)这里用model.classifier[6]而不是model.fc是因为VGG16的分类头是一个Sequential容器共7层第6层是输出层。替换之后模型输出维度从1000变成num_classes反向传播时只有最后一层和前面全连接层的梯度计算会改变。如果你下载的压缩包里已经带了训练好的权重文件可以跳过这步直接torch.load加载整个state_dict。需要特别注意的是加载自带的best_model权重时如果之前保存的是整个模型字典要保证当前模型结构和保存时一致。如果weights参数用了IMAGENET1K_V1程序第一次运行会尝试联网下载预训练权重内网环境就改成加载本地权重让代码从项目目录读取。3.3 冻结策略先只练分类头再决定要不要解冻迁移学习有两条路一是冻结所有卷积层只训练分类头二是全部参与训练但使用较小学习率。对于这个项目我一般这样做第一轮把features整个冻结训练5个epoch让新的分类头先稳定下来再解冻后半部分特征层用1e-4左右的学习率做微调。训练方案冻结范围学习率适用场景只训练分类头features全部冻结0.01快速验证、资源受限微调后半段features后两个block解冻0.001追求更高准确率全量微调不冻结0.0001数据量足够且防过拟合# 策略一冻结卷积特征层只训练分类器 for param in model.features.parameters(): param.requires_grad False # 只给需要梯度的参数做优化器分组 optimizer torch.optim.SGD( filter(lambda p: p.requires_grad, model.parameters()), lr0.01, momentum0.9, weight_decay5e-4 )冻结features之后模型参数更新只会影响分类器训练速度快显存占用也小。缺点是分类器直接作用在ImageNet特征上可能无法捕捉植物类别之间非常细微的差异。所以训练几轮后再解冻features最后两个block让卷积核针对植物叶片和花蕊微调。很多新手会把所有层直接解冻认为效果一定更好。实际上当训练集只有几千张时整体微调很容易让前几层学到的通用边缘特征被数据噪声覆盖反而降低泛化。解冻部分层是折中方案底层保留ImageNet学到的通用特征高层针对植物分类去调整。3.4 超参数自动生成的关键逻辑项目readme里强调“超参数代码会自动生成例如分类类别个数”这句话的意思不是不需要数据而是代码会根据data-train目录自动推导。实现方式很朴素扫描文件夹数量得到num_classes扫描每个文件夹名字得到类别列表再把这两个值填进模型初始化、损失函数、日志输出和推理脚本。使用的时候只要保持目录结构正确不需要亲手去改任何分类数量。下面这段代码演示了最核心的自动生成逻辑import os train_root data-train classes sorted([ d for d in os.listdir(train_root) if os.path.isdir(os.path.join(train_root, d)) ]) num_classes len(classes) print(自动识别类别数:, num_classes) print(类别清单:, classes)这段代码要求每个类别文件夹里都是同一类植物的图片不能把多个品种混在一个文件夹里。如果新增了一个类别只需要在data-train和data-test下各建一个同名文件夹把图片放进去程序下次启动就会在num_classes里自动加1不需要改动模型代码。这也是这类项目最容易迁移到自定义数据集的原因。4. 训练阶段要盯的参数cos学习率衰减、最优权重保存与loss曲线4.1 优化器、损失函数与学习率调度植物分类是标准的单标签多分类问题损失函数用CrossEntropyLoss。优化器这里我建议用带动量的SGD学习率0.01配合cos退火。Adam在新分类头上收敛很快但后期容易出现验证准确率在某个值附近震荡的情况SGD虽然在前期慢一些结合余弦退火后收敛曲线更平滑最后的权重更稳。criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD( model.parameters(), lr0.01, momentum0.9, weight_decay5e-4 ) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max30, eta_min1e-5 )CosineAnnealingLR会按照余弦曲线把学习率从初始值逐渐降到eta_min。T_max设成总的训练轮数比如训练30个epochT_max30学习率会在最后1个epoch降到最低。这种调度方式比StepLR更适合迁移学习前期保持较大学习率快速调整分类头后期用小学习率精细搜索不容易跳过最优解。如果只解冻分类头lr0.01没有问题如果解冻了features最好把学习率降到0.001因为ImageNet预训练卷积层已经处于一个很小的局部最优附近学习率太大会破坏已有特征。项目里的超参数生成逻辑不会自动做这个区分所以换自己数据时要注意。4.2 训练循环、验证与最优权重保存run_results目录里应当能看到最好的权重文件、训练日志以及loss和精度曲线。训练时通常在每个epoch结束后跑一次验证集保存验证准确率最高的模型而不是最后一个epoch。测试集只有310张验证集如果从训练集里切出来可以随机抽10%作为验证集但注意要保证每个类别都能分到至少1张。best_acc 0.0 for epoch in range(epochs): model.train() train_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() train_loss loss.item() * images.size(0) val_acc evaluate(model, val_loader, device) scheduler.step() if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), run_results/best_model.pth) print(fepoch {epoch1}: save best acc {val_acc:.4f})这里每个batch先做optimizer.zero_grad()清空梯度然后前向、计算损失、反向、更新。train_loss乘上images.size(0)是为了得到整个epoch的总损失后面除以训练集样本数得到平均。验证用单独的evaluate函数它内部要写model.eval()和torch.no_grad()否则BatchNorm和Dropout在验证时不生效结果会偏差很大。保存权重时有几个坑不要每次epoch都保存否则硬盘会被占满也不要只保存最后的权重因为最后几个epoch可能已经过拟合。保存的state_dict里只含参数不含优化器状态二次加载时不需要恢复学习率调度器。如果想断点续训可以额外保存optimizer和scheduler的state_dict路径建议放在run_results/checkpoint_epochX.pth。4.3 训练日志和loss曲线怎么读训练日志会记录每个epoch的train_loss、val_loss、val_acc、lr。loss曲线一般画两条训练loss和验证loss。看到训练loss持续下降、验证loss也在下降说明模型还在正常学习训练loss下降但验证loss上升说明过拟合开始需要早停或降低学习率。余弦退火下的学习率曲线是平滑下降的loss曲线会有小波动这是正常的不要因为一个epoch波动就终止训练。验证准确率在310张测试图上的波动可能比较明显个别epoch掉2个点不用紧张。更有效的判断方式是同时记录top-3准确率因为最终predict脚本输出的是前三个类别top-3更贴近项目的真实使用体验。计算top-3可以用torch.topk对outputs取前3个索引再和labels比较。def topk_accuracy(output, target, k3): _, pred output.topk(k, 1, True, True) pred pred.t() correct pred.eq(target.view(1, -1).expand_as(pred)) return correct[:k].reshape(-1).float().sum(0).item() / target.size(0)这段代码里outputs形状是[batch, 62]每个样本在62个类上的分数topk(3)取分数最大的3个位置。目标展开成相同大小后比较只要真实类别出现在前3个位置就算预测命中。对310张测试集来说top-3通常比top-1高5到8个百分点也能告诉你模型是否在“大方向正确但细分类欠拟合”的状态。4.4 类别级评估和混淆矩阵全局准确率容易忽略少数类的坏情况所以训练结束后可以用sklearn跑一份classification_report把每个类别的精确率、召回率都打出来。如果某个类别召回率很低去data-train里看这个类别的图片数量再决定是补充样本还是调整增强参数。from sklearn.metrics import classification_report y_true, y_pred [], [] model.eval() with torch.no_grad(): for images, labels in test_loader: images images.to(device) outputs model(images) _, preds outputs.max(1) y_true.extend(labels.tolist()) y_pred.extend(preds.cpu().tolist()) print(classification_report(y_true, y_pred, target_namestest_dataset.classes))因为每个类别只有约5张测试图片单个类别的召回率参考价值有限但可以快速暴露哪些类别完全学不出来。看到“芦笋”和“葛根”互相混淆就回去检查训练图是不是植物部位差异太小。更精细的做法是输出混淆矩阵定位到具体哪两个类经常被搞混。之后在predict阶段看到同类错误时就知道这是模型能力边界不是偶然失误。5. predict推理脚本的细节前三个类别绘制与自己的数据怎么接进来5.1 推理流程读取inference目录、输出top-3并绘制在左上角predict脚本的逻辑很简洁遍历inference目录下的所有图片对每张图做一次前向推理取概率最大的前三个类别再使用PIL把类别文本绘制到图片左上角。这样人眼可以直接看到模型认为的前三个候选第一个通常是主要判断后两个帮助判断哪些类在特征上接近。from PIL import Image, ImageDraw import torch model.eval() transform val_transform for img_path in sorted(glob.glob(inference/*.jpg)): img Image.open(img_path).convert(RGB) input_tensor transform(img).unsqueeze(0).to(device) with torch.no_grad(): output model(input_tensor) probs torch.softmax(output, dim1)[0] top3_prob, top3_idx torch.topk(probs, 3) draw ImageDraw.Draw(img) y 5 for prob, idx in zip(top3_prob, top3_idx): class_name idx_to_class[idx.item()] text f{class_name} {prob.item()*100:.1f}% draw.text((5, y), text, fill(255, 0, 0)) y 25 img.save(output/ os.path.basename(img_path))推理时一定要加torch.no_grad()这个上下文管理器会关闭自动求导既省显存又加快速度。softmax放在模型输出之后做能把原始分数转成概率因为CrossEntropyLoss内部已经包含softmax训练时不需要额外加但推理时拿到的logits必须softmax后才是0到1的概率。topk返回的是概率和类别索引类别索引再通过数据加载时保存的idx_to_class还原成植物英文名。5.2 换自己的数据集时真正要改的只有目录项目readme说“训练自己的数据不需要更改参数只需要摆放好数据集即可”这句话成立的前提是train和test文件夹的分类目录一致。具体操作是在你自己的数据目录下建train和test两个平级目录里面每个子文件夹叫一个类别名把图片按类别放进去。运行训练脚本时会自动计算类别数不需要改模型的最后输出层也不需要重新设置学习率。如果类别目录差别很大比如图像不是jpg而是png记得把glob和ImageFolder支持的后缀统一改一下。图像尺寸也不要低于224x224VGG16输入主要由预处理决定但分辨率太低时预训练特征提取效果明显下降。5.3 一个实用的验证技巧批量导出错误样本训练结束之后不要只盯着一张inference图看输出更有效的做法是让推理脚本把预测错误的前三名也输出出来。用上面topk得到的top3索引在每张图右下角标注真实类别名和top3列表再按概率从低到高排序。这样可以快速发现混淆模式比如Sow-Thistle和Milk-Thistle经常同时出现在前两名说明这两类在训练数据里的特征区分度不够应该检查对应训练集中的图片是否过于相似。如果发现模型对某类图片的top1一直不对但top3里有正确类别说明分类头对这类别的决策边界还有余量。发现这类情况后把难样本整理到data-train对应目录里重新训练一轮比在inference阶段改阈值更有效。本文还有配套的精品资源点击获取