ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vgg16实战CIFAR10:从零搭建图像分类模型全流程指南

Vgg16实战CIFAR10:从零搭建图像分类模型全流程指南

Vgg16跑CIFAR10,这个项目我前前后后完整做过不止一回。说它入门,它确实在不少深度学习框架的教程里都有影子;说它简单,真正动手从零搭一遍,你会发现网络结构和输入尺寸的配合、预训练权重的取舍、训练超参数的调节,处处都有隐藏的坑。当时我拿最朴素的思路,直接照着Vgg16的网络结构写了一版模型,然后往CIFAR10上套,结果第一轮训练就报了维度不匹配的错误。后来才反应过来,一个给224x224大图设计的网络,直接接32x32小图,中间所有尺寸变化都要重新算一遍。

这个项目适合刚学完卷积神经网络基础、想完整跑通一个图像分类任务的人,也适合做课程设计、练手项目或准备后续研究的人。它能帮你把一个经典的卷积网络从网络定义、数据加载、训练循环到调参排错全链路走一遍。文章里的代码我都实际跑过,你可以直接参考复现,也可以根据自己机器的显存和算力来调整,核心思路是不变的。

1. 项目思路拆解:Vgg16与CIFAR10的组合逻辑

1.1 Vgg16的网络结构与核心优势

Vgg16名字里的“16”指的是16层带权重的层,具体是13个卷积层加3个全连接层。卷积部分的规则非常统一:全部使用3x3大小、步长为1、padding为1的卷积核,卷积之后接ReLU激活,每经过两到三轮卷积后接一个2x2的最大池化。通道数从最开始的64一直翻倍到128、256、512,整体像一只规整的漏斗:空间尺寸不断减半,通道数不断加宽。

这个结构放在今天看不算花哨,但它的好处恰恰是规则,方便理解和复现。两个3x3卷积堆叠,感受野等价于一个5x5卷积;三个3x3卷积堆叠,感受野等价于7x7卷积,但参数更少、非线性表达能力更强。CIFAR10的图像只有32x32,理论上有几层卷积就够用,但Vgg16这种逐层压缩空间尺寸、扩展通道数的策略,能让人把卷积网络提取特征的过程看得清清楚楚:前面几层学会颜色和边缘,中间层组合出纹理,最后几层提炼出能分类的高层语义特征。

我选它还有一个现实原因:资料多、复现容易。无论是自己手写结构,还是调用现成实现,Vgg16的每一层参数都能在网上找到对照。真出了问题,打印每一层的shape,一眼就能看出来从哪一步开始不对,对新手的排查很有帮助。

1.2 CIFAR10数据集特点与训练难度

CIFAR10是图像分类里最常见的基准数据集之一,包含10个类别,每张图片是32x32的RGB彩色图。整个数据集一共60000张,其中50000张是训练集、10000张是测试集,每个类别各有6000张。类别包括飞机、汽车、鸟类、猫、鹿、狗、青蛙、马、船、卡车,有些类别之间很接近,比如猫和狗、汽车和卡车。这意味着模型不能只靠颜色或者粗略轮廓来区分,必须学到细粒度的纹理结构。

不少新手会觉得图小、类别少应该很容易,实际训练一圈下来往往会发现没那么简单。Vgg16有上亿级别的参数,却要面对只有50000张的训练数据,如果什么都不做,训练集准确率很快逼近100%,测试集准确率却卡在80%以下,典型的过拟合。想要拿到能看的测试精度,数据增强和正则化基本是必须的,这一条经验也是我个人最想强调的:CIFAR10的训练难点从来不是网络前向计算不出来,而是怎么让大网络在小数据集上不“背题”。

1.3 技术栈选型与两种常见训练路线

我这次用的是PyTorch加torchvision。选PyTorch没有特别复杂的理由,就是调试方便:模型结构清晰,前向过程能逐步打印,断点哪里都能停。torchvision里自带CIFAR10数据集接口和Vgg16预训练权重,省去很多下载和格式转换的麻烦。如果你习惯用TensorFlow或Keras,完全也可以,但核心的“网络尺寸适配”问题是一样的。

