VGG深度学习网络:从3×3卷积原理到PyTorch迁移学习实战
简介:VGG深度学习网络框架由牛津大学视觉几何组于2014年提出,以大量3x3小卷积核逐层堆叠来加深网络,在ImageNet分类任务中表现优异,是理解现代CNN架构的基础。这份Python实现资源面向深度学习初学者与研究者,用三个衔接紧密的脚本串起从模型构建到实际预测的完整链路,可帮助快速理解VGG16与VGG19的层数差异、参数规模来源以及分类任务的工程化流程。压缩包共3个文件,全部为.py脚本,总大小仅3KB,但功能完备:一个脚本负责搭建卷积层、池化层和全连接层,一个脚本用于加载数据集、选择损失函数与优化器并完成多轮训练,另一个脚本演示输入图像尺寸调整、归一化预处理及结果解析。已有246人学习下载,适合希望通过阅读代码掌握VGG经典结构,并进一步尝试模型迁移或剪枝优化的读者,是一份轻量而完整的深度学习实践样例。
1. VGG 深度学习网络框架,为什么一个 2014 年的模型仍是 Python 深度学习的第一课
VGG 是 2014 年 ImageNet 竞赛的亚军,冠军是 GoogLeNet,但真正活到今天的是这个当年的“千年老二”。它的核心贡献是用连续的小卷积核(3×3)堆出很深的网络,让“深度本身就是一个超参数”从口号变成了可复现的结论。对用 Python 做深度学习的人来说,VGG 是入门 CNN 最合适的骨架:结构极其规整,代码一百行左右就能复现,还能直接拿来做迁移学习,覆盖分类、检索、特征提取这些常见任务。这篇文章会把 VGG 从原理到 Python 实现完整串一遍,给你能直接跑的训练脚本,也会把显存溢出、冻结层失效、权重加载报错这些坑提前点出来。全程用 PyTorch,新手可以照着敲,熟手可以直接拿避坑清单。
2. VGG 架构拆解:为什么 3×3 小卷积核成了后来 CNN 的标配
2.1 VGG 用 3×3 小卷积核替代大卷积核的数学账
VGG 相对此前 AlexNet 最大的变化,是把大卷积核全部换成 3×3 小卷积核。AlexNet 用了 7×7 和 5×5 的大核,感受野大,但参数量也大。VGG 的思路很直接:用两个串联的 3×3 卷积替代一个 5×5 卷积,用三个串联的 3×3 卷积替代一个 7×7 卷积。感受野完全相同,但参数量更低,而且中间多了两次激活,特征的非线性表达能力反而更强。
算一笔账就清楚了。假设输入输出通道数都是 C,一个 7×7 卷积的参数是 49×C²,拆成三个 3×3 卷积后,参数是 3×(9×C²)=27×C²,直接少了四成半。对通道数动辄 512 的深层网络,这个省下来的显存和训练时间是实打实的。这也是为什么后来几乎所有主流 CNN 主干,包括 ResNet、DenseNet,默认都用 3×3 卷积。如果你在面试里被问到“为什么 VGG 用 3×3 而不是一个 7×7”,答案就是这两条:参数少一半,非线性更强。
VGG 的整体结构也极规整。卷积段分五段,逐段把特征图尺寸减半,通道数从 64 依次翻到 128、256、512,每段末尾接一次 2×2、步长 2 的最大池化。卷积层只负责特征提取和尺寸压缩,最终分类交给三个全连接层。这种“特征提取 + 分类头”的划分,让 VGG 做迁移学习时特别顺手:把分类头砍掉,卷积段输出就是通用特征,下游接什么任务都行。
还有一个容易被忽略的点:VGG 的输入被固定在 224×224,因为 ImageNet 原始尺寸就是 224。如果你换到更高分辨率,五段池化之后的特征图就不再是 7×7,全连接层会直接报维度不匹配。这个设计放到今天看确实有点僵,但工程上处理起来也简单,后面第 5 章会专门讲。
2.2 VGG11 / VGG13 / VGG16 / VGG19 对比,选哪一版
VGG 家族按深度分四个档位,数字代表带权重的层数。VGG11 是 8 层卷积加 3 层全连接,VGG13 是 10 层卷积,VGG16 是 13 层卷积,VGG19 是 16 层卷积。它们的通道数增长规则完全一样,差别只在每段内部叠了几个相同通道数的卷积层。
| 版本 | 卷积层数 | 全连接层 | 参数量(约) | 适用场景 |
|---|---|---|---|---|
| VGG11 | 8 | 3 | 1.33 亿 | 跑通流程、代码验证 |
| VGG13 | 10 | 3 | 1.33 亿 | 小规模实验 |
| VGG16 | 13 | 3 | 1.38 亿 | 常规分类、迁移学习主力 |
| VGG19 | 16 | 3 | 1.44 亿 | 对照实验、追求极限 |
选型我一般看三条。第一条是精度和成本的权衡。如果只是验证想法、调试代码,VGG11 足够,训练快,显存占用低,问题暴露也快。如果要做正经的分类实验或复现论文效果,VGG16 是性价比最优的点。VGG19 在 ImageNet 上比 VGG16 的 top-1 精度提升通常不足一个百分点,训练时间却要多个三分之一以上。除非你要做“深度与精度”的对照实验,否则没必要追最深的。
第二条是迁移学习的兼容性。VGG 的预训练权重在 PyTorch 和 TensorFlow 里都能加载,但不同框架的预处理不一样。PyTorch 里 ImageNet 归一化用的是mean=(0.485, 0.456, 0.406)、std=(0.229, 0.224, 0.225)。如果你换到别的框架,预处理里写成均值方差 0.5,同一个权重推理出来的概率分布会有明显差异。这个坑在做多框架集成时很常见,同一个模型两个结果,多半就是预处理没对齐。
第三条是输入分辨率。如果任务需要更高分辨率,比如检测小目标,我会把输入改成 448×448,同时在特征层后面补一个AdaptiveAvgPool2d((7,7)),让任意尺寸输入都能对齐到 7×7 再进全连接层。代价是中间激活变大,显存暴涨,所以通常只在离线推理时用。
提示:VGG 的参数量大头其实在全连接层而不是卷积层。VGG16 全连接三层的参数量占总量六成以上,这也是它比 ResNet 显存占用高很多的原因之一。
3. 用 Python 在 PyTorch 里复现 VGG:完整可跑的代码
3.1 手写 VGG 的 nn.Module 定义:配置表驱动的灵活写法
PyTorch 里搭建 VGG 的经典思路是一张配置表驱动结构。列表里的数字代表卷积输出通道数,字符串'M'代表最大池化。用一个构造函数刷这个字典,就能生成 VGG11 到 VGG19 的所有版本。
import torch import torch.nn as nn # 结构配置表:数字代表卷积输出通道数,'M' 代表 nn.MaxPool2d(2, 2) CFG = { 'vgg11': [64, 'M', 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'], 'vgg13': [64, 64, 'M', 128, 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'], 'vgg16': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'M', 512, 512, 512, 'M', 512, 512, 512, 'M'], 'vgg19': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 256, 'M', 512, 512, 512, 512, 'M', 512, 512, 512, 512, 'M'], } class VGG(nn.Module): def __init__(self, cfg, num_classes=1000, init_weights=True): super().__init__() self.features = self._make_layers(cfg) # 224x224 输入经过五段池化后是 7x7,最后一个卷积段输出 512 通道 self.classifier = nn.Sequential( nn.Linear(512 * 7 * 7, 4096), nn.ReLU(True), nn.Dropout(0.5), nn.Linear(4096, 4096), nn.ReLU(True), nn.Dropout(0.5), nn.Linear(4096, num_classes), ) if init_weights: self._initialize_weights() def _make_layers(self, cfg): layers = [] in_channels = 3 for v in cfg: if v == 'M': layers.append(nn.MaxPool2d(kernel_size=2, stride=2)) else: # 所有卷积都是 3x3、padding=1、stride=1,不改变空间尺寸 layers.append(nn.Conv2d(in_channels, v, kernel_size=3, padding=1)) layers.append(nn.ReLU(inplace=True)) in_channels = v return nn.Sequential(*layers) def _initialize_weights(self): for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') if m.bias is not None: nn.init.constant_(m.bias, 0) elif isinstance(m, nn.Linear): nn.init.normal_(m.weight, 0, 0.01) nn.init.constant_(m.bias, 0)代码里有几个细节值得单独说。第一,CFG 里每个数字都会同时生成一个 Conv2d 和一个 ReLU,而'M'只做池化不激活,所以最后一个卷积层输出会经过 ReLU 后再进池化,这跟 VGG 原结构一致。第二,全连接层第一层的输入维度512 * 7 * 7是硬编码的,它依赖输入尺寸是 224×224,换尺寸就报错,这在前面的 2.1 已经提到了。第三,_initialize_weights里卷积层用 kaiming_normal_,全连接层用方差 0.01 的高斯初始化,这是 VGG 原论文里的策略。别觉得这一步多余,复现论文结果时,初始化方式不同会让收敛曲线有明显差异。
第四,ReLU 里的inplace=True能让一部分内存复用,对 VGG 这种激活值很多的大网络能省一点显存,代价是不能用torch.autograd追踪某些中间变量。调试阶段如果遇到奇怪的反向传播报错,可以先把 inplace 关掉试试。
3.2 验证前向传播:一份假数据跑通全链路
写完网络定义,第一件事就是用假数据验证前向传播。这一步能筛掉九成以上低级错误,比直接上训练集高效太多。
# 实例化 VGG16,先做前向传播验证 model = VGG(CFG['vgg16'], num_classes=1000) model.eval() # 模拟一个 batch:4 张 224x224 的 RGB 图片 dummy = torch.randn(4, 3, 224, 224) with torch.no_grad(): out = model(dummy) print(out.shape) # 期望输出 torch.Size([4, 1000])如果输出形状不是[4, 1000],说明维度推导出了问题。最常见的两个错误:一是512 * 7 * 7手滑写成512 * 14 * 14,二是输入尺寸根本不是 224。把特征层输出的实际形状打印出来,再跟全连接层in_features对比,马上就能定位。
注意:这里
model.eval()不是可选项。VGG 里有 Dropout,train 模式下每次前向传播都会随机丢弃一半神经元,导致同一份假数据两次跑出来的输出不一样。验证网络结构时先切 eval,输出才是确定的。
3.3 torchvision 的预训练 VGG:为什么工程里我更推荐用它
手写版本的优点是透明,缺点是权重要从零训练。VGG16 在 ImageNet 上从零训练,一张高配卡也得跑两到三周,工程里几乎没人这么干。实操时我都是用 torchvision 加载带预训练权重的 VGG,在这个基础上微调,收敛速度会快一个数量级。
from torchvision import models # 加载带 ImageNet 预训练权重的 VGG16 model = models.vgg16(weights=models.VGG16_Weights.DEFAULT) model.eval() # 只保留特征层,用于提取通用特征 features_only = nn.Sequential(*list(model.features.children())) out = features_only(torch.randn(1, 3, 224, 224)) print(out.shape) # torch.Size([1, 512, 7, 7])用list(model.features.children())转成列表再塞进nn.Sequential,是因为children()返回的迭代器只能遍历一次。经过这一步,模型只剩卷积和池化,后面接什么头完全由你决定。如果你的任务分类数不是 1000,把最后全连接层的输出维度改成自己的类别数就行,前面积累的特征权重可以保留,只训练新分类头。
权重下载多说一句:weights=models.VGG16_Weights.DEFAULT会自动解析并下载官方权重,不需要手动下载文件之后再去指定路径。首次下载依赖网络环境,如果下载超时或断连,确认网络稳定之后直接重跑,PyTorch 会自动继续断点。有一点要注意:PyTorch 2.6 之后部分模型的weights参数类型更严格,老代码里写的pretrained=True已经不建议用了,新代码统一用weights=那种写法。
4. VGG 训练的关键参数:学习率、数据增强与正则化怎么设
4.1 优化器与学习率:SGD 还是 Adam,迁移学习里怎么拆分参数组
VGG 是 2014 年的模型,那个年代的主流优化器就是带动量的 SGD。放到今天,直接用 Adam 训练 VGG 也完全能收敛,但你会发现一个现象:Adam 收敛快、loss 下降顺利,验证集精度却经常打不过带动量的 SGD。原因是 VGG 参数规模大,SGD 配合 momentum 能走出一条更稳定的优化路径,Adam 的逐参数自适应学习率在深层网络上容易把模型推到一个尖锐极小值附近,泛化性偏差。
我自己调试阶段用 Adam 快速确认代码和数据逻辑,正式训练换回 SGD。标准配置是:
import torch.optim as optim optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) # 每 30 个 epoch 学习率乘 0.1 scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1)lr=0.01是 VGG 在 ImageNet 上常用的起点。如果你用的是 Adam,学习率必须降到 1e-4 量级,直接拿 0.01 给 Adam 开局,第一轮 loss 就会乱跳。step_size和gamma要看数据集规模调整。小数据集上我会把step_size改到 15 到 20 轮,让学习率更早降下去,因为小数据的训练曲线到后期更容易过拟合。
迁移学习场景里,特征层和分类头的学习率应该分开。分类头是随机初始化的,梯度信号强,可以用较大的学习率快速拟合;特征层如果是从预训练权重来的,学习率太高会直接把预训练特征破坏掉。我常用的写法是给优化器传参数组:
optimizer = optim.SGD([ {'params': model.features.parameters(), 'lr': 0.001}, {'params': model.classifier.parameters(), 'lr': 0.01}, ], momentum=0.9, weight_decay=5e-4)逻辑是预训练部分的权重已经接近一个“好的特征提取器”,即使它当时是针对 ImageNet 优化的,也不希望前几轮训练就把它们大幅改动。而分类头是完全新的,需要快速拟合。这个技巧对 VGG 特别重要,因为它的特征层参数多、分类头参数更大,统一学习率往往顾此失彼。
4.2 数据增强:防过拟合的性价比最高手段
VGG 全连接层的参数量占了整个模型一大半,小数据集上过拟合几乎是必然。数据增强是成本最低、效果最明显的缓解手段,性价比远高于换模型。我常用的训练和验证变换组合如下:
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.08, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.4, 0.4, 0.4, 0.1), 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 到 256 再 CenterCrop 到 224,保证每个验证样本的处理方式一致,指标才有可比性。如果验证集也做随机裁剪,你每次评估的输入都不同,验证 loss 曲线会像呼吸一样上下波动,没法判断模型是真的在变好还是随机噪声。
ColorJitter的参数也要小心。亮度、对比度、饱和度分别设 0.4 看起来温和,但在细粒度分类任务里,比如区分两种外观相近的鸟类,颜色扰动过强会把判别性特征污染掉。遇到这种任务我会把ColorJitter的数值调低到 0.1 或直接去掉,只保留随机裁剪和翻转。
4.3 Dropout 与权重衰减:VGG 自带的正则化不能丢
VGG 的三个全连接层之间有两个 Dropout,概率 0.5,这是原论文就有的设计。很多人复现时觉得 Dropout 老套,直接删掉,小数据集上验证精度会掉得很明显。
权重衰减设 5e-4 是标准值,但我见过有人从别的代码里复制成 1e-2,训练直接发散。如果你拿不准,就保持 5e-4。发散时优先检查学习率是不是太大,其次再查权重衰减。另外,PyTorch 的weight_decay默认对所有参数生效,包括 BN 层的缩放和偏移。有些实现会专门排除 BN 参数,但对 VGG 这种 BN 在 2014 年还属于辅助结构的模型,我倾向于直接全部施加,省心,也更容易对照原论文。
5. VGG 实战避坑:显存溢出、冻结失效、权重下载断连的排查记录
5.1 改输入尺寸导致全连接层维度报错
入门时最容易撞见。训练时把图片从 224×224 换成 256×256,第一次 forward 就报in_features不匹配。原因前面说过:VGG 全连接层写死了512 * 7 * 7,输入尺寸一变,五段池化后的特征图就不是 7×7 了。解决方法是保持输入的 224×224,或者在特征层后加一个AdaptiveAvgPool2d((7,7))把任意尺寸统一到 7×7。第二种做法省心,但会改变特征分布,一般只用在推理阶段。
5.2 预训练权重加载报 key 不匹配或 size mismatch
把 VGG 的num_classes改成自己的类别数之后,再用load_state_dict加载 ImageNet 权重,会报size mismatch for classifier.6。这个报错是良性的,因为你把最后的全连接层维度改了,和预训练权重的1000对不上。标准做法是加strict=False跳过分类头权重,只加载特征层部分:
state_dict = torch.load('vgg16_weights.pth', weights_only=False) model.load_state_dict(state_dict, strict=False)strict=False会忽略 key 或形状不匹配的参数。这是迁移学习里加载预训练权重的常见姿势。
5.3 冻结了特征层,但 BN 统计量还在变
迁移学习时把features层所有参数都设成requires_grad=False,训练新分类头,结果验证 loss 震荡。原因是 PyTorch 的 BatchNorm 在train模式下,即使参数不更新,running_mean和running_var也会随当前 batch 更新。特征段虽然冻结了,但 BN 层仍然处于“在线更新统计量”状态,前向传播的结果不稳定。
正确冻结姿势是这样的:
for p in model.features.parameters(): p.requires_grad = False model.features.eval() # 关键:BN 统计量不再被 batch 更新 model.classifier.train() # 分类头保持训练模式model.features.eval()会把该模块下的所有 BN 层切到推理状态,统计量不再更新。检查有没有冻结干净,可以打印特征段 BN 的running_mean,在训练前后对比一下数值,如果变了就说明没冻结住。
5.4 显存不够,减小 batch size 反而让 loss 震荡
VGG16 参数量 1.38 亿,训练时的中间激活值占用比参数本身还大。显存溢出时,很多人的第一反应是把 batch size 从 32 减到 8,结果 loss 开始剧烈震荡。原因是 VGG 的 BN 对 batch size 敏感,batch 太小时 BN 统计量噪声大,训练不稳定。
我的排查顺序是:先看是不是有别的地方在占用显存,比如同时加载了多个模型;再把输入尺寸从 224 压到 160 或 128,空间尺寸降下来,中间激活按平方级别下降;最后才用混合精度 AMP 训练,这个对 VGG 很友好,显存占用能省一半以上。AMP 的代码模板如下:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() with autocast(): out = model(inputs) loss = criterion(out, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()autocast只在前向和反向计算时用低精度,优化器参数仍然保持 FP32,所以精度损失很小。VGG 这种结构没有对精度敏感的操作,用 AMP 基本无感。
5.5 断点续训时学习率恢复到了初始值
训练到第 40 轮,机器重启,你从 checkpoint 恢复训练,结果学习率从原本的 0.001 跳回了 0.01,训练曲线直接起飞。原因是只保存了模型和优化器状态,忘了scheduler。恢复训练时scheduler从第 0 轮重新计数,学习率当然回到初始值。解决方法是保存 checkpoint 时把scheduler.state_dict()一起存进去,恢复时也一起加载,这个习惯能帮你避开很多训练中断后无法对齐曲线的破事。
6. 拿 VGG 做迁移学习:冻结策略、验证指标和我的习惯
6.1 冻结还是微调:两条路的适用范围不同
从零训练 VGG 在大型数据集上是可行的,但你手上如果只有几千张图,微调几乎是唯一合理的选择。微调也有两种策略。第一种是特征提取:冻结全部特征层,只训练新的分类头,适合数据量小、目标域和预训练域差异不大的任务。第二种是全局微调:用较小学习率更新整个网络,特征层学习率通常设为分类头的十分之一,适合数据量中等以上、目标域有较大偏移的任务。
我的经验是:数据量少于三万张时,先做特征提取,把分类头训收敛,再决定要不要解冻最后一段特征层做整体微调。这个顺序能帮你省掉很多不必要的调试时间,也比一上来就全局微调更稳定。
6.2 验证指标不是训练 loss,是验证集损失
迁移学习里最常见的误区是只看训练 loss 下降就以为模型在变好。VGG 的分类头很大,过拟合出现得比现代轻量网络早得多。正确的做法是盯着训练 loss 和验证 loss 之间的距离:训练 loss 持续下降、验证 loss 开始回升,说明从那个 epoch 起已经过拟合,此时应该回退到之前保存的最优权重。
所以我每轮结束都会保存验证集损失最低的模型,而不是最后一轮。checkpoint 里除了模型权重和优化器状态,还必须包含 epoch 数和 scheduler 状态,这个习惯在 5.5 说过,值得再强调一遍。
6.3 我的习惯:先用 VGG11 跑通链路再上 VGG16
最后说一条我的个人习惯:新任务拿到手,先用 VGG11 加一个小型训练流程,把数据加载、数据增强、训练循环、验证评估这条链路完整跑通,再换成 VGG16。原因很简单——很多 bug 跟网络深度无关,VGG11 训练几分钟就能暴露数据或代码问题,VGG16 可能要等一两个小时才出结果。链路确认没问题之后,再把 VGG11 换成 VGG16,batch size 和学习率微调一下,其余不动。这个习惯帮我避开了很多“训练了两小时,loss 却一路发散”的血泪场面,换成你大概率也用得上。希望帮到你。
本文还有配套的精品资源,点击获取