VGG16 这个网络,在深度学习圈子里算是“祖师爷”级别的经典了。哪怕现在各种新架构、Transformer 模型层出不穷,我依然建议每个做计算机视觉的开发者,不管你做检测、分割还是图像分类,都老老实实把 VGG16 吃透一遍。为什么?因为这个网络把“深度”这件事做到了极致,它是理解后续所有复杂网络结构的基石。这篇内容,我就把 VGG16 的结构图和 PyTorch 代码结合起来,从原理到实践,一步步拆给你看。
1. VGG16 结构拆解:为什么“深”就是王道
VGG16 的论文题目就叫《Very Deep Convolutional Networks for Large-Scale Image Recognition》,核心思路简单到令人发指:用更小的卷积核,堆更深的网络。你如果看过 AlexNet(2012年冠军)就会发现,它用了 11x11 的大卷积核,而 VGG 把卷积核统一换成了 3x3,并且全部堆叠起来,这种设计思想在当时是一场不小的变革。
1.1 结构图里藏着的核心参数
我们先看整体结构,VGG16 的“16”指的就是13 个卷积层 + 3 个全连接层,这里不包含池化层。整张结构图从左到右,通道数从 3 开始,依次经过:
- 64 通道:2 个卷积层(conv1_1, conv1_2)
- 128 通道:2 个卷积层(conv2_1, conv2_2)
- 256 通道:3 个卷积层(conv3_1, conv3_2, conv3_3)
- 512 通道:3 个卷积层(conv4_1, conv4_2, conv4_3)
- 512 通道:3 个卷积层(conv5_1, conv5_2, conv5_3)
每个卷积层后面都跟一个ReLU 激活函数,在每段卷积结束后插入一个最大池化层(Max Pooling),尺寸 2x2,步长 2。最后接 3 个全连接层,分别是 4096、4096、1000 个神经元(1000 对应 ImageNet 的 1000 个类别)。这里有个容易忽略的细节:为什么到了最后几层,通道数保持 512 不再增加了?因为特征图尺寸已经缩小到了 7x7,如果再增加通道数,计算量会变得难以接受。你从结构图上看,VGG16 的“宽度”控制得很有节制,它把所有的收益都押在了“深度”上。
如果你把各层参数加起来统计一下,会发现一个有意思的结果:卷积层虽然占据了网络的大部分层数,但参数只占约 0.5M;而三个全连接层,参数高达约 14.7M。全连接层的参数量占了整个网络的 89% 以上,这才是 VGG16 模型体积高达几百MB的根本原因。知道这一点,后续做模型裁剪或者部署时,你就明白该从哪下手了。
1.2 为什么必须是 3x3 小卷积核
这是 VGG 最核心的设计哲学。结构图上看,每个卷积层的卷积核都是 3x3,但你可千万别小看它。两个连续的 3x3 卷积堆叠,感受野相当于一个 5x5 卷积;三个连续 3x3 卷积堆叠,感受野相当于一个 7x7 卷积。用 3 个 3x3 替换 1 个 7x7 卷积,参数总比是 3×(3×3×C×C)对比 7×7×C×C,大约是 27C² 对比 49C²,参数少了近一半,但非线性表达能力反而更强了——因为中间多了两层 ReLU 嘛。
我用一个直观的类比来帮你理解:一个大团队(7x7)虽然人多(感受野大),但内部沟通层级扁平,决策模式单一;换成三个小团队(3x3)层层传递,每层都有独立的决策层(ReLU),反而能学到更复杂的特征模式。你从 VGG16 在 ImageNet 上的成绩就能看得出,这种堆叠方式不仅没让效果变差,还在 2014 年拿下了分类任务的第二名、定位任务的冠军。
实操心得:理解这一点对后续调参很重要。比如你想提升网络感受野,先想想能不能用多层小卷积核叠加,而不是盲目换大核,这能显著控制参数量和显存占用。
2. PyTorch 代码实现:从结构图到逐层搭建
结构图画得再漂亮,最终也要落到代码里。VGG16 用 PyTorch 实现并不难,但有一个选择摆在你面前:用现成的预训练模型,还是从零手写。我的建议是,初次学习务必手写一遍,哪怕后面用官方预训练模型,也要对手写过程有印象——这就像学编程,你总得手写过一次链表反转,面试时才心里不慌。
2.1 核心代码骨架:用循环帮你省下 13 层重复代码
如果你直接按照结构图 13 个卷积层挨个写 nn.Conv2d,你会发现代码冗长、易错、毫无美感。VGG16 的模式非常规律:每段卷积层数量相同、通道数逐段倍增、段尾接池化层,所以我建议你用配置列表的方式来自动化生成网络。看代码:
import torch import torch.nn as nn class VGG16(nn.Module): def __init__(self, num_classes=1000, init_weights=True): super(VGG16, self).__init__() # 每个元素代表一个卷积层:M 代表 MaxPooling,数字代表通道数 self.features = self._make_layers([64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'M', 512, 512, 512, 'M', 512, 512, 512, 'M']) # 分类器部分 self.classifier = nn.Sequential( nn.Linear(512 * 7 * 7, 4096), nn.ReLU(inplace=True), nn.Dropout(p=0.5), nn.Linear(4096, 4096), nn.ReLU(inplace=True), nn.Dropout(p=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: conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1) layers.append(conv2d) layers.append(nn.ReLU(inplace=True)) in_channels = v return nn.Sequential(*layers) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) # 展平,得到 [batch, 512*7*7] x = self.classifier(x) return x 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)看到这里你可能觉得疑惑:nn.MaxPool2d(kernel_size=2, stride=2)后面为什么没有接 ReLU?池化不是激活函数,它做的只是下采样,不需要非线性变换。另外,注意padding=1这个参数,它保证了卷积操作不改变特征图的 H 和 W,尺寸的变化完全由池化层来控制。输入是 224x224,经过第一段池化变成 112x112,第二段变成 56x56,第三段 28x28,第四段 14x14,第五段就来到了 7x7。这就是为什么最后的全连接层输入维度是 51277。
2.2 复杂版本:为什么你还需要一个更“高级”的实现
初学者用上面这个版本就够了,但如果你追求更贴近官方实现,或者需要更精致的初始化策略,我还得给你提一个“复杂版”的实现思路。那个版本本质上是把每个卷积层的参数结构(如 kernel_size=3, padding=1)显式写出来,类似 Pytorch 官方 torchvision 里 VGG 源码的风格。
复杂版本的好处是:每一层都看得清清楚楚,方便你中途插入调试代码、打印中间层特征图,也方便做局部修改。而简洁版本的好处是:代码量少,逻辑清晰,适合快速搭建。我自己在写论文代码时更偏爱简洁版,但给学生演示时会用复杂版。这里给你一个过渡方案:
你完全可以在_make_layers的循环里,用 Python 的print或者torchsummary库输出每一层的形状,让你既享受循环建模的便捷,又能看清每一层的输出维度。比如:
from torchsummary import summary model = VGG16(num_classes=1000) summary(model, input_size=(3, 224, 224))运行结果会清楚地告诉你:第一层卷积输出 [batch, 64, 224, 224],第一层池化输出 [batch, 64, 112, 112],最终展平前输出 [batch, 512, 7, 7]。看到这个结果,再对照结构图,你就能完全把“图”和“码”对应起来了。
实操心得:torchsummary 是你初学网络结构时最好的朋友。我见过太多人对着结构图发呆,或者单纯数 16 层数错了,用这个工具一秒就能验证你的模型定义到底对不对。
3. 训练 VGG16 的实操细节:从预处理到超参调优
网络搭好了,训练才是真正的战场。VGG16 是一个非常“挑剔”的模型——它没有残差连接,没有 BatchNorm(原始版本),所以在训练时有一些特有的注意事项。这一节我讲讲从数据预处理到超参与优化器的完整配置,这些都是你直接抄作业就能用的经验。
3.1 数据预处理的坑:多尺度裁剪和均值方差
如果你拿 VGG16 做迁移学习(用 ImageNet 预训练权重),就必须严格遵守它的预处理方式。VGG 官方对训练集采用了一种叫随机多尺度裁剪(Random Multi-Scale Cropping)的策略:先将图像随机缩放到 [256, 512] 区间的一个尺寸,然后随机裁剪出 224x224 的区域,再随机水平翻转。这个过程模拟了不同距离、不同位置的观察视角,能显著提升模型对尺度变化的鲁棒性。
测试时则是一致的:先等比缩放让短边等于 256,然后中心裁剪出 224x224。别忘了标准化,ImageNet 的均值是[0.485, 0.456, 0.406],方差是[0.229, 0.224, 0.225],这三个值千万不能写错。我见过有人把均值方差全填成 0.5,训练出来的模型损失根本降不下去,查了半天才发现是预处理的问题。
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(), 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]) ])随机裁剪的scale=(0.8, 1.0)是我自己比较偏好的设置,原论文用的范围更宽(0.08 到 1.0)。范围越宽,数据增强越强,但如果你训练只数不够大,过强的增强反而不利于收敛。你要是第一次训练,用我的参数会更稳。
3.2 超参数选择的“潜规则”:学习率和批次大小
VGG16 参数量巨大(约 1.38 亿),对超参数异常敏感。按我训练经验,初始学习率设置在 0.001 到 0.01 之间是最稳的,配合动量(momentum)0.9和权重衰减(weight_decay)5e-4。为什么 weight_decay 要设这么大?因为 VGG 没有任何正则化,除了全连接层的 Dropout 之外,只能靠权重衰减来压制过拟合。
关于批次大小(batch size),常规建议是 32 或 64。如果你显存不够,降到 16 也行,但最好同步降低学习率。你有没有想过为什么?因为批次越小,梯度的随机噪声越大,模型对学习率就越敏感,如果你不降低学习率,loss 就会像坐过山车一样剧烈震荡。我做过实验,batch size 从 64 降到 16,学习率从 0.01 降到 0.002,效果几乎一致,但稳定度好很多。
优化器方面,SGD + Momentum 是 VGG 的黄金搭档。我踩过的坑是直接用 Adam 训练 VGG,结果验证集精度比 SGD 低了 2~3 个百分点。原因在于:Adam 的自适应学习率机制加上 VGG 巨大的参数量,很容易让模型在训练后期陷入局部最优,而 SGD 的动量机制则能更好地“冲”过平坦区域。这个现象在迁移学习中会更明显,如果是微调预训练好的 VGG,Adam 倒是可以用,但也要把学习率降到 1e-5 以下。
实操心得:如果条件允许,训练 VGG16 时优先用 SGD 而不是 Adam,别问为什么,这就是一个经验法则。如果非要用 Adam,就配上余弦退火学习率调度(CosineAnnealingLR),效果会好不少。
3.3 可视化训练损失:你会遇到的两个关键拐点
在训练 VGG16 时,如果你把损失曲线打出来,会看到两个典型的“拐点”。第一个拐点出现在前 5 个 epoch 左右,损失快速下降,这是网络在快速学习基本特征;第二个拐点出现在第 20 个 epoch 附近,损失下降开始变得非常平缓,这时你需要耐心,至少让它跑完 50 到 75 个 epoch 再看最终结果。
我之前训练一个 10 分类的自定义数据集时,到第 30 个 epoch 验证集精度还在 88% 上下晃动,差点就想删掉模型重来。后来加了学习率调度(第 30 个 epoch 直接腰斩学习率),把训练拉到 75 个 epoch,最终到达 93%。VGG 就是个“慢热”模型,你要给它充分的训练时间。
4. 训练中的常见问题与排查技巧实录
技术博主写代码,最怕的不是写不出来,而是训练到一半模型崩了,还不知道崩在哪。我整理几个 VGG16 训练中最常见的问题,都是我自己或朋友实测踩过的坑,按频率排序给你。
4.1 显存爆掉(CUDA Out of Memory):第一反应不该是换 GPU
VGG16 在 224x224 输入下,单张图片前向传播就要占用约 250MB 显存(batch size = 1)。如果你直接设置 batch_size=64,那约等于 16GB 显存直接打满,所以经常爆显存太正常了。我的第一建议不是换显卡,而是关掉梯度计算或调整输入尺寸。
- 梯度裁剪:如果你只是用 VGG 做特征提取(推理),记得用
torch.no_grad()包起来,省掉一半显存。 - 调输入尺寸:VGG 并不强制 224x224 输入,你可以降到 192 或 160,只要结构能够整除,显存占用会成平方地下降。
- 梯度累积:如果你的 batch size 必须设小,可以用
accumulation_steps来模拟大 batch。每一步计算小批次的梯度,每累计 N 步再更新一次参数,等效于 batch size 放大 N 倍。
显存是干净的资源,不要过度依赖硬件,优化代码才是正事。
4.2 验证集 Loss 反弹:别怀疑数据,先怀疑 Dropout
VGG 在训练模式和验证模式下,Dropout 的行为完全不同。训练时随机丢弃一半神经元,验证/测试时是全量通过,相当于做集成。如果你发现验证集 loss 不降反升,首先检查模型是否用.eval()切到了验证模式,否则 Dropout 还在“随机丢人”,预测结果自然不稳定。
这个问题看起来简单,但我见过不止一个人在这上面耗掉一整天。还有一种情况:如果你是用model.train()和model.eval()做切换,别忘了在验证结束之后切回model.train(),不然你下一次训练刚开始,Dropout 仍然处于关闭状态,模型就废了。
4.3 损失为 NaN 或巨大数:初始化策略出了问题
如果你从零训练 VGG16,且没有用我之前代码里的kaiming_normal_初始化,损失很容易在几步迭代之后就变成 NaN。为什么?因为网络太深,没有合理的初始化,梯度经过多层反向传播后数值会溢出。ReLU 激活函数虽然能缓解梯度消失,但对初始化极其敏感,kaiming 初始化就是为了匹配 ReLU 的分布特性而设计的。
如果用了 kaiming 初始化还是爆 NaN,检查一下你的学习率是不是太高了,或者数据是否有异常值。从零训练 VGG16 推荐第一轮学习率用 0.01;这个数值一般来说能让你能安稳度过前 10 个 epoch。
4.4 训练集精度高、验证集精度低:过拟合的锅
VGG16 是天然过拟合体质,参数量太大,几乎可以把任何训练集背下来。削减方式有三层:
- 数据增强拉满:随机裁剪、翻转、色彩抖动、CutOut / RandomErasing。
- 正则化:全连接层 Dropout 已经是标配,但别忽视 weight_decay。把 weight_decay 从 1e-4 调到 5e-4 时,过拟合程度肉眼可见地下降。
- 模型简化:如果你训练集很小(几千张),果断把 VGG16 后面两个 4096 的全连接层改成 512 或者 256 维。低位特征已经足够胜任分类,没必要非得拿大锤砸核桃。
实操心得:如果有人问我 VGG16 第一个该调什么参数,我永远回答“把 4096 的全连接层降维”。这个操作在不损失精度的情况下能把模型体积缩小一半以上,推理速度也会提升 20% 左右。
5. 从 VGG16 出发:如何举一反三改造你的项目
很多人觉得 VGG16 落伍了,其实它的价值远不止刷榜。它的设计思路和代码结构,是你理解 ResNet、Inception、MobileNet 这些网络的“活教材”。而且,就算在今天的很多项目中,VGG16 依然可以作为骨干网络(Backbone)被复用。
5.1 改造为你的分类器:替换全连接层
最基础的改造,就是把最后的nn.Linear(4096, 1000)换成你自己的类别数。但这里有个关键细节:你替换掉全连接层之后,新加的层会有随机初始化,而原网络其他层保留了预训练权重的分布。这种不匹配会导致微调初期损失抖动厉害。我建议你给新层设置一个较大的学习率(比如是其他层学习率的 10 倍),而预训练层用较小学习率(1e-4 或更低),分头训练。
一个常用的实现片段:
pretrained_model = torchvision.models.vgg16(pretrained=True) pretrained_model.classifier[6] = nn.Linear(4096, 10) # 换成10类 new_params = pretrained_model.classifier[6].parameters() old_params = list(set(pretrained_model.parameters()) - set(new_params)) optimizer = torch.optim.SGD([ {'params': old_params, 'lr': 1e-4}, {'params': new_params, 'lr': 1e-3} ], momentum=0.9, weight_decay=5e-4)这种“分区设置学习率”的做法简直万能,适用于一切迁移学习项目。
5.2 用 VGG16 做特征提取器:取中间层特征
前面提到,VGG16 的卷积层参数极少,真正有意义的是它前面 13 层提取出的特征图。如果你要做目标检测、图像检索、风格迁移,完全可以截断 VGG16 的 classifier,只用 features 部分作为特征提取器。
class VGG16FeatureExtractor(nn.Module): def __init__(self, original_model, out_layer_idx=30): super().__init__() # 截取到指定层为止 self.features = nn.Sequential( *list(original_model.features.children())[:out_layer_idx] ) def forward(self, x): return self.features(x)out_layer_idx取 30 时,输出的特征图是 512x14x14,这相当于 VGG16 提取了“高维语义信息”,比直接输出全连接层结果更有空间信息,很多分割结构会用这个来生成特征金字塔。别看 VGG 结构老,做迁移学习它依旧能打。
5.3 现代化改造:吃透 VGG 的思路后,你可以怎么优化
如果你想让 VGG16 变得更“现代”一点,我总结几个可行方向,按效果和难度排序:
- 引入 BatchNorm:在卷积层和 ReLU 之间加上 BatchNorm,可以让训练加速 2 到 3 倍,同时还能缓解梯度消失,代价是推理时略微增加点计算量。
- 全局平均池化替代全连接层:全连接层占据了 89% 的参数,直接删掉换成
nn.AdaptiveAvgPool2d((1,1)),模型体积骤降至原来的十分之一,精度几乎不掉。这就是后来很多轻量化模型的核心思路。 - 深度融合剪枝思想:因为 VGG 卷积层参数极小,你可以用 1x1 卷积在通道维度做降维,把 512 通道压到 128 再进行后续卷积,大幅减少 FLOPs,同时保持精度。
我实际测试过,一个加了 BatchNorm 并改用全局平均池化的“魔改 VGG16”,参数量从 1.38 亿降到 800 万以下,精度在 CIFAR-10 上反而比原版高了 1% 左右。所以每次有新人问我要入门项目练手,我都建议他们做一个“VGG16 魔改实战”,这个项目既深入理解了结构,又锻炼了动手优化能力,一举两得。
6. 最终经验总结:一些实用的“避坑清单”
最后,基于我这个“老油条”对 VGG16 的反复折腾,给你整理一份避坑速查清单。它不是理论,是实打实的实战沉淀。这份清单你直接贴到自己笔记里,下次用 VGG 时翻出来能少走很多弯路。
- 不要用 VGG16 从零训练大模型,除非你是想研究网络优化实验,否则请务必使用预训练权重,这是省时省力的最佳选择。
- 输入的尺寸要保持技能 224 或符合 2 的幂次,这样池化才能均匀降维。如果你非要用 128x128 输入,记得把全连接层的第一层维度改成 51244,否则会报维度不匹配。
- 迁移学习微调时别急着把所有层一起训练,正确姿势是先用较小学习率只训练新加分类层,等 loss 降下来之后,再解冻最后一两个卷积块一起微调。
- 监控 GPU 显存,如果发现显存将爆未爆,优先减小 batch size,而不是改网络结构。
- 验证时别忘
model.eval(),这句很基础,但真的很致命。
其实,VGG16 最可贵的不是它的精度,而是它那份“笨拙而扎实”的设计哲学。你理解了它,再去接触 ResNet 的跳跃连接、Inception 的多尺度、MobileNet 的深度可分离卷积,你会觉得每一个新结构都是在解决旧结构的痛点,整条 CV 技术演化线都串起来了。真心建议,花一个晚上把 VGG16 的结构图和代码对照着吃透,这笔时间投资,稳赚不赔。