1. 项目背景与核心脉络
这个项目标题其实已经把整件事的轮廓说得很清楚了:用 VGGNet 做自己数据集的图像分类,而且特别强调“一步步完整实现”和“自己图像数据集准备”。作为前一篇博客的细化延续,这里就不再重复 VGGNet 的基础原理,而是把重点放到从零开始把一套分类流程真正跑通的完整细节。
先说这个项目适合谁。如果你已经接触过深度学习的框架,跑通了官方的 MNIST 或 CIFAR 示例,现在手里正好攒了一批自己的图片,想训练一个能识别这些小类别的模型,那这个项目的每个环节你都会遇到。我不止一次看到有人卡在“模型代码写好了,但数据加载器一直报错”或者“训练完精度挺高,一到自己随便找的图就完全乱猜”这种地方,根源往往是数据准备环节埋了雷,或者训练和推理时的数据预处理不一致。
整条技术链路包括这几个核心环节:
- 自定义图像数据集的目录组织与数据集划分
- 基于 torchvision 进行图像预处理和数据增强
- VGGNet 的网络结构搭建或迁移学习
- 模型训练的核心参数配置和训练循环实现
- 模型评估、预测可视化与测试集验证
先说结论:这套流程我用同样方法在好几个项目里跑过,无论是几十张的极小数据集还是上千张的常规小数据集,VGG 的 11 层或 16 层变体都能在合理时间内收敛,前提是数据预处理和迁移学习的打开方式正确。这篇就是把每一步怎么操作、为什么这样操作、踩过什么坑讲透。
2. 数据集的制备与目录设计
2.1 原始图像的采集与核验
很多人一上来就整理图片,其实第一步最该做的是把图片先看一遍,把明显有问题的剔除掉。我在项目里遇到过的常见问题包括:下载或拍摄的图片里混入了空白图、其他人像合影、带有二维码水印的无关图、不同分辨率和横竖比例差异极大的图。这些脏数据对特征学习的干扰非常大,尤其是 VGG 这种对局部纹理和形状极度敏感的卷积网络。
建议先统一图像格式为 JPEG,图片通道统一为 RGB。灰度图虽然也能训练,但如果你准备用 ImageNet 预训练权重做迁移学习,网络第一个卷积层输入就是三通道,灰度图还得转三通道,白白损失细节。分辨率不需要统一到某个固定值,因为后续数据加载环节会统一缩放,但原始文件本身的清晰度会影响最终效果。
2.2 目录结构与数据集划分策略
我用过的最可靠的方案是torchvision.datasets.ImageFolder的标准目录编排方式,就是按类别建文件夹,图片按类放入,文件结构如下:
dataset/ ├── train/ │ ├── class_a/ │ │ ├── img1.jpg │ │ ├── img2.jpg │ └── class_b/ │ └── img101.jpg └── val/ ├── class_a/ │ ├── val_img1.jpg └── class_b/ └── val_img10.jpg划分比例上,我一般按 8:2 切分训练集和验证集。如果你数据量本身就不大(比如每个类别不到 100 张),可以尝试 9:1,但验证集至少留出每类 15~20 张,不然评估指标波动会很大,一个 batch 的偶然误差就能让准确率跳好几个点。
顺带说一个关键细节:一定不要在数据读取的时候做随机采样划分,而是要先划分好文件夹再交给ImageFolder去读取。实际项目里我一开始图省事,直接用random_split在内存里切,结果验证集的分布和训练集几乎重合,评估结果虚高,换到真实场景就露馅。用文件夹隔离的方式更稳,还能保证每次训练和评估看到的数据是完全一致的。
2.3 数据增强与归一化配置注意事项
数据增强在自定义小数据集上的重要性,怎么强调都不为过。
from torchvision import transforms, datasets train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), 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]) ])这里有两个数据增强细节必须留意。第一,验证集不要做随机增强,只做 Resize 加 CenterCrop,这样才能保证评估结果的稳定性和可复现性。第二,VGGNet 在 ImageNet 上预训练时输入的标准化参数就是均值[0.485, 0.456, 0.406]、方差[0.229, 0.224, 0.225],这套数值在绝大多数迁移学习场景下不要改,改了你就是在和预训练权重对着干。
关于RandomResizedCrop的 scale 参数,我从经验上来说scale=(0.7, 1.0)比默认的(0.08, 1.0)更适合小数据集。默认值裁剪得太狠,很多图片的主体对象可能被裁掉大半,小数据集上模型反而学不到有效特征。如果你的分类对象本身在图中就占据较小区域,可以考虑用RandomResizedCrop加上RandomRotation(degrees=10),但要小心某些旋转后面临方向性的对象会被错误标记。
3. VGGNet 模型原理与 PyTorch 实现
3.1 VGGNet 的结构特点和设计取舍
VGGNet 之所以到今天仍被很多个人项目用作分类基准,核心在于它证明了“用小卷积核堆深度”这条路是走得通的。连续多个 3×3 卷积堆叠,在感受野上等价于一个更大的卷积核,但参数量更少,而且中间夹杂的 ReLU 激活让网络具备了更强的非线性拟合能力。
最常用的 VGG16 结构包含 13 个卷积层和 3 个全连接层,卷积部分分成 5 个 stage,每个 stage 后接一次 2×2 的最大池化来缩小特征图尺寸。最后一个池化输出的特征图是 7×7×512,经过 Flatten 后变成 25088 维向量,再进入全连接层。
这里要特别提醒一个容易翻车的点:正因为 VGG 前两个全连接层的维度高达 4096,模型体积和计算量都非常大。如果直接从头训练,哪怕图像分辨率只有 224,一张 224×224 的图片也要经过五个池化层逐级缩小,前向传播的耗时和显存占用都肉眼可见地增长。这也是我强烈建议你优先用预训练权重而不是从头训练的根本原因。
3.2 迁移学习适配自定义分类数量
用 PyTorch 加载 VGG16 并适配自己的分类数,代码上非常直接。
import torchvision.models as models model = models.vgg16(pretrained=True) for param in model.features.parameters(): param.requires_grad = False num_features = model.classifier[6].in_features model.classifier[6] = torch.nn.Linear(num_features, num_classes)这里需要解释两个设计决定背后的逻辑。
第一,为什么冻结特征提取层只训练分类器。预训练权重在 ImageNet 上已经学会了非常通用的边缘、纹理、形状特征,这些特征对绝大多数视觉分类任务都是有效的。小数据集上你只需要让最后的全连接层学会如何组合这些特征,这比从头训练要稳定得多,收敛也快得多。我实测在几千张图的场景下,微调分类器只需要几十个 epoch 就能达到一个不错的效果。
第二,为什么只替换最后一个全连接层。model.classifier是包含三个全连接层的 Sequential 结构,索引 6 对应的就是最后那个输出维度为 1000 的层。替换成自己的分类数即可。中间那两个 4096 的全连接层保留预训练参数,它们相当于特征融合器,不需要改动。
有的朋友可能会考虑把输入分辨率提高来获取更多细节。从操作可行性的角度,对 VGGNet 这种全卷积部分加全连接的网络,224 是标准输入,超过 224 的话你可以把最后一个全连接层的输入维度重新 Flatten 后调整,但计算量暴涨,小数据集上往往得不偿失。我自己试过 256 输入,训练时间几乎翻倍,最终准确率提升却非常有限。
3.3 训练配置的选择依据
PyTorch 中对应 VGG16 的实例化,如果你显存吃紧,还可以考虑vgg11或者vgg16_bn。批量归一化层能加速收敛,但显存占用也略增。
import torch import torch.nn as nn device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.classifier.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5)关于优化器的选择,我提一个有参考价值的细节:当你的训练数据量在每类几百到一千张这个区间时,在迁移学习框架下用 Adam 配合较低学习率,通常比 SGD 更容易在短时间内达到平滑收敛。因为整体参数量小、训练数据少,动量的精细调参带来的收益远不如 SGD 在大数据场景下明显。
VGG16 在 ImageNet 上的参数量超过 1 亿,直接在全量参数上做反传,显存消耗非常夸张。我见过有人用 8G 显存的卡跑 VGG16 的完整微调,batch size 只能压到 8 左右,结果批归一化统计量都不稳定。冻结特征层之后,参与训练的参数骤减到最后一层线性层和可能微调的分类器,显存压力小得多,batch size 起步 32 没有问题。
4. 训练全流程实现与关键参数剖析
4.1 数据加载器的实现细节
用ImageFolder加载我们前面构建好的目录结构,配合DataLoader提供批量读取。
from torch.utils.data import DataLoader train_dataset = datasets.ImageFolder('dataset/train', transform=train_transform) val_dataset = datasets.ImageFolder('dataset/val', transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True)两个配置参数的作用不要忽略了。shuffle=True对训练集是必须的,这样每个 epoch 都能让模型见到不同顺序的数据,避免由于同类数据连续出现导致的梯度更新方向偏差;验证集不需要打乱。num_workers在 Windows 上如果设置大于 0 偶尔会报错,在主流环境或者容器里一般没有问题,如果内存紧张就调回 0。
pin_memory=True对 GPU 训练几乎是无脑开的,它把数据预先锁定到页锁定内存,数据从主机到设备的拷贝效率更高。但要注意它需要足够的物理内存支撑,如果你的机器只有不到 8G 内存而数据吞吐量又大,也可以关掉。
4.2 训练循环代码的实现
训练循环是这套流程的主干,我给出的代码每次迭代都会同时计算训练损失和当前 batch 的准确率,并在每个 epoch 结束后在验证集上做一次完整评估。
from tqdm import tqdm num_epochs = 30 best_acc = 0.0 for epoch in range(num_epochs): model.train() running_loss = 0.0 correct_train = 0 total_train = 0 for images, labels in tqdm(train_loader): images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total_train += labels.size(0) correct_train += (predicted == labels).sum().item() train_acc = 100.0 * correct_train / total_train train_loss = running_loss / len(train_dataset) model.eval() correct_val = 0 total_val = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total_val += labels.size(0) correct_val += (predicted == labels).sum().item() val_acc = 100.0 * correct_val / total_val if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model_vgg16.pth') print(f'Epoch {epoch+1}: saved best model, val_acc = {val_acc:.2f}%') print(f'Epoch {epoch+1}/{num_epochs} | ' f'Train Loss: {train_loss:.4f} | ' f'Train Acc: {train_acc:.2f}% | ' f'Val Acc: {val_acc:.2f}%') scheduler.step()这个循环里有三个问题需要专门讲透。
第一,训练阶段必须用一个独立的best_acc变量来记录迄今为止最高的验证集准确率,并且只在刷新最优记录时保存模型。如果每个 epoch 都保存或者只在最后一个 epoch 保存,你会遇到过拟合退化的问题——很多小数据集的模型训练后期在验证集上会回落,最后一个 epoch 的模型未必是最优的那个。我复盘过不少训练记录,最好的验证集结果经常出现在第 20 个 epoch 左右,第 30 个 epoch 时已经有些退化。
第二,为什么要把验证和训练准确率分开打印而不是只在每 N 个 epoch 打印一次。这是帮助你判断过拟合拐点的最直观途径。当训练准确率持续走高而验证准确率开始持平或掉头,就说明模型对训练数据开始“背诵”了,理论上应该停止训练或降低学习率。我在实践中发现,小数据集上这个拐点出现得远比想象的早,比如 VGG16 + 每类 200 张图,往往十几轮就开始出现过拟合迹象,单靠看损失曲线是分不清这个区别的。
第三,model.eval()的调用不是可选项。特别是如果你最终选择vgg16_bn这种带批归一化的结构,不切换到 eval 模式的话,批归一化层会继续用当前 batch 的统计数据做归一化,验证结果会出现令人困惑的抖动和虚高。踩过一次这个坑的人应该都懂。
4.3 学习率计划和训练轮次的调参思路
我之前给出的StepLR(step_size=10, gamma=0.5)是一种非常稳的学习率衰减方案,每 10 个 epoch 学习率减半,让模型在粗调阶段用较高学习率快速下坡,在精调阶段用低学习率压实最优区域。
实际调参时我的建议是:不要一上来就追求一次跑出最好结果,先用 10 个 epoch 观察训练趋势。如果 loss 在前两三个 epoch 内没有明显下降,大概率是学习率设高了或者数据预处理出了问题;如果 loss 降得飞快但在验证集上完全没有相应提升,则要考虑是否过拟合开始得太早,此时可以调小step_size或者把gamma改成 0.3。
训练轮次也不是越多越好。在冻结特征层只训练分类器的场景下,30 个 epoch 一般足够。如果做全网络微调,那学习率要降到1e-4甚至1e-5,同时训练轮次可以适当拉长,但风险也随之提高,需要配合更频繁的验证集监控。
5. 模型测试与推理阶段的核心环节
5.1 用测试图像做完整推理
训练完成后,加载保存的模型权重,用一张不在训练和验证集里的独立图片做分类测试。这里的代码其实非常简短:
from PIL import Image import torchvision.transforms.functional as TF def predict_image(image_path, model, class_names, device, transform): image = Image.open(image_path).convert('RGB') image_tensor = transform(image).unsqueeze(0).to(device) model.eval() with torch.no_grad(): outputs = model(image_tensor) probabilities = torch.nn.functional.softmax(outputs, dim=1) top_prob, top_class = torch.max(probabilities, dim=1) class_name = class_names[top_class.item()] confidence = top_prob.item() * 100 return class_name, confidence这里class_names对应ImageFolder的class_to_idx映射关系的反向索引,在训练时获取,在测试时传入。
5.2 混淆矩阵与错误样本分析
只看单个测试图或者只靠准确率一个指标,在小数据集上容易误判模型的真实水平。我每次都会额外输出混淆矩阵来观察模型的偏置情况。
import numpy as np from sklearn.metrics import confusion_matrix all_preds = [] all_labels = [] model.eval() with torch.no_grad(): for images, labels in val_loader: images = images.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) print(cm)混淆矩阵的价值在于,它告诉你模型到底在哪些类别之间容易搞混。比如你的数据里有两个外观高度相似的类,它们被互相混淆的频率会明显高于其他配对,这直接指出了一个方向:该回去补充此类别的训练样本,或者考虑在该类别上做更多的针对性数据增强。
对于我经历的一个具体项目,数据里包含“猫和狗”这类跨品种分类时,错误往往发生在特定毛色、特定姿势的图片上,用混淆矩阵一眼就能看出来。光靠准确率,你是看不出这种局部偏见的。
5.3 训练验证与测试三段数据的一致性
这一节要讲一个很多项目里最后功亏一篑的细节:训练时用的数据预处理流程,在预测时必须一字不差地复刻。
训练时做了Resize(256)加CenterCrop(224),那预测时对任意一张新图也必须走完全相同的前置缩放和裁剪流程,再归一化到同样的均值和方差。如果你训练时用RandomResizedCrop,预测时当然不能用它,因为随机裁剪没有确定性,要换成中心裁剪方式的等价流程,保持输入图像的分布与训练时尽量一致。
我遇到过一个实际案例:训练时始终用 3 通道 RGB 输入,到了推理阶段试了一张 PNG 带透明通道的图,直接按四通道输入,形状对不上,报了一堆错。检查源头,就是 PIL 读图时没有.convert('RGB')。这类小问题虽不涉及模型逻辑,但足够消耗你一下午的排查时间。
6. 训练过程中的典型问题与排查经验
6.1 损失不下降或剧烈震荡
模型训练时 loss 完全不下降或者像锯齿一样来回震荡,主要有三种可能性。
第一种,学习率设置不合理。如果学习率过大,梯度更新跨度过大,loss 会在高位震荡,这时候观察训练集自身的准确率也是起伏不定的。把lr降到1e-4或1e-5重新跑几个 epoch 就能看到平滑的下降曲线。
第二种,数据本身太随机且增强过强。如果你对每张图都做了非常激进的色彩抖动和随机裁剪,某些样本已经被增强得几乎看不出原始类别,模型接收的信息过于混乱,也会表现为 loss 不降。这种情况下把增强强度调回温和档位,比如只保留水平翻转和小范围随机裁剪,通常能解决。
第三种,标签与图像内容存在错配。此前我处理某一份爬取的数据时,发现不少图片的分类和内容确实对不上,比如夹带了不少与类别无关的图像,结果怎么调学习率都是白费。这种问题靠训练曲线看不出来,只能靠抽样可视化训练数据来纠察。
6.2 GPU 显存不足与训练速度缓慢
VGGNet 是一个非常“吃显存”的网络结构,即使冻结了特征层,前向传播时的中间特征图依然占着显存。显存不足最常见的报错是CUDA out of memory,常规手段是先降低batch_size,但还有两个不那么直观的操作。
一个是关闭梯度计算来降低峰值显存。如果只做验证与预测阶段,可以在上下文管理器torch.no_grad()内执行前向传播,它不会为中间激活保留额外显存空间。另一个是全网络微调模式下,可以考虑开启混合精度训练,配合torch.cuda.amp的GradScaler,显存占用能直接降到原来的六成左右,速度还有提升。
如果你发现显存充足但训练速度依然很慢,检查num_workers是否太低导致数据加载成为瓶颈。num_workers设为 4 或 8 时,CPU 会并行解码和增强图像,大幅缩短单个 epoch 的等待时间。
6.3 模型泛化能力不足的改善路径
验证集准确率始终不理想,甚至训练集准确率也不高,排除标签错误和代码 bug 后,基本就是数据量本身不够模型学。这里有几条经过验证的可行路径:
- 数据增强强度拉高一档,加入随机旋转、随机尺度变化、遮挡模拟等手段。
- 先冻结特征层训练多轮后,再解锁最后的卷积 stage 做低学习率微调。
- 更换更强的预训练骨干,但保留 VGG 的分类头逻辑。
- 如果样本实在太少(每类不足 50 张),优先收集更多数据,增强算法解决不了统计意义上的信息不足。
其中解锁部分特征层微调是一个非常好的折中方案。操作上很直接:
for name, param in model.named_parameters(): if 'features.24' in name or 'features.26' in name: param.requires_grad = True optimizer = torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-5 )这样做的意义是:底层的通用特征(边缘、简单纹理)没必要再动,而靠近分类器的高层特征可以针对你的数据域做一定适应,同时控制了可训练参数量,过拟合风险也低。
7. 实测效果与心得总结
直接说说用这套流程跑一个真实小数据集的表现。类别数是 5,每类大约 180 张训练图、40 张验证图,用的 VGG16 加冻结特征层训练 30 个 epoch。最终验证集准确率在 87% 到 91% 之间波动(不同批次的数据增强随机性会有影响),单 epoch 在普通显卡上只需要几秒。整个训练流程流程跑下来,包括中间踩坑的时间,大概一个下午就完成了。
对比方案:用同样数据从头训练 VGG16,至少需要 60~80 个 epoch 才能达到类似精度,而且训练曲线震荡剧烈,换一次随机种子结果差异就很大。预训练权重的迁移学习方案在稳定性和时间成本上都有压倒性优势。
个人来说我最想强调的还是数据准备的质量。同一套模型代码,数据干净规范和混入噪声的乱序排列,最终准确率可以相差 20 个百分点以上。很多人执着于调模型结构或学习率,却忽略了数据本身才是决定分类性能的上限来源。
如果后续想继续扩展这个项目,可以考虑的方向包括:引入混淆矩阵来做针对性数据补充,把分类结果加上 Grad-CAM 可视化判断模型是否在关注图中的合理区域,或者更换更强的骨干网络对比效果差异。VGGNet 作为经典架构,在这个任务里更多是提供一个稳定扎实的基线,但它带来的训练流程和数据管理经验,是换到任何其他模型都通用的核心资产。