在正式开始前,先说清楚两条常见的训练路线:

  • 路线A:加载ImageNet预训练权重,把输入图resize到224x224,保留Vgg16原始结构,只替换最后一层分类器为10类。
  • 路线B:保持CIFAR10原始的32x32输入,修改Vgg16的分类器部分,从零开始训练。

很多人第一反应是预训练模型一定更香,我的实测结论还真不一定。预训练权重是在224x224的ImageNet大图上学出来的,CIFAR10这种32x32小图被强行拉伸到224,图像细节损失很大,训练单轮耗时明显增加,显存占用也上了一个台阶。而路线B虽然是从零开始,但输入尺寸合理,单轮训练快,配合数据增强,测试精度完全能和路线A打平甚至反超。后面我会把两条路线的代码和参数都给出来,你自己对比一次就明白了。

2. 环境准备、数据加载与预处理细节

2.1 环境配置与依赖安装

先说我本地的运行环境:Python 3.10,PyTorch 2.x,torchvision 0.17,CUDA可用。如果你没有独立显卡,纯CPU也能跑,但千万别一上来就开200个epoch,建议先把epoch降到40到50,或者把batch size调小一点,否则训练时间会让人失去耐心。

安装依赖很简单,直接用pip:

pip install torch torchvision

如果你用的是Windows,我更推荐用Anaconda装,因为conda会把CUDA相关运行库一起装好,省去手动配环境变量的麻烦。装好后可以跑一下下面的命令验证环境:

import torch print(torch.__version__) print(torch.cuda.is_available())

cuda.is_available()返回True,说明可以走GPU训练;如果返回False,先确认CUDA驱动是否安装、PyTorch版本是否带CUDA支持,这一步比很多人想的重要,因为我见过不少朋友环境装上能用,但跑起来才发现一直用的是CPU版本。

2.2 数据集下载与归一化处理

CIFAR10的下载在torchvision里非常简单,第一次运行时它会自动把数据下载到本地目录。它原始的文件格式不是一张张JPG图片,而是序列化好的数组文件,torchvision都封装好了,你不需要去官网手动找下载链接。

加载和预处理代码分成训练集和测试集两套,注意测试集的transform里不要加随机增强:

from torchvision import datasets, transforms transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) train_dataset = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train) test_dataset = datasets.CIFAR10(root='./data', train=False, download=True, transform=transform_test)

这里有一个很容易忽略的点:Normalize的均值和标准差不能随便写,CIFAR10有一组通用的RGB均值(0.4914, 0.4822, 0.4465)和标准差(0.2470, 0.2435, 0.2616)。把它理解成对不同评分科目做标准化,先减去平均分,再除以标准差,让所有通道的数值都在同一量级。如果跳过这一步,模型训练初期loss会异常高,收敛也慢不少。

2.3 DataLoader与数据增强配置

数据增强这一节,建议新手优先做两个基础操作:RandomCrop和RandomHorizontalFlip。RandomCrop用padding=4,是因为32x32的图太小,直接裁剪容易丢掉边角信息,所以先把图padding到40x40,再随机裁回32x32,相当于模拟了物体位置的扰动。水平翻转对绝大多数类别是合理的,猫左右翻还是猫,车左右翻还是车。

如果你想追求更高的精度,后期可以再加CutOut、MixUp这类更强的增强,但Vgg16在CIFAR10上用前两个基础操作已经能有明显提升,先把它们吃透再上复杂方案。

DataLoader的参数也值得注意:

from torch.utils.data import DataLoader train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=4, pin_memory=True) test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False, num_workers=4, pin_memory=True)

训练集必须shuffle,否则模型每个epoch看到的数据顺序都一样,会影响收敛稳定性;测试集不需要shuffle,因为只是评估。num_workers表示数据加载的子进程数量,一般设成CPU核心数的一半左右,太小会成瓶颈,太大占用内存又太高。pin_memory=True能加快GPU训练时数据从内存到显存的传输速度,建议养成习惯加上。

