简介:本资源面向深度学习与人工智能方向的实验学习者,聚焦经典卷积神经网络VGG19的图像分类实践,适合已具备一定CNN基础、希望理解模型部署与推理优化的学生或开发者。压缩包共4个文件,包含3个Python脚本与1个pb模型文件,整体约95.86MB,其中脚本分别用于CPU与MLU平台上的模型评估,pb文件为INT8量化后的VGG19权重,便于在资源受限或嵌入式环境中加载运行。实验围绕VGG19的19层3x3卷积堆叠结构展开,涵盖图像预处理、前向传播、全连接与Softmax分类,并对比CPU与MLU两种硬件下的推理性能差异,同时涉及INT8量化对内存占用与速度的优化。已有2140人学习,可帮助读者掌握模型量化、硬件加速与跨平台评估的完整流程,理解VGG19的运作机制及实际部署中的性能权衡。
1. 从一次“模型不收敛”的深夜调试说起
带过几届智能计算系统实验之后,我发现一个规律:大部分同学卡住的地方,不是反向传播推不明白,而是把 VGG19 搬进自己的图像分类任务时,训练 loss 死活不降。明明网络结构是现成的,预训练权重也加载了,为什么效果还不如自己随手搭的三层卷积?问题往往出在几个不起眼的环节——输入尺寸没对齐、分类头没换对、BatchNorm 的track_running_stats在微调时没处理好。这个实验的核心,就是让你亲手把 VGG19 从“论文里的名字”变成“能跑在自己数据集上的分类器”。它适合正在做智能计算系统课程实验的学生,也适合想系统走一遍迁移学习完整流程的开发者。读完你至少能拿到三样东西:一套可复现的训练脚本、一份对 VGG19 结构逐层拆解的理解、以及一套遇到不收敛时能自己排查的思路。
2. VGG19 结构拆解:从 224×224 输入到 1000 维输出
2.1 为什么是 VGG19 而不是 VGG16
VGG 系列的核心设计哲学非常朴素:用连续的小卷积核(3×3)堆叠来替代大卷积核,在感受野不变的前提下减少参数量、增加非线性。VGG16 包含 13 个卷积层和 3 个全连接层,VGG19 则把卷积层增加到 16 个,多出来的三层集中在最后两个卷积块里。多这三层带来的收益是特征表达更细,代价是参数量和显存占用略微上升。在图像分类实验里,VGG19 的预训练权重在 ImageNet 上的 top-5 准确率比 VGG16 高一点点,但真正让它在教学实验里受欢迎的原因是:结构规整到可以用几行代码循环生成,非常适合用来讲“卷积块”这个概念。
整个网络可以拆成五个卷积阶段加一个分类头:
| 阶段 | 层组成 | 输出通道 | 输出尺寸(输入 224) |
|---|---|---|---|
| Block1 | 2×Conv3×3 + MaxPool | 64 | 112×112 |
| Block2 | 2×Conv3×3 + MaxPool | 128 | 56×56 |
| Block3 | 4×Conv3×3 + MaxPool | 256 | 28×28 |
| Block4 | 4×Conv3×3 + MaxPool | 512 | 14×14 |
| Block5 | 4×Conv3×3 + MaxPool | 512 | 7×7 |
| 分类头 | FC4096 + FC4096 + FC1000 | — | 1000 |
每个卷积层后面都跟着 ReLU,这是 VGG 的标配。注意 Block3、Block4、Block5 各包含 4 个卷积层,加起来 2+2+4+4+4=16 个卷积层,加上 3 个全连接层就是 19 层——这就是 VGG19 名字的由来。
2.2 用代码把结构“打印”出来
与其对着论文数层数,不如直接让框架告诉你。下面这段代码用 torchvision 加载预训练 VGG19,并打印每一层的名称和形状:
import torch import torchvision.models as models # 加载预训练 VGG19,首次运行会自动下载权重 vgg19 = models.vgg19(pretrained=True) # 打印完整结构 print(vgg19) # 只打印特征提取部分(卷积块)的层名 for name, module in vgg19.features.named_children(): print(f"features.{name}: {module}")运行后会看到features是一个Sequential,里面按顺序排列着 Conv2d、ReLU、MaxPool2d。classifier则是三个 Linear 层加 ReLU 和 Dropout。这里有个关键点:vgg19.features的输出是一个 512×7×7 的张量,进入classifier之前会被nn.AdaptiveAvgPool2d((7,7))或者直接view展平。如果你要改分类头,改的就是classifier这一部分。
2.3 预训练权重到底带了什么
pretrained=True加载的是在 ImageNet 上训练好的权重。ImageNet 有 1000 个类别,所以最后一层全连接输出是 1000 维。这些权重里,卷积部分的参数学到的是通用视觉特征——边缘、纹理、局部形状,这些特征对大多数图像任务都有用。全连接部分学到的则是 ImageNet 特定的类别判别信息,对你的任务大概率没用。所以迁移学习的标准操作是:保留features的全部权重,把classifier的最后一层换成你自己数据集的类别数,前面的层可以选择冻结或微调。
3. 迁移学习实战:把 VGG19 接到自己的数据集上
3.1 数据准备与增强策略
假设你的数据集是按文件夹组织的,每个类别一个子文件夹,结构如下:
dataset/ ├── train/ │ ├── cat/ │ ├── dog/ │ └── bird/ └── val/ ├── cat/ ├── dog/ └── bird/用torchvision.datasets.ImageFolder可以直接读取。VGG19 要求输入尺寸是 224×224,训练时通常会做随机裁剪、翻转、颜色抖动来增强泛化:
from torchvision import transforms, datasets from torch.utils.data import DataLoader train_transform = transforms.Compose([ transforms.RandomResizedCrop(224), # 随机裁剪并缩放到 224 transforms.RandomHorizontalFlip(), # 随机水平翻转 transforms.ColorJitter(0.2, 0.2, 0.2), # 颜色抖动 transforms.ToTensor(), transforms.Normalize( # ImageNet 的均值和标准差 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] ) ]) train_set = datasets.ImageFolder('dataset/train', transform=train_transform) val_set = datasets.ImageFolder('dataset/val', transform=val_transform) train_loader = DataLoader(train_set, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_set, batch_size=32, shuffle=False, num_workers=4)这里的Normalize参数必须和预训练时一致,否则输入分布偏移会导致预训练权重“水土不服”。RandomResizedCrop的缩放范围默认是 0.08 到 1.0,对小数据集可以适当调高下限,避免裁得太狠丢失目标。
3.2 改造分类头并设置分层学习率
加载预训练模型后,把classifier的最后一层替换掉。同时,卷积部分和全连接部分应该用不同的学习率——卷积部分已经学得很好,只需要微调,学习率要小;全连接部分是随机初始化的,需要正常学习率:
import torch.nn as nn import torch.optim as optim num_classes = len(train_set.classes) # 你的类别数 # 加载预训练模型 model = models.vgg19(pretrained=True) # 冻结 features 部分(可选,数据量小的时候建议冻结) for param in model.features.parameters(): param.requires_grad = False # 替换分类头 model.classifier[6] = nn.Linear(4096, num_classes) # 分层设置学习率 optimizer = optim.SGD([ {'params': model.features.parameters(), 'lr': 1e-4}, # 微调小学习率 {'params': model.classifier.parameters(), 'lr': 1e-3} # 新层正常学习率 ], momentum=0.9, weight_decay=5e-4) criterion = nn.CrossEntropyLoss()model.classifier[6]是最后一个 Linear 层,输入 4096,输出改成你的类别数。如果你选择不冻结 features,那requires_grad保持默认的 True,但学习率要设得比分类头小一个数量级。weight_decay用 5e-4 是 VGG 论文里的推荐值,对抑制过拟合有帮助。
3.3 训练循环与验证
训练循环本身不复杂,但有几个细节决定成败:每轮结束后切换model.train()和model.eval(),验证时用torch.no_grad()关闭梯度计算:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) for epoch in range(20): # 训练阶段 model.train() running_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() running_loss += loss.item() # 验证阶段 model.eval() correct = 0 total = 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 += labels.size(0) correct += (predicted == labels).sum().item() val_acc = correct / total print(f"Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}, Val Acc: {val_acc:.4f}")optimizer.zero_grad()必须放在前向传播之前,否则梯度会累加。model.eval()会关闭 Dropout 并固定 BatchNorm 的统计量,验证时不加这一句,准确率会波动得让你怀疑人生。
4. 避坑与排查:那些让 loss 不降的“玄学”问题
4.1 输入尺寸不匹配导致的全连接层报错
现象:运行时报RuntimeError: mat1 and mat2 shapes cannot be multiplied,提示维度对不上。
原因:VGG19 的classifier第一层 Linear 期望输入是 512×7×7=25088 维。如果你的输入图片不是 224×224,经过五层池化后特征图尺寸就不是 7×7,展平后维度自然对不上。
解决:要么把所有输入统一 Resize 到 224×224,要么在features和classifier之间加一个nn.AdaptiveAvgPool2d((7,7)),强制输出 7×7。后者更灵活,推荐加上。
4.2 BatchNorm 在微调时的“统计量漂移”
现象:冻结 features 后训练,验证准确率始终在随机水平附近徘徊。
原因:VGG19 的卷积层后面有 BatchNorm。当你冻结features的requires_grad时,BatchNorm 的running_mean和running_var仍然会在前向传播时更新。如果微调数据集的分布和 ImageNet 差异较大,这些统计量会被“带偏”,导致预训练权重失效。
解决:冻结 features 时,同时把所有 BatchNorm 层设为 eval 模式,并禁止更新统计量:
for module in model.features.modules(): if isinstance(module, nn.BatchNorm2d): module.eval() module.track_running_stats = False4.3 学习率过大导致预训练权重被“冲毁”
现象:第一轮 loss 就飙到很大,之后一直不降。
原因:分类头是随机初始化的,如果学习率设得和从头训练一样大(比如 0.01),反向传播的梯度会非常大,直接把预训练好的卷积权重冲乱。
解决:分类头用 1e-3,features 用 1e-4 或更小。如果还是不稳定,先用 1e-4 训练几轮让分类头稳定下来,再解冻 features 做微调。
4.4 显存不足导致的“假死”
现象:程序卡住不动,nvidia-smi 显示显存占满但 GPU 利用率 0%。
原因:VGG19 参数量约 1.4 亿,全连接层占了大头。batch_size 设成 32 时,如果图片没提前 resize 到 224,显存很容易爆。
解决:先把 batch_size 降到 8 或 16,确认能跑通后再逐步往上加。另外,num_workers设成 4 以上时,如果 CPU 核心数不够,数据加载会成为瓶颈,表现为 GPU 利用率忽高忽低。
4.5 验证集准确率高于训练集
现象:训练时准确率 70%,验证时反而 75%。
原因:训练时用了数据增强(随机裁剪、翻转),验证时用的是确定性的中心裁剪,任务难度不同。另外 Dropout 在训练时开启、验证时关闭,也会造成这种“反常”现象。
解决:这是正常现象,不用慌。判断模型是否过拟合,看训练 loss 和验证 loss 的差距,而不是准确率。
5. 二次开发:从分类器到特征提取器的改造思路
5.1 把 VGG19 当成骨干网络
VGG19 的features部分本质上是一个特征提取器。你可以把它接到其他任务上,比如目标检测或图像检索。具体做法是:去掉classifier,把features的输出(512×7×7)作为特征图,后面接自己的网络头。如果任务需要更高分辨率的特征图,可以去掉 Block5 的最后一个 MaxPool,让输出变成 512×14×14。
class VGG19Backbone(nn.Module): def __init__(self, pretrained=True): super().__init__() vgg = models.vgg19(pretrained=pretrained) # 取到 Block4 结束,输出 512×14×14 self.features = nn.Sequential(*list(vgg.features.children())[:35]) def forward(self, x): return self.features(x) # 使用示例 backbone = VGG19Backbone() x = torch.randn(1, 3, 224, 224) feat = backbone(x) print(feat.shape) # torch.Size([1, 512, 14, 14])list(vgg.features.children())[:35]这个索引需要根据实际打印的层序号来调整,不同版本的 torchvision 可能略有差异。建议先print出每一层的索引,再决定切在哪里。
5.2 用中间层特征做可视化
VGG19 的每一层卷积都提取了不同抽象级别的特征。你可以注册 hook 来抓取中间层的输出,做特征图可视化:
activation = {} def get_activation(name): def hook(model, input, output): activation[name] = output.detach() return hook # 抓取第一个卷积块的输出 model.features[0].register_forward_hook(get_activation('block1_conv1')) # 前向传播一张图片 model.eval() with torch.no_grad(): _ = model(images[:1].to(device)) # 查看激活值形状 print(activation['block1_conv1'].shape) # torch.Size([1, 64, 224, 224])这个技巧在调试时特别有用:如果某一层的激活值全是 0 或者全是 NaN,说明那一层出了问题。我一般会在训练前先跑一遍可视化,确认每一层的输出范围正常,再开始正式训练。
5.3 模型剪枝与量化尝试
VGG19 参数量大,部署到边缘设备时需要压缩。一个简单的剪枝思路是:对classifier的 Linear 层做 L1 范数排序,把权重接近 0 的神经元剪掉。量化方面,PyTorch 提供了torch.quantization工具,可以把 FP32 权重转成 INT8,模型体积缩小约 4 倍,推理速度提升 2 到 3 倍。不过量化对 VGG 这种全连接层占大头的模型效果有限,因为全连接层的量化误差比卷积层更难控制。我一般会先剪枝再量化,两步走比一步到位稳。
5.4 一个我踩过的坑
有一次我把model.classifier[6]替换成新的 Linear 层后,忘了把新层的requires_grad设为 True。结果训练了 10 轮,loss 几乎没变。排查了半天才发现,新层默认是requires_grad=True,但我在前面冻结 features 时写了一句for param in model.parameters(): param.requires_grad = False,把新层也冻上了。从那以后我每次改完分类头,都会强制打印一遍所有需要梯度的参数名,确认新层在里面。这个习惯帮我省下了不少深夜调试的时间。
希望帮到你。
本文还有配套的精品资源,点击获取