
这是“cv基础算法”系列的第二篇文章。今天要拆的VGG在计算机视觉里几乎无人不晓。它出现在每一份深度学习入门教程中也是很多分类、检测、分割任务的backbone起点。很多人知道它“很深”却说不清为什么这样设计参数为什么这么多怎么在代码里落地。这篇文章想做的就是把这些点全部串起来设计动机、网络结构、参数量计算、PyTorch实现还有我平时调模型时踩过的若干坑。适合刚入门CV的初学者也适合准备把VGG当作迁移学习基座的开发者。我建议你边读边打开编辑器把代码敲一遍别只用眼睛看。因为VGG的规整性非常适合“手动实现一遍再理解”当你亲手把每一层的输入输出维度算清楚很多之前模糊的概念会一下子变得具体。这篇文章不会只贴一张结构图就完事我会把参数明细、运行时的显存占用、微调技巧一并交代你跟着走完能直接拿去改自己的项目。1. VGG为什么值得单独写一篇1.1 它把“深度”变成了一个朴素的工程方案在VGG出现之前卷积网络虽然已经在图像分类上表现出很强的能力但主流思路还是依赖较大的卷积核比如11×11、7×7。大卷积核的好处是感受野大几层之后就能看到比较大的图像区域但坏处也很明显卷积核尺寸一大参数数量会爆炸而且网络通常只敢堆到七八层再深就很难训练。VGG的核心思路说穿了特别朴素与其用大卷积核不如老老实实把3×3的小卷积核一层一层堆起来。两个3×3卷积堆在一起感受野等价于一个5×5卷积三个3×3卷积堆在一起等价于一个7×7卷积。用这种“以小换大”的方式网络可以在不牺牲感受野的前提下把深度拉到了16层甚至19层。这个思路在今天是常识但在当时是一个非常关键的转变它证明了深度本身是有价值的而堆深度的方式也可以非常规整、非常工程化。1.2 为什么现在还要学VGG很多新人会问现在有残差结构、密集连接结构、高效结构VGG是不是已经过时了我的看法是VGG恰恰是所有现代CNN的“母版”。它的结构极其简单若干卷积块堆叠池化降采样最后接全连接分类。你去看后来的很多网络甚至很多检测分割网络骨干部分依然保留着这种“卷积块池化”的骨架。而VGG是理解“卷积核堆叠与感受野关系”、“特征图通道数如何逐渐增加”、“参数量都消耗在哪里”这几个核心问题的最佳教材。再加上VGG的特征提取能力非常强到今天仍有大量预训练VGG被用作迁移学习的骨干网络。可以说你把VGG的每一层结构吃透了后续很多网络对你来说都会变得好懂很多。1.3 现在还有哪些场景离不开VGG别看VGG老了有几种场景它依然很有存在感。第一个是中小型数据集上的迁移学习当你的数据量只有几千到几万张一个预训练VGG提取特征的效果往往比很多复杂网络还要稳因为它的特征表达足够丰富也不会像某些轻量网络那样过分依赖特定数据分布。第二个是特征可视化与可解释性分析VGG每一层的语义层次非常清晰浅层学边缘纹理、中层学物体部件、高层学完整语义用来做卷积核可视化、特征图热力图特别合适。第三个是作为知识蒸馏中的教师模型VGG的参数量和复杂度都比较大正好适合先训好一个“大而笨”的教师再指导小模型学习。第四个就是教学场景几乎所有深度学习课程都会拿VGG当范本因为它没有花哨的结构所有原理都摆在明面上。2. VGG的网络结构与参数细节网络结构图大家都见过但真要说清每一层的尺寸变化和参数走向不少人是含糊的。下面把这些细节完整过一遍尤其是感受野和参数量这两笔账值得花点时间仔细推。2.1 VGG16和VGG19的层配置VGG最常用的两个版本是VGG16和VGG19。这里的数字指的是带权重的层数也就是卷积层加全连接层的总数池化层和激活函数不计入。VGG16总共有16层由5段卷积block和3个全连接层组成。前两段每个block各有两个卷积层中间三段每个block各有三个卷积层每段最后接一个2×2最大池化。卷积输出通道数从64起步每经过一段翻倍一次到第四、第五段保持512不变。VGG19则是在中间三段里各多塞了一个卷积层总层数变成19。这个设计没有花哨的trick就是不断重复“卷几层、池化一次”的模式所以它的代码实现异常规整适合用配置列表来生成。下面用一张表把VGG16的配置梳理一下卷积核大小统一为3×3padding1stride1激活函数统一用ReLU网络段层配置输出尺寸按输入224×224推算Block1Conv64 ×2 → MaxPool112×112×64Block2Conv128 ×2 → MaxPool56×56×128Block3Conv256 ×3 → MaxPool28×28×256Block4Conv512 ×3 → MaxPool14×14×512Block5Conv512 ×3 → MaxPool7×7×512分类层FC4096 → FC4096 → FC10001000注意每个卷积层都保持了输入输出特征图的尺寸不变只有池化层会把宽高减半。全连接层则把最后7×7×512的特征图展平后映射到分类空间。如果你输入的不是224×224只需要记住一个规律每经过一个池化宽高除以2经过5段pool之后整体宽高会缩小到原来的1/32。2.2 为什么坚持用3×3小卷积核感受野与参数账这部分值得多花点篇幅。为什么两个3×3卷积等价于一个5×5卷积我习惯用一个很朴素的理解方式一个像素点经过两层卷积后能看到的信息范围是两层卷积核叠加的区域。第一层卷积让每个输出像素感受到输入上的3×3邻域第二层卷积再把第一层输出的3×3邻域进行加权组合最终等效感受野就是5×5的窗口。三个3×3堆叠则等价于7×7。但参数呢假设输入输出通道数都是C一个5×5卷积的参数量是25C²而两个3×3卷积的参数量是2×9C²18C²少了28%。三个3×3卷积的参数量是27C²比一个7×7卷积的49C²少了45%。你以为这就完了三个3×3卷积之间还夹着两个ReLU激活而7×7卷积只有一个ReLU。激活函数为网络引入了更多的非线性让决策边界可以更复杂。所以小卷积核堆叠本质上是一笔非常划算的买卖感受野不打折、参数更少、非线性更强。2.3 VGG16参数量和计算量到底有多少VGG16常被吐槽“参数多、计算量大”那咱们把账算清楚。卷积层参数很好算卷积核是3×3输入通道是C_in输出通道是C_out加上偏置参数量就是3×3×C_in×C_out C_out。按这个公式把VGG16所有卷积层加起来卷积部分大约是14.7M参数其实不算夸张。真正的大头在后面三个全连接层。第一个全连接层输入是最后池化得到的7×7×51225088维输出是4096这一层的参数量是25088×4096加上偏置约1.03亿第二个全连接是4096×4096约1678万第三个全连接是4096×1000约409.6万。三项加起来超过1.2亿算上卷积部分VGG16总参数量约为1.38亿。计算量的主力则在卷积层单张224×224输入跑完整网络大约需要155亿次浮点运算。所以有人总结说VGG的“贵”贵在计算量和最后那三个全连接层的参数存储上。这个特点直接影响了后续很多网络的改进思路比如用全局平均池化替代全连接、用1×1卷积降维等。2.4 选VGG16还是VGG19如果只是做迁移学习或者特征提取我的默认选择是VGG16。VGG19比VGG16多三个卷积层精度提升很有限但参数量和计算量都涨了一截总参数约1.44亿训练和推理都更慢。除非你面对的任务特别需要高分辨率细节或者你有充足的算力、且已经在VGG16上验证过基线效果不够好否则没必要一上来就用VGG19。在实际项目里我用VGG16做backbone的次数远多于VGG19因为它的精度已经足够稳定留给后续微调和数据增强的空间也更大。3. 用PyTorch从零实现一个VGG16结构参数都清楚了接下来动手。PyTorch里搭VGG是一件很舒服的事因为它高度规整几乎可以用一个函数生成全部卷积段。你不用手打十几层重复代码只需要定义每个block的卷积数量和输出通道。3.1 用配置表生成网络VGG这种“重复堆叠小卷积块”的结构特别适合用配置表来定义。我平时写代码不会手打十几个Conv、ReLU而是先定义每个block的卷积次数和输出通道数再用循环生成。下面这个实现是我常用的骨架关键点在于把“特征提取部分”和“分类部分”分开方便后续做迁移学习时直接砍掉分类头。import torch import torch.nn as nn def make_block(in_channels, out_channels, num_convs): layers [] for i in range(num_convs): layers.append(nn.Conv2d(in_channels, out_channels, kernel_size3, padding1)) layers.append(nn.ReLU(inplaceTrue)) in_channels out_channels layers.append(nn.MaxPool2d(kernel_size2, stride2)) return nn.Sequential(*layers) class VGG16(nn.Module): def __init__(self, num_classes1000): super().__init__() self.features nn.Sequential( make_block(3, 64, 2), make_block(64, 128, 2), make_block(128, 256, 3), make_block(256, 512, 3), make_block(512, 512, 3), ) self.avgpool nn.AdaptiveAvgPool2d((7, 7)) self.classifier nn.Sequential( nn.Linear(512 * 7 * 7, 4096), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(4096, 4096), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(4096, num_classes), ) self._init_weights() def _init_weights(self): for m in self.modules(): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu) 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) def forward(self, x): x self.features(x) x self.avgpool(x) x torch.flatten(x, 1) x self.classifier(x) return x model VGG16(num_classes1000) print(model)这段代码有几点值得说明一是每段卷积block内部不改变特征图尺寸只有最后的MaxPool让宽高减半二是我在classifier里加了Dropout这是VGG的常见配置三是AdaptiveAvgPool2d在这里不是做全局平均池化而是把输入特征图强行映射到7×7方便我们在输入不是224×224时也能复用网络。如果你确定输入一定是224×224也可以直接用Flatten但加了这层会让模型更灵活。3.2 从零训练还是加载预训练对于VGG这种参数规模我强烈不建议在普通小数据集上从零随机初始化训练。1.38亿参数如果只有几万张图结果基本就是过拟合到怀疑人生。更现实的路线有两条。第一个是加载预训练权重后微调把最后一层全连接的输出改成自己的类别数然后用较小的学习率训练。第二个路线是把VGG当作固定的特征提取器只训练一个简单的分类头或者直接对提取到的特征做逻辑回归。这两种方式我在实际项目里都用过效果稳定且省事。如果确实想从头训练那至少需要百万级样本的数据集并且要配足够强的正则化与数据增强否则模型会立刻陷入不收敛或者梯度爆炸。预训练权重加载通常是这样先把权重文件下载到本地然后构造自己的模型再调用load_state_dict。因为最后一层类别数变了所以加载时要用strictFalse或者手动把分类层的键过滤掉。修改分类数也很简单在构造模型时传入num_classes然后只加载除最后一层之外的所有参数。如果发现维度不匹配不要慌这是正常的因为你改了最后一层的输出维度。3.3 训练配置里容易被忽略的三个细节第一个是数据预处理。VGG在224×224输入上的表现依赖于训练时采用的归一化统计值。我在代码里一般直接用固定的均值[0.485, 0.456, 0.406]和标准差[0.229, 0.224, 0.225]这是在大规模自然图像数据上统计出来的常规值对大多数RGB图像都适用。第二个细节是优化器。VGG虽老但它对学习率并不挑剔常用的SGD加动量就能训得很好。初始学习率我习惯设置在0.01到0.001之间配合余弦退火或StepLR。如果用的是Adam学习率要低一个数量级0.0001左右起步否则前期loss容易波动太大。第三个细节是数据增强。因为VGG没有内置的正则化结构很多版本的VGG甚至不带BatchNorm所以训练时必须靠随机裁剪、水平翻转、颜色抖动这些手段来抑制过拟合。我自己的经验是随机裁剪对VGG的帮助特别明显它能模拟不同尺度和位置的物体让模型学会更鲁棒的特征。4. VGG的常见问题与排查实录不管你是从零训练还是加载预训练权重微调VGG的坑都不少。我把自己踩过的和帮别人排查过的问题集中整理一下。4.1 显存不足从换硬件到换策略VGG训练时最常遇到的就是“显存不足”的报错。原因很简单中间特征图计算量大尤其是前几层分辨率高、通道数逐渐增加显存消耗非常快。我第一次在单张显卡上训练VGG16时batch size开到64直接炸显存。排查思路其实不复杂先把batch size降到16或者8如果还不够打开混合精度训练用半精度float16替换float32做前向和反向显存消耗差不多能减半。如果这些都不够那就从模型层面想办法。最有效的一招是只微调最后几层把前几个block的参数冻结不参与反向传播这样显存占用主要集中在后面的层训练速度也能快不少。另外VGG有一堆全连接参数但它们在推理时可以等效转换成1×1卷积或全局平均池化显存压力自然就下来了。4.2 训练loss不下降或直接爆掉VGG虽然结构简单但没有BatchNorm的VGG非常依赖合理的初始化和学习率。我遇到过不少新手把学习率设成0.1结果loss前几步就冲到NaN。这种问题的排查思路也直接先把学习率降到0.001甚至0.0001确认loss能下降后再逐步调大。如果还是不稳定检查一下数据和标签有没有问题比如有没有漏做归一化有没有类别数超了。还有一个很隐蔽的坑VGG的卷积层和全连接层的初始化方式不一样卷积用Kaiming初始化全连接用标准差0.01的正态分布。如果你用默认初始化一把梭深层网络很容易出现方差爆炸表现为loss一开始就非常大然后缓慢下降。把初始化按照原始版本对齐情况会好很多。4.3 迁移学习时分类头改不对用VGG做迁移学习最常见的报错大概是加载权重时出现size mismatch。这个报错信息通常会明确告诉你哪个键不匹配。原因无非两类一是你改了num_classes最后一层线性层的权重维度对不上二是你在自己的模型里给某些层起了不同的名字导致state_dict里的键找不到。处理方式很简单构造模型后用strictFalse加载只加载匹配的层或者直接从state_dict里过滤掉分类层的键。我自己的习惯是写一个load_weights函数先把模型自身的state_dict和预训练权重合并分类层的权重和偏置跳过然后再覆盖回去。这样既能保留预训练特征又能让自己的分类头从头学起。4.4 问题速查表遇到问题先别急着重启按下面这张表排查多数时候几分钟就能定位。现象可能原因处理建议显存不够batch size过大 / 输入分辨率过高减小batch、降输入尺寸、开混合精度、冻结部分层loss变成NaN学习率过大 / 初始化不当降低学习率、对齐原始初始化、检查归一化loss不下降学习率太小 / 数据增强过强调大学习率或换优化器、减弱增强强度size mismatch分类类别数变了 / 层名改了用strictFalse加载跳过不匹配层推理速度太慢全连接层计算量大转成1×1卷积或全局平均池化替代FC5. 把VGG当作特征提取器迁移学习实战心得如果你只是把VGG当成分类器多少有点浪费。它的中间特征在迁移学习、特征可视化、图像检索这些场景里才是真正能打的地方。5.1 特征提取的两种姿势VGG真正的价值不只是分类精度更在于它学习到的中间特征具有很强的可迁移性。实际操作中我们可以把VGG的features部分当作一个固定编码器输入图像后取出某个中间层的输出作为特征向量。两个常用的特征层是pool5也就是最后一个池化层输出形状为7×7×512另一个是第二个全连接层的输出4096维。pool5还保留空间结构适合做目标检测、语义分割里的特征图使用第二个全连接层出来的则是高度抽象化的全局描述子适合做图像检索、相似度计算。提取过程不需要改模型结构只需在前向时把中间结果接出来。在PyTorch里可以通过register_forward_hook或者直接手动截断forward函数来实现。我习惯用hook因为不用改动原模型对后续参数更新也没有副作用。5.2 微调时冻结哪些层更划算如果手头数据量不大但又想充分利用VGG的预训练特征一个常用的策略是“分类头重训浅层冻结深层微调”。具体来说前面两个block学到的通常是边缘、纹理这类低级特征通用性很强可以冻结后面block学的是越来越具体的高级语义需要针对目标数据集做一定调整所以从第三个block之后开始微调。我一般在数据量只有几千张时直接把整个features冻结只训练一个简单的分类头效果就很不错数据量上了几万后才把后两个block放开微调。学习率也要分层设置被微调的层用0.001新加的层可以适当高到0.01。这个策略能在防止过拟合和提升精度之间取得一个很好的平衡。5.3 一个小技巧用全局平均池化换掉全连接VGG被人诟病最多的是全连接层参数太多。如果只是做特征提取我们完全可以用一个AdaptiveAvgPool2d((1,1))把7×7×512的空间特征压缩成512维全局描述子再接一个线性层。这个改动会让模型参数从1.38亿降到1500万左右推理速度明显提升精度在多数任务上损失很小甚至有时因为正则化效果而更好。这不是我凭空想的后来很多轻量网络都沿用了全局平均池化的思路。所以当你在项目里觉得VGG太重时不要急着换网络先试试把全连接层改成1×1卷积或全局平均池化很可能就够用了。这篇先把VGG聊到这儿。我个人的体会是VGG最值得学习的地方并不是它拿了多高的精度而是它用最简单的积木块搭出了一个可解释、可复现的深度模型。如果你能把每个block的维度变化写在纸上真的会比看十篇教程都扎实。最后再分享一个小经验第一次在自己的数据上做迁移学习时别急着端到端微调先用预训练VGG提取特征再训练一个线性分类器把整条链路跑通然后再去碰微调。这样出问题的时候你至少能判断是特征提取的问题还是分类器的问题。我们下一篇继续聊另一个经典网络。