3. 模型搭建与输入适配的核心实现

3.1 从卷积到全连接:Vgg16逐层拆解

我手写了一个简化版的Vgg16,去掉了BatchNorm,保持原始论文的结构。这样可以更清楚地看到每一层的作用:

import torch import torch.nn as nn class VGG16(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( # Block 1 nn.Conv2d(3, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(64, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # Block 2 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(128, 128, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # Block 3 nn.Conv2d(128, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(256, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(256, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # Block 4 nn.Conv2d(256, 512, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(512, 512, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(512, 512, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # Block 5 nn.Conv2d(512, 512, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(512, 512, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(512, 512, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), ) self.avgpool = nn.AdaptiveAvgPool2d((1, 1)) self.classifier = nn.Sequential( nn.Linear(512, 4096), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(4096, 4096), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(4096, num_classes), ) def forward(self, x): x = self.features(x) x = self.avgpool(x) x = torch.flatten(x, 1) x = self.classifier(x) return x

如果你好奇这五段卷积在32x32输入下到底发生了什么,流程是这样的:输入3x32x32,第一段池化后变成64x16x16,第二段后128x8x8,第三段后256x4x4,第四段后512x2x2,第五段池化后正好512x1x1。这个1x1的结果很关键,决定了后面全连接层怎么改。

3.2 32x32输入的全连接层适配方案

这里是我觉得这个项目最值得搞清楚的部分,也是我一开始踩坑的地方。原始的Vgg16输入是224x224,经过五段池化之后,最后一层卷积输出的特征图是7x7x512,展平后得到25088维向量,然后接4096维的全连接层。

如果直接把32x32的图喂进去,最后得到的特征图是1x1x512,展平后只有512维。很多人习惯用torchvision自带的models.vgg16(pretrained=True),它内部默认有AdaptiveAvgPool2d((7, 7)),就算输入是1x1的特征图,它也会强行把输出变成7x7,但这7x7里的每一个值都是一样的,因为1x1输入根本没有空间信息可供聚合。结果就是全连接层拿到25088个高度重复的数值,有效信息其实只有512维,参数多、计算量大、效果还差。

所以我在这版代码里把池化层改成了AdaptiveAvgPool2d((1, 1)),让展平后的维度变成512,分类器第一层也对应从512开始。这样32x32的输入能跟网络结构完全匹配,既没有无效的重复特征,也不会报维度错误。

两条路线的对比,我整理成了一张表:

方案输入尺寸是否用预训练分类器第一层输入典型场景
路线A224x224是25088图比较大、追求快速收敛、显存充足
路线B32x32否(可选择性加载卷积层)512算力有限、想跑通完整流程、训练速度快

如果你想在路线B里也要一点预训练的好处,可以只加载卷积层的权重,分类器仍然随机初始化。代码可以这样写:

import torchvision.models as models pretrained = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) features_state = {k: v for k, v in pretrained.state_dict().items() if k.startswith('features.')} model = VGG16(num_classes=10) model.load_state_dict(features_state, strict=False)

这里的关键是strict=False,允许部分key缺失。但说实话,我用下来感觉这个操作的收益比较有限,因为ImageNet特征是大图学出来的,直接用在32x32小图上不一定匹配。想省时间可以先试,但不建议把它当成默认选择。

3.3 损失函数、优化器与评价指标

分类任务损失函数直接用nn.CrossEntropyLoss,它内部已经包含Softmax计算,不需要在模型后面再接一层。优化器我建议用SGD加Momentum,而不是无脑Adam。这个选择可能和很多人习惯相反,但在Vgg16这类结构规整、层数较深的网络上,SGD配合学习率调度,最终精度普遍比Adam高,还没有那么强的泛化性损失。

Adam的优点在于对不同参数的自适应学习率,起步快、调试省心;SGD加Momentum则更像一个稳定前进的过程,虽然前期看似慢,但后期收敛得更扎实。生活化一点理解:Adam像自带导航的自动驾驶,起步方便,但最后一段路未必比老司机开得准;SGD就是那个老司机,路线固定、惯性稳定,终点精度往往更好。

评价指标我用最简单的top-1 accuracy,也就是预测出的类别和真实类别一致的样本比例。等到你后面想细看各类别表现,可以再算precision、recall和混淆矩阵,入门阶段一个准确率就够用了。

4. 训练流程、关键参数调试与实测记录

4.1 训练循环与关键超参数设置

训练循环本身不复杂,但细节不少。下面是我在一台普通消费级显卡上跑通的训练主循环:

import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = VGG16(num_classes=10).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=120) best_acc = 0.0 epochs = 120 for epoch in range(epochs): model.train() total_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() total_loss += loss.item() model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() acc = 100.0 * correct / total if acc > best_acc: best_acc = acc torch.save(model.state_dict(), 'vgg16_cifar10_best.pth') print(f'Epoch {epoch+1}/{epochs}, Loss: {total_loss/len(train_loader):.4f}, Acc: {acc:.2f}%')

