VGG16迁移学习实现62种植物图像分类实战 简介面向计算机视觉初学者与深度学习实践者的 VGG16 迁移学习图像分类实战项目以 62 种大型野外可食用植物为分类对象解决小型数据集上训练深层 CNN 的难题。压缩包共 2000 个文件以约 1993 张 JPG 图像为主另有 4 个 Python 脚本、1 个 txt 说明、1 个 readme 及 1 个 JSON 超参数文件整体体积 768.42MB。训练采用余弦学习率自动衰减run_results 目录保存最优权重、训练日志与 loss/accuracy 曲线预测时运行 predict 即可自动推理 inference 下所有图片并在左上角绘制前三个概率最高的类别。内置数据集按类别分子目录存放训练集约 4300 张、测试集约 310 张若要训练自己的数据按 readme 规定摆放目录即可类别数等超参数会自动生成。已有 74 人学习下载适合课程设计、毕业设计及快速搭建图像分类基线。1. 62种可食用植物图像分类为什么用VGG16迁移学习而不是从零训练CNN62类野外可食用植物的图像分类任务难的不是“分类网络选哪个”而是数据量和数据分布。同一株植物在幼叶期和成熟期看起来像两个物种阴天拍和晴天拍的背景又完全不同。与其从零训练一个深度CNN更常见的做法是拿ImageNet预训练过的VGG16做迁移学习把前边的卷积特征直接复用只重学最后的分类层。这篇内容以这套实战项目的完整落地方案为主线从结构原理、数据组织、模型替换、训练参数到评估和剪枝都过一遍。适合正在做植物物种识别、林业图像分类或需要快速搭图像分类原型的工程师。2. VGG16的卷积结构为什么适合做植物图像特征提取器2.1 16层堆叠的固定模板卷积块、池化与分类头VGG16名字里的16指的是13个卷积层加上3个全连接层。输入图像统一缩放为224×224×3经过5个最大池化层特征图尺寸从224降到112、56、28、14最后变成7×7。通道数从64开始翻倍到网络深处变成512。这个规律非常规整因此VGG16的结构图在所有CNN里最好记。阶段操作序列输出特征图尺寸conv12个3×3卷积64 maxpool112×112×64conv22个3×3卷积128 maxpool56×56×128conv33个3×3卷积256 maxpool28×28×256conv43个3×3卷积512 maxpool14×14×512conv53个3×3卷积512 maxpool7×7×512classifier3个全连接层4096 → 4096 → 1000连续堆叠两个3×3卷积感受野等效一个5×5卷积但参数量从25C²降为18C²还多引入了一次非线性。VGG16的卷积核都极小结构上没什么花哨的设计这正是它能被反复复用、容易改造成迁移学习模型的原因。先用torchvision把模型加载出来看一眼结构是动手前的标准动作。import torch from torchvision.models import vgg16, VGG16_Weights model vgg16(weightsVGG16_Weights.IMAGENET1K_V1) print(model)这段代码以当前torchvision的推荐写法为例。weightsVGG16_Weights.IMAGENET1K_V1会加载在ImageNet上预训练好的权重如果还在用旧版API里的pretrainedTrue也能运行只是会在新版本里收到废弃提示。打印出来的模型主体由features、avgpool和classifier三部分组成。最后一个卷积层输出的7×7×51225088个数值被展平后送入全连接层这个数字在修改分类头时需要用到。2.2 预训练权重到底迁移了什么边缘、纹理与叶片形态VGG16在ImageNet上训练时前几层卷积学到的是边缘、颜色和纹理滤波器中间层学到的是拐角、弧线和重复纹理的组合最后面的卷积层才偏向物体部件和整体形状。植物分类恰好需要这些特征叶片锯齿边缘、叶脉走向、花瓣颜色渐变、果实表面质感都能从预训练权重里找到对应的激活模式。迁移学习在这里的定位属于直推式迁移学习源域是ImageNet里的自然图像目标域是野外植物照片两者不完全同分布但低层视觉特征高度重叠。直接复用卷积层参数只替换最后一层分类输出是数据量不足时最稳的方案。迁移策略冻结范围训练数据要求收敛速度适用场景特征提取冻结features只训练分类头每类30到50张即可起步快数据少、原型验证全量微调全部参数参与训练每类建议至少200张慢数据充足、域偏移大如果62类每类只有几十张我一般会分两步走先用特征提取模式把分类头训收敛再解冻最后两个卷积块用更小的学习率做微调。直接上全量微调VGG16有1.38亿参数训练集不大时很容易把验证集准确率越训越低。3. 62类植物图像数据集整理与图像增强让VGG16学到真实特征3.1 目录结构按ImageFolder整理并保留独立验证集torchvision里的ImageFolder要求数据按“根目录/类别/图片”的组织方式存放。62个类别每个类别一个目录目录名就是类别标签。原始采集的图片文件名往往没有任何语义先整理成统一结构后面所有代码都不用动路径。data/ train/ class_01_荠菜/ IMG_001.jpg IMG_002.jpg class_02_蕨菜/ ... val/ class_01_荠菜/ ...写一个划分脚本把每个类别的图片按比例拆到train和val。import os import random import shutil random.seed(42) def split_dataset(src_dir, train_dir, val_dir, val_ratio0.15): for cls_name in os.listdir(src_dir): cls_path os.path.join(src_dir, cls_name) images os.listdir(cls_path) random.shuffle(images) num_val int(len(images) * val_ratio) os.makedirs(os.path.join(train_dir, cls_name), exist_okTrue) os.makedirs(os.path.join(val_dir, cls_name), exist_okTrue) for img in images[:num_val]: shutil.copy( os.path.join(cls_path, img), os.path.join(val_dir, cls_name, img) ) for img in images[num_val:]: shutil.copy( os.path.join(cls_path, img), os.path.join(train_dir, cls_name, img) )split_dataset(raw_data, data/train, data/val, 0.15)执行后每个类别下会各自生成15%的验证图片。这里的val_ratio0.15是常用起点如果总数据量只有每类三四十张最好提到0.2保证验证集有足够样本评估。提示如果图片是同一台设备在同一地点连续拍摄的不要用纯随机划分。相邻帧背景和光照几乎一样会让验证集分数虚高。按拍摄时间或拍摄地点分组后再划分更贴近真实识别场景。3.2 训练集图像增强参数裁剪、翻转与颜色抖动野外植物照片和ImageNet里的图片差距不小尤其是光照条件阴天、树荫、背光都会让同一个物种呈现完全不同的颜色分布。图像增强在这里的作用不是刷数据量而是让卷积网络忽略这些与物种无关的变化。from torchvision import transforms mu [0.485, 0.456, 0.406] std [0.229, 0.224, 0.225] train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.5, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ColorJitter( brightness0.4, contrast0.4, saturation0.3, hue0.05 ), transforms.ToTensor(), transforms.Normalize(mu, std) ]) val_transform transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mu, std) ])每个增强参数都有对应场景。RandomResizedCrop随机裁剪一部分再缩放模拟距离变化和枝叶遮挡RandomRotation(15)容忍拍摄角度倾斜ColorJitter里brightness0.4应对强光和逆光saturation0.3应对阴天色彩偏淡。归一化的均值和方差使用ImageNet的标准统计量因为预训练权重是在这个统计条件下学出来的。验证集只做Resize和CenterCrop不做随机增强保证评估结果稳定可重复。Resize到256再CenterCrop成224是VGG16输入尺寸的常见搭配直接Resize到224也可以但前者会让目标稍微放大识别小叶片时略有优势。增强方式常用范围解决的实际问题RandomResizedCropscale(0.5, 1.0)距离远近、遮挡、目标占比变化RandomRotation10到20度拍摄角度不固定ColorJitter brightness0.3到0.5树荫、阴天、背光ColorJitter saturation0.2到0.4季节和湿度导致的颜色差异如果采集到的数据类别严重不均衡比如某个常见野菜有几千张某个稀有物种只有二十张可以用WeightedRandomSampler让每个批次里少数类的出现概率更高。class_counts [500, 23, 120, ...] sample_weights [1.0 / count for count in class_counts] sampler torch.utils.data.WeightedRandomSampler( sample_weights, num_samplessum(class_counts), replacementTrue )sample_weights按类别样本数的倒数计算样本越少的类权重越大。num_samples决定了每个epoch总共采样多少张设为全量数据集的图片总数会让少数类在一个epoch内被重复采样多次。只要数据集能整体装进内存这个方案比手工复制少数类图片干净得多。4. 基于VGG16迁移学习的最小训练代码特征提取、微调与超参数4.1 用torchvision加载预训练VGG16并替换最后一层VGG16的classifier是三段式全连接结构4096 → 4096 → 1000。前两个4096层在ImageNet上学到的是高层特征的组合方式可以直接保留。需要替换的只有最后一层把输出从1000改成62。import torch import torch.nn as nn from torchvision.models import vgg16, VGG16_Weights def build_model(num_classes62, finetuneFalse): model vgg16(weightsVGG16_Weights.IMAGENET1K_V1) if not finetune: for param in model.features.parameters(): param.requires_grad False in_features model.classifier[6].in_features model.classifier[6] nn.Linear(in_features, num_classes) return modelmodel.features是卷积特征提取部分finetuneFalse时这些参数不参与梯度更新前向传播仍然使用预训练权重。model.classifier[6]指的是classifier这个Sequential里下标为6的模块也就是最后一层。先取它的in_features再构造新的Linear避免手写4096这个魔法数字。有些教程会把整个model.classifier重建为新网络我一般不建议这么做。除非数据量很大否则前两个全连接层里有价值的特征组合会被随机初始化的新层覆盖反而丢失预训练信息。4.2 训练参数选型SGD与Adam、学习率、batch size与epochs迁移学习的训练参数和从零训练不一样。特征提取阶段只需要训练最后那个Linear层优化器只接收model.classifier.parameters()学习率可以给大一点。微调阶段所有参数都参与训练VGG16深层卷积的梯度比较敏感学习率必须调小。训练模式优化器学习率batch size建议epoch数特征提取SGD momentum0.90.013220到30整体微调SGD 或 AdamW1e-4 到 3e-416到3210到20SGD在这个任务上仍然是最稳的选择收敛曲线比Adam平滑最终准确率一般也更高。Adam的优势是前期收敛快适合快速验证代码能不能跑通。weight_decay1e-4加在优化器参数里相当于对权重做L2正则能稍微抑制全连接层的过拟合。device torch.device(cuda if torch.cuda.is_available() else cpu) model build_model(num_classes62, finetuneFalse).to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD( model.classifier.parameters(), lr0.01, momentum0.9, weight_decay1e-4 ) scheduler torch.optim.lr_scheduler.StepLR( optimizer, step_size7, gamma0.1 )StepLR每7个epoch把学习率乘以0.1前20个epoch大概会经历两次断层下降。这样做的好处是前期用大学习率快速搜索分类头的参数空间后期小学习率精细收敛。如果训练曲线在验证集上抖动明显换成ReduceLROnPlateau按验证loss自动降低学习率更省心。4.3 训练循环不用框架也能跑完的最小实现训练循环本身不复杂关键点在于把模型切成train和eval两种模式验证阶段不要计算梯度。def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss 0.0 correct 0 for images, labels in loader: images images.to(device) labels labels.to(device) optimizer.zero_grad() logits model(images) loss criterion(logits, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) correct (logits.argmax(dim1) labels).sum().item() avg_loss total_loss / len(loader.dataset) accuracy correct / len(loader.dataset) return avg_loss, accuracy def evaluate(model, loader, criterion, device): model.eval() total_loss 0.0 correct 0 with torch.no_grad(): for images, labels in loader: images images.to(device) labels labels.to(device) logits model(images) loss criterion(logits, labels) total_loss loss.item() * images.size(0) correct (logits.argmax(dim1) labels).sum().item() avg_loss total_loss / len(loader.dataset) accuracy correct / len(loader.dataset) return avg_loss, accuracylogits.argmax(dim1)取得每个样本预测概率最大的类别索引与真实标签比对。损失用CrossEntropyLoss它内部已经把softmax计算进去了不需要在网络输出后手动加softmax。images.to(device)把整个batch送到GPUbatch size选择16到32是显存和训练速度的常见平衡点VGG16参数量大输入分辨率224×224时单卡4GB显存跑32的batch基本是上限。如果数据集不平衡特别严重可以在构造CrossEntropyLoss时传入weight参数给少数类更高的损失权重。这个做法和WeightedRandomSampler二选一即可同时用容易让训练不稳定。5. 模型评估、过拟合诊断与VGG16剪枝的落地做法5.1 用classification_report看62类的精度和召回率整体准确率在植物分类里会掩盖很多问题。62个类别如果某几个类占了数据量的一半模型把这几类全认对整体准确率也能到80%以上但对其他类可能完全没学会。每次训练完先跑一个完整验证集的分类报告。from sklearn.metrics import classification_report import torch model.eval() all_preds [] all_labels [] with torch.no_grad(): for images, labels in val_loader: images images.to(device) logits model(images) preds logits.argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) print(classification_report( all_labels, all_preds, target_namesclass_names, digits4 ))classification_report会输出每个类别的precision、recall和f1-score。哪个类的recall低说明这类图片外观变化太大模型没学到足够的判别特征哪个类的precision低说明模型经常把其他类误判成它。野外可食用植物经常出现同科不同种比如叶片形状接近的菊科植物这类混淆从准确率曲线上看不出来只有逐类指标能暴露。5.2 过拟合的三个信号与早停设置VGG16容量很大62类数据量通常不过几千张过拟合几乎是必然出现的现象差别只是来得早还是晚。曲线现象判断第一步处理训练acc继续涨验证acc开始回落过拟合增强数据增强强度增大Dropout训练loss和验证loss都停在高位欠拟合解冻更多卷积层参与微调验证acc上下震荡不收敛学习率过大降低学习率或换余弦退火特征提取模式下过拟合主要集中在最后一层Linear它的参数量是4096×62约25万个参数数据量少时很容易记住训练集。最简单的缓解方式是设置早停只保存验证集上表现最好的那轮权重。class EarlyStopping: def __init__(self, patience5, min_delta0.001): self.patience patience self.min_delta min_delta self.best_loss float(inf) self.counter 0 def __call__(self, val_loss): if val_loss self.best_loss - self.min_delta: self.best_loss val_loss self.counter 0 else: self.counter 1 return self.counter self.patience每次验证结束后把model.state_dict()保存到文件如果early_stopping(val_loss)返回True就终止训练。patience5意味着连续5个epoch验证loss没有明显下降就停止。训练脚本里别忘了在验证集指标更好时覆盖保存权重而不是等所有epoch跑完再取最后一轮。5.3 对VGG16进行剪枝从全连接层开始VGG16剪枝是常见需求但要注意剪哪里收益最大。卷积层参数量虽然大主要在计算量全连接层的权重矩阵是25088×4096参数量占比最高对模型体积影响最直接。import torch.nn.utils.prune as prune layer model.classifier[6] prune.l1_unstructured(layer, nameweight, amount0.3) prune.remove(layer, weight)l1_unstructured把全连接层中绝对值最小的30%权重置为0实现稀疏化。prune.remove的作用是固化剪枝掩码把稀疏后的权重正式写入模型。这里选最后一层剪枝是因为它本来就是随机初始化的新层剪完再重训几个epoch就能恢复精度不会破坏预训练权重。需要清醒一点PyTorch默认的非结构化剪枝在GPU上基本没有速度收益只是减小了参数量和存储体积。要想真正加速推理要么把权重导出为稀疏格式要么对通道做结构化剪枝直接减少卷积输出通道数。对VGG16做通道剪枝复杂度高不少通常是先剪最后的全连接层看效果再考虑剪conv5_3这类冗余较明显的卷积层。6. 进阶应用给VGG16加一个不认识的拒识阈值62类植物分类模型训练完成后会面临一个实际工程问题模型只有62个输出节点任何输入都会被分到其中一类哪怕输入的是一片完全不相干的树叶模型也会给出一个最高概率的类别。野外识别场景里“不确定”比“乱认”安全得多。解决办法是为softmax输出加一个置信度阈值低于阈值就判定为未知。VGG16的分类头输出的logits经过softmax后每一类的概率在0到1之间最大值反映了模型对预测结果的把握。如果最大概率都很低说明输入不在训练分布内。import torch import torch.nn.functional as F def infer_with_threshold(model, image_tensor, threshold0.75): model.eval() with torch.no_grad(): logits model(image_tensor.unsqueeze(0)) prob F.softmax(logits, dim1) max_prob, pred prob.max(dim1) if max_prob.item() threshold: return None, max_prob.item() return pred.item(), max_prob.item()关键参数是threshold。定得太低未知样本会被漏过去定得太高大量真实的可食用植物会被误拒。更稳妥的做法是用验证集标定收集模型在全部验证集样本上的最大概率取第5百分位数作为阈值保证至少95%的已知样本能通过过滤。all_confidences [] with torch.no_grad(): for images, _ in val_loader: images images.to(device) logits model(images) prob F.softmax(logits, dim1) all_confidences.append(prob.max(dim1).values) confidences torch.cat(all_confidences) threshold torch.quantile(confidences, q0.05).item() print(f标定阈值: {threshold:.4f})torch.quantile(confidences, q0.05)返回排序后第5%位置的值意味着95%的验证集样本置信度高于这个值。实际部署时如果发现误拒率高可以把阈值往下调但随之而来的是未知样本被错分的风险。一个自然的闭环是把低于阈值的图片单独保存下来人工标注后归入一个新的未知类别下次训练时让模型显式学习这个类别从而持续扩展分类边界。本文还有配套的精品资源点击获取