主循环分成两部分:训练阶段用model.train()开启Dropout,让随机性参与前向;验证阶段用model.eval()关闭Dropout,再包一层torch.no_grad(),避免保存计算图浪费显存。如果忘记切换模型状态,会出现训练时正常、验证时结果忽高忽低的怪事。

我用的batch size是128,如果你的显存只有4G,可以降到64或32。学习率也要跟着调整:batch size缩小一半,学习率大概降到原来的0.5到0.7倍,否则梯度更新步长过大,loss容易震荡。

4.2 学习率策略与正则化细节

学习率是整个项目里对结果影响最大的超参数。SGD配0.01是一个常见起点,但别让学习率一直不变地跑到底。我习惯用CosineAnnealingLR,它会把学习率从初始值按余弦曲线逐渐降到接近0。前期学习率大,模型能快速找到大致方向;后期学习率小,参数在局部最优附近慢慢精细调整。相比之下,阶梯式下降也没有问题,但需要人为确定在哪个epoch降,多一个要调的参数。

权重衰减weight_decay也是经验值,我用了5e-4,配合Dropout一起缓解过拟合。Vgg16的两个全连接层中间本来就有Dropout,比例为0.5,这个原始设计保留了即可。如果你观察到自己训练集准确率接近99%,而测试集准确率只有86%上下,基本就是过拟合信号,优先检查数据增强够不够、Dropout有没有被去掉,而不是无限加epoch。

有一次我给模型增加了一个全连接层,却漏了Dropout,结果测试集准确率跑了几轮都在84%附近打转。加回0.5的Dropout之后,很快突破到89%以上。这类正则化细节在结构简单的小网络上可能不明显,在Vgg16这种参数很多的网络上差距非常直观。

4.3 实测训练结果与调参记录

我用上面的代码跑了一轮参考实验,提前说明真实数值会随机器、随机种子、PyTorch版本有浮动,但趋势是一致的。

在batch size为128、epoch为120的设置下,我得到的大致过程是:前面10个epoch,测试集准确率快速从30%左右升到80%附近;第30个epoch左右能到88%;第60到80个epoch来到91%上下并开始明显变慢;第120个epoch结束时,测试集准确率稳定在92%附近,训练集准确率接近99%。如果把epoch延长到180到220轮,还能冲一冲93%,但收益已经很小,性价比不高。

如果采用路线A,也就是加载预训练权重并resize到224x224,前期会非常快,20个epoch左右就能到90%,但单轮训练时间大概是路线B的两倍多,显存占用也明显增加,最终精度并不会高出多少。所以我的建议很直接:如果这是你的第一个完整图像分类项目,从路线B开始更合适,整体闭环短、迭代快,遇到问题也能更快定位。

5. 常见问题与排查技巧实录

5.1 维度不匹配:最常见的运行时错误

项目里反馈最多的报错,多半是维度不匹配。我见过两类:一类是用了torchvision自带Vgg16,只改最后一层分类器为10,但输入是32x32,前面提到的问题就出现了;另一类是手写了分类器,第一层线性层输入维度跟实际展平后的特征维度对不上。

排查思路按顺序来:

  1. 先造一个dummy输入,打印模型输出尺寸:model(torch.randn(1, 3, 32, 32)).shape,看是[1, 10]还是其他形状。
  2. 再打印卷积部分的输出:model.features(torch.randn(1, 3, 32, 32)).shape,确认最后一层特征图是1x512x1x1还是其他尺寸。
  3. 根据实际展平维度设置分类器第一层Linear的输入,别凭感觉照抄原始Vgg16的25088。
  4. 检查pooling层,如果用了AdaptiveAvgPool2d((7, 7)),展平维度就是25088;如果用了(1, 1),展平维度就是512。

这一类问题一旦理解了尺寸变化过程,以后碰到任何自定义网络都不慌了。

5.2 训练不收敛或loss变为NaN怎么办

训练不收敛的现象主要是loss不降、或者降几步就停在某个值不动。我按可能性从高到低列一下排查项:

  • 检查数据归一化是否用了正确的均值和标准差,没归一化时第一轮loss会非常大。
  • 检查学习率是否不匹配:SGD初始0.01跑不动,可以先降低到0.001试;如果loss剧烈震荡,通常是学习率偏高。
  • 检查模型有没有在训练前调到train()状态,以及验证时有没有调到eval()。
  • 检查类别数是否匹配:CIFAR10是10类,最后一层Linear的输出必须是10。

loss变成NaN通常更简单,绝大多数是学习率过高导致梯度爆炸,或者某条数据里出现了异常值。这时候可以把学习率降低一个数量级,再给梯度加个裁剪:

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5)

如果还是NaN,检查一下DataLoader读到的label是否有越界或空值。CIFAR10本身数据很干净,一般不会出问题。

5.3 显存不足与训练速度优化

显存爆掉最常见于路线A,因为224x224输入比32x32大得多,中间特征图占显存明显更多。如果你坚持用路线A,又遇到OOM,优先把batch size从128降到64或32,代价是训练变慢一点,但至少能跑通。

路线B本身很省显存,但也要注意几点:训练过程中尽量只保存一个最优模型,不要每个epoch都torch.save,否则模型文件占满磁盘;验证阶段一定记得用with torch.no_grad();num_workers不是越大越好,设成CPU核心数的一半左右比较合理,太大反而会因进程调度开销拖慢速度。

另外,数据增强是在CPU上做的,如果训练循环里发现CPU占用没有打满,可以考虑用手动写的数据读取类,但一般没必要。相比之下,pin_memory=True是性价比很高的提速手段,强烈建议保留。

5.4 一份可直接复现的训练配置清单

为了让读者少走弯路,我把上面所有参数汇总成一张清单,你可以直接抄作业:

配置项推荐值
数据集CIFAR10,50000训练图 + 10000测试图
输入尺寸32x32(路线B)
数据增强RandomCrop(32, padding=4) + RandomHorizontalFlip
归一化均值(0.4914, 0.4822, 0.4465)
归一化标准差(0.2470, 0.2435, 0.2616)
batch size128,显存小可降为64或32
优化器SGD,momentum=0.9
初始学习率0.01(batch size减小时对应降低)
weight decay5e-4
学习率调度CosineAnnealingLR,T_max=120
epochs120,想要更高精度可加到180~200
Dropout两个全连接层之间各0.5

这套配置我复现过多次,稳定跑到90%以上没有悬念。如果你用的是不同框架,参数本身不变,只需要把优化器和调度器的写法翻译过去。

最后再分享一个我个人的习惯:每次改动数据增强、学习率策略、网络结构,都单独记录一版结果,不要凭感觉混在一起调。这个项目最好的地方是训练周期短,单次实验成本低,特别适合做控制变量。等它在CIFAR10上稳定复现了,后续想换更强的增强方法、做迁移学习微调,或者把同样的适配思路套到其他经典网络上,都会顺很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进