1. 为什么今天还要回头聊VGG
如果你这两年才入坑深度学习,大概率一上手就是ResNet、ViT、ConvNeXt这些"新贵",VGG这个名字可能只在论文引用列表或者某些预训练权重目录里扫到过。但我自己带过几轮新人、也做过不少从零搭模型的活,发现一个挺有意思的现象:越是基础的老模型,越容易被跳过,而恰恰是这些老模型里藏着最多"为什么后来的网络要那么设计"的答案。VGG就是典型代表。
VGG是牛津那组人搞出来的卷积网络架构,2014年那会儿在图像分类赛道上拿了很好的成绩,核心贡献其实不是"更深"这么简单,而是它用一套极其规整的堆叠方式,把"卷积核到底该多大、网络该怎么加深"这件事讲清楚了。它解决的问题很朴素:在它之前,大家堆网络堆得比较随意,卷积核大小五花八门(11x11、7x7、5x5混着来),而VGG用连续的小卷积核替代大卷积核,证明了感受野可以靠堆叠小核等效获得,同时参数更少、非线性更强。这个结论放到今天依然是卷积网络设计的基本功。
这篇内容适合谁看?如果你是刚学完卷积基本概念、想找一个结构干净、代码好复现的模型练手,VGG是最合适的起点之一,因为它没有残差连接、没有注意力、没有分组卷积这些花活,就是卷积+池化+全连接的直白堆叠。如果你是有一定经验、想搞清楚"3x3卷积堆叠等效5x5"这类经典结论到底怎么算出来的,这篇也会把推导和代码都摆出来。我会从结构拆解讲到PyTorch实现,再到实际训练时那些文档里不会写的坑,尽量让你看完能直接跑起来、也能讲清楚每一层在干嘛。
需要先说明一点:下面涉及的具体训练参数、显存占用这些数字,是我在常见配置(单卡、输入224x224、batch size按显存调)下的经验值,不同硬件和框架版本会有出入,你按自己环境微调就行,别当成绝对标准。
2. VGG的结构骨架:规整到近乎刻板的堆叠哲学
2.1 从"卷积核该多大"这个原始问题说起
要理解VGG,得先回到它诞生时面对的问题。在它之前,比较有代表性的网络(比如早期的AlexNet)第一层就用11x11的大卷积核,后面还有5x5的。大卷积核的问题是参数爆炸:一个11x11x3的卷积核,单通道就有363个参数,如果输出通道是96,那就是三万多参数压在一层上。而且大卷积核的感受野虽然大,但它的空间分辨率抓取比较粗,对细节的响应不如多个小核叠加来得细腻。
VGG的作者做了一个很关键的观察:两个连续的3x3卷积(中间不加池化)所覆盖的感受野,等价于一个5x5卷积;三个连续的3x3卷积等价于一个7x7卷积。这个等效关系是VGG整个设计的地基,我在下一节会把推导写清楚。基于这个观察,VGG干脆把所有卷积核统一成3x3(少数配置里用1x1做通道变换),然后靠"堆多少个3x3"来控制感受野和网络深度。这种统一带来的好处是结构极其规整,写代码的时候一个循环就能生成整个卷积主干,调试和迁移都方便。
我个人的理解是,VGG的价值不在于它多强(它参数量大、推理慢,今天直接拿来当主力 backbone 的场景不多),而在于它把"深度"和"小卷积核"这两个变量单独拎出来做了控制实验,给后来所有网络设计提供了参照系。你去看ResNet的论文,里面做对比实验的baseline经常就是VGG式的plain网络,这不是偶然。
2.2 五段式下采样:VGG16的逐层拆解
VGG有几个配置版本,最常被拿来用的是VGG16和VGG19,数字代表带权重的层数(卷积层+全连接层,池化层不算)。我以VGG16为主讲,因为它是复现和理解成本最低的一个。整个网络可以切成五个"stage",每个stage后面跟一次最大池化做下采样,最后接三层全连接输出。
先看卷积主干的通道变化规律:从64开始,每经过一次池化通道数翻倍,依次是64、128、256、512、512。这个"翻倍"不是随便定的,它背后有个朴素的平衡逻辑——每次池化让特征图的空间尺寸减半(224→112→56→28→14→7),空间信息在减少,那就用增加通道数来补偿表达能力,让每层承载的信息总量大致维持在一个量级。你可以把它理解成"空间换通道",这是卷积网络里非常常见的一种设计直觉。
具体到每个stage里堆几个3x3,VGG16的配置是:stage1两个、stage2两个、stage3三个、stage4三个、stage5三个。为什么后三个stage多堆一个?因为越往后特征图越小、单层计算量越低,多堆一层带来的计算开销可以接受,而深层需要更强的非线性来表达高层语义。这个取舍在VGG19里更极端,stage3、4、5各堆四个。我实测下来,VGG16在大多数中小规模任务上性价比比VGG19好,VGG19多出来的三层带来的精度提升往往不明显,但参数量和显存占用实打实上去了。
下面这张表把VGG16的完整结构列出来,方便你对照代码看:
| 阶段 | 层组成 | 输出尺寸(输入224x224x3) | 输出通道 |
|---|---|---|---|
| stage1 | 2 x Conv3x3 + MaxPool2x2 | 112x112 | 64 |
| stage2 | 2 x Conv3x3 + MaxPool2x2 | 56x56 | 128 |
| stage3 | 3 x Conv3x3 + MaxPool2x2 | 28x28 | 256 |
| stage4 | 3 x Conv3x3 + MaxPool2x2 | 14x14 | 512 |
| stage5 | 3 x Conv3x3 + MaxPool2x2 | 7x7 | 512 |
| 分类头 | FC4096 + FC4096 + FC1000 | 1x1 | 1000 |
注意每个卷积后面都跟了ReLU,表里为了简洁没单独列。还有一点容易忽略:VGG的池化用的是2x2、步长2的最大池化,不是平均池化。用最大池化是那个年代的惯例,它保留显著特征、对微小位移更鲁棒,代价是丢失一部分背景信息。今天有些改进版本会换成平均池化或者用步长卷积替代池化,但原版就是最大池化,复现时别改。
2.3 分类头为什么这么"重"
VGG最被人诟病的地方就是分类头。从stage5出来是7x7x512,展平后是25088维,第一个全连接层直接映射到4096维,这一层的参数量是25088×4096,算下来超过一亿个参数。整个VGG16大约1.38亿参数,其中绝大部分都压在这三个全连接层上。这也是为什么VGG的权重文件动辄五百多兆。
这个设计在今天看确实笨重,但它反映的是当时的思路:卷积层负责提特征,全连接层负责做最终的决策映射,两者职责分明。后来ResNet用全局平均池化替代了大部分全连接,参数量骤降,这也是一个重要的演进节点。不过话说回来,全连接层在迁移学习时其实挺方便的——你冻结卷积主干、只换最后一层全连接,就能快速适配新类别数,这个操作在VGG上做起来特别直观,因为主干和头分得很清楚。
我在实际项目里如果非要用VGG做迁移,通常会做两件事:一是把前两个全连接层换成更小的维度(比如1024),二是加dropout。原版VGG在两层4096的全连接之间就加了dropout(p=0.5),这个不能省,否则在小数据集上过拟合会非常快。
3. 3x3堆叠等效大卷积:把感受野算明白
3.1 感受野的递推公式与手算过程
很多人知道"两个3x3等于一个5x5"这个结论,但被问"那三个3x3等于几乘几、感受野具体是多少"就卡壳。我把推导过程完整写一遍,这个在面试和实际调网络时都用得上。
感受野的递推公式是这样的:假设第i层的感受野是RF_i,卷积核大小是k_i,前面所有层的步长乘积是S_{i-1},那么
RF_i = RF_{i-1} + (k_i - 1) × S_{i-1}
其中S_{i-1}是第i层之前所有层步长的连乘。对于连续堆叠、步长都为1的3x3卷积,S始终为1,所以每加一层3x3,感受野就增加(3-1)=2。
从第一层开始算:第一层3x3,感受野是3。第二层3x3,感受野=3+2=5。第三层3x3,感受野=5+2=7。所以三个3x3堆叠的感受野是7x7,两个是5x5,这个结论就出来了。
但这里有个关键前提:中间不能有池化或步长大于1的层。一旦插入池化,步长乘积变了,感受野的增长就不是简单的+2了。比如stage之间插了2x2步长2的池化,那么下一层的感受野增长就要乘以2。这也是为什么VGG每个stage内部的连续3x3能等效大核,但跨stage就不能这么简单叠加。
3.2 参数量的对比:为什么小核反而更省
光说感受野等效还不够,得算参数才能看出小核的优势。假设输入输出通道都是C,对比一下:
- 一个5x5卷积:参数量 = 5×5×C×C = 25C²
- 两个3x3卷积:参数量 = 2×(3×3×C×C) = 18C²
18C² < 25C²,省了约28%。再看7x7的情况:
- 一个7x7卷积:49C²
- 三个3x3卷积:3×9C² = 27C²
省了将近45%。这个账算下来就很清楚了:用堆叠小核在获得相同感受野的同时,参数量显著更少。而且别忘了,两个3x3之间夹了一个ReLU,等于多了一次非线性变换,表达能力反而更强。这就是VGG这套设计最漂亮的地方——既省参数又增非线性,一举两得。
不过这里有个容易被忽略的代价:堆叠小核增加了层数,也就增加了串行的计算步骤,在GPU上并行度不如单个大核。所以"省参数"不等于"省时间",实际推理时VGG因为层数多、全连接重,速度并不快。这个权衡在做移动端部署时尤其要注意,别看到参数少就以为一定轻量。
3.3 一个常见误解:等效不等于完全等价
我见过不少人把"等效"理解成"完全一样",这是个坑。两个3x3堆叠和一个5x5在感受野范围上等效,但它们的表达能力和学习动态并不相同。原因有三:
第一,中间多了非线性。5x5是线性卷积后接一个ReLU,两个3x3是卷积-ReLU-卷积-ReLU,非线性更强,能拟合更复杂的函数。第二,参数分布不同。5x5是一个大矩阵,两个3x3是两个小矩阵,后者在优化时的梯度传播路径更长,训练动态不一样。第三,边界效应。堆叠小核在特征图边缘的处理和大核不完全一致,padding方式会影响最终效果。
所以准确的说法是:堆叠小核在感受野和参数效率上优于大核,但两者不是数学上的严格等价。理解这一点,你在做网络改造时就不会盲目照搬"等效"结论去替换,而是会考虑非线性、训练稳定性这些因素。我自己改网络时,如果原设计用了5x5,我会先试3x3堆叠,但一定会重新调学习率和初始化,因为非线性变强后训练动态确实会变。
4. PyTorch实现:从零搭一个能跑的VGG16
4.1 用配置列表驱动,别手写每一层
写VGG代码最忌讳的就是一层一层手敲,既容易错又不好改。VGG的结构规整,天然适合用配置列表驱动。我习惯用一个列表描述每个stage的卷积通道和堆叠次数,然后循环生成。这样改VGG16到VGG19只需要改配置,主干代码不动。
先定义配置。我用元组列表,每个元素是(通道数, 卷积层数):
vgg16_cfg = [ (64, 2), (128, 2), (256, 3), (512, 3), (512, 3), ]然后写一个生成卷积块的函数,每个块里连续堆指定数量的3x3卷积(padding=1保证尺寸不变),块末尾接一个最大池化:
import torch import torch.nn as nn def make_conv_block(in_channels, out_channels, num_convs): layers = [] for i in range(num_convs): layers.append(nn.Conv2d( in_channels if i == 0 else out_channels, out_channels, kernel_size=3, padding=1 )) layers.append(nn.ReLU(inplace=True)) layers.append(nn.MaxPool2d(kernel_size=2, stride=2)) return nn.Sequential(*layers)这里有个细节值得说:inplace=True的ReLU能省一点显存,因为不用额外存一份输出。但在某些需要保留原始输入的场合(比如后面要做残差相加)就不能用inplace,VGG没有残差,所以放心用。padding=1是为了让3x3卷积后尺寸不变,这样池化前特征图尺寸才好控制,224经过五次池化正好到7。
4.2 主干与分类头的拼接
有了块生成函数,主干就是一个循环:
class VGG16(nn.Module): def __init__(self, num_classes=1000): super().__init__() cfg = [(64, 2), (128, 2), (256, 3), (512, 3), (512, 3)] blocks = [] in_ch = 3 for out_ch, num_convs in cfg: blocks.append(make_conv_block(in_ch, out_ch, num_convs)) in_ch = out_ch self.features = nn.Sequential(*blocks) self.classifier = nn.Sequential( nn.Linear(512 * 7 * 7, 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 = torch.flatten(x, 1) x = self.classifier(x) return xtorch.flatten(x, 1)从第1维开始展平,保留batch维,等价于原来的x.view(x.size(0), -1),但更直观不容易写错。分类头里两个dropout都是0.5,这是原版设置,别省。
这里有个必须提醒的点:输入尺寸必须固定为224x224(或者至少是能被32整除的尺寸,因为五次池化每次除以2)。如果你喂一个不是224的图,展平后的维度就对不上512*7*7,会直接报维度错误。我见过新手拿256的图直接喂,然后对着报错懵半天。解决办法要么resize到224,要么把分类头的输入维度改成自适应(用nn.AdaptiveAvgPool2d((7,7))先固定尺寸再展平),后者更鲁棒,推荐。
4.3 权重初始化:别用默认的
PyTorch的Conv2d和Linear默认初始化是Kaiming均匀分布(针对卷积)和均匀分布(针对全连接),对VGG这种深层网络来说,默认初始化其实能跑,但收敛不算快。原论文用的是从正态分布采样、均值0、标准差0.01的初始化,偏置初始化为0。我实测下来,用Kaiming正态初始化(mode='fan_out',非线性用relu)效果更稳,尤其是层数多的时候。
def init_weights(m): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') if m.bias is not None: nn.init.zeros_(m.bias) elif isinstance(m, nn.Linear): nn.init.normal_(m.weight, 0, 0.01) nn.init.zeros_(m.bias) model = VGG16() model.apply(init_weights)mode='fan_out'的意思是让初始化考虑输出通道数,这对深层网络的前向传播更友好,能避免信号在深层衰减太快。这个细节很多教程不讲,但实际训练时初始化好坏直接影响前几个epoch的收敛速度,值得花两分钟加上。
4.4 快速验证:跑通一次前向
代码写完先别急着训练,跑一次前向确认维度对得上:
model = VGG16(num_classes=10) x = torch.randn(2, 3, 224, 224) y = model(x) print(y.shape) # 期望 torch.Size([2, 10])如果这步报错,八成是分类头维度或者输入尺寸的问题。跑通之后再往下走。我习惯在写任何训练脚本前都先做这个"冒烟测试",能省掉大量后面调试的时间。顺便可以打印一下参数量:
total = sum(p.numel() for p in model.parameters()) print(f"参数量: {total/1e6:.2f}M")VGG16在num_classes=1000时大约138M参数,改成10类会少一点点(最后一层变小),但主体还是那个量级。看到这个数字你就明白为什么它吃显存了。
5. 训练VGG时那些文档不会写的坑
5.1 显存不够:先砍batch还是先砍分辨率
VGG16在224分辨率、batch size=32的情况下,单卡训练大概要吃掉10G以上的显存(含激活值和梯度),具体数字看框架和是否用混合精度。很多人一上来就OOM,第一反应是砍batch size,但砍太小(比如砍到4)会导致BatchNorm统计不准、训练不稳定。我的经验顺序是:先开混合精度(AMP),再考虑砍分辨率,最后才砍batch。
混合精度能把显存占用降三到四成,速度还能提一些,几乎是无脑开。PyTorch里几行就搞定:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for x, y in loader: optimizer.zero_grad() with autocast(): out = model(x) loss = criterion(out, y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()如果开了AMP还OOM,再考虑把输入从224降到160或128。降分辨率要注意同步调整分类头的输入维度(用自适应池化就不用管)。最后才动batch,而且尽量别低于16。
5.2 全连接层是过拟合重灾区
VGG的全连接层参数太多,在小数据集上过拟合来得非常快。我做过一个对比:同样的数据、同样的训练轮数,去掉dropout的VGG16在训练集上准确率能冲到接近100%,但验证集早早开始掉。原版两个0.5的dropout是保命的,千万别为了"训练快一点"把它关掉。
除了dropout,还有几个实用手段:一是标签平滑(label smoothing),把硬标签软化一点,能抑制模型过度自信;二是权重衰减,VGG对weight decay比较敏感,我一般设1e-4到5e-4之间,太大收敛慢、太小正则不够;三是数据增强,随机裁剪加水平翻转是标配,颜色抖动看任务。这几样叠上去,小数据集上的泛化会稳很多。
还有一个偏方:如果任务类别少、数据量小,干脆把两个4096的全连接换成1024甚至512,参数量骤降,过拟合压力小很多,精度往往不掉甚至更好。这个改动在迁移学习场景下我几乎必做。
5.3 学习率与优化器的选择
VGG这种没有残差连接的深层网络,对学习率比较敏感。太大容易在早期就发散(loss变NaN),太小收敛慢得让人怀疑人生。我用SGD加动量(momentum=0.9)比较多,初始学习率从0.01起,配合余弦退火或者阶梯衰减。如果用Adam,初始学习率要调小到1e-4量级,因为Adam的自适应特性在VGG上容易让早期更新过猛。
有个现象值得注意:VGG训练初期loss可能会先平一段时间再下降,这不是卡住了,是深层网络在找方向。我一般会观察前两三个epoch的loss曲线,如果完全不动才考虑调学习率,如果缓慢下降就再等等。另外,warmup对VGG也有帮助,前几百步用很小的学习率线性爬升,能显著降低早期发散的概率,尤其是batch比较大的时候。
5.4 一个容易忽略的细节:输入归一化
VGG的预训练权重是按ImageNet的均值和标准差归一化的,具体是mean=[0.485, 0.456, 0.406],std=[0.229, 0.224, 0.225]。如果你用预训练权重做迁移,输入必须按这套参数归一化,否则特征分布对不上,精度会莫名其妙地掉。如果是从零训练,用自己数据集的统计量算均值和方差更合理,但很多人图省事直接用ImageNet的,问题也不大。
我踩过一次坑:从零训练时忘了做归一化,输入还是0-255的原始像素,结果训练极其不稳定,loss来回震荡。后来加上除以255再减均值除标准差,立刻就正常了。这个细节看起来小,但对深层网络的训练稳定性影响很大,别跳过。
6. VGG在今天还有什么用武之地
6.1 迁移学习的"老实人"backbone
虽然VGG又大又慢,但它在迁移学习里依然有它的位置,尤其是当你的目标任务和ImageNet比较接近、数据量中等时。原因在于它的特征层次非常清晰:浅层抓边缘纹理、中层抓局部部件、深层抓语义,这种清晰的分层让"冻结前几层、微调后几层"的操作特别好使。我做过一些细粒度分类的小实验,VGG16冻结到stage4、只微调stage5和分类头,在小样本上的表现相当稳,而且因为结构简单,调起来心里有底。
用预训练权重的代码很直接:
from torchvision import models model = models.vgg16(weights='IMAGENET1K_V1') # 冻结主干 for p in model.features.parameters(): p.requires_grad = False # 换分类头 model.classifier[6] = nn.Linear(4096, num_classes)注意classifier[6]是最后一层全连接的索引,前面几层保持不动。如果数据量稍多,可以解冻stage5一起微调,学习率设小一点(比如主干的十分之一)。
6.2 作为教学和对比基线
在写论文或者做技术分享时,VGG经常被拿来当"plain网络"的基线,用来衬托残差连接、注意力机制这些改进的价值。这不是因为它弱,而是因为它足够"干净"——没有skip connection、没有复杂模块,任何性能差异都能比较清楚地归因到被对比的那个改进上。我自己做小规模架构对比实验时,也喜欢拿VGG16当参照,因为它复现成本低、结果可预期。
另外,VGG的中间层特征图可视化特别直观,因为它的stage划分清晰,你可以很方便地看到"经过第一个stage后特征图长什么样、经过第五个stage后又长什么样",这对理解卷积网络的特征抽象过程很有帮助。我给学生讲特征层次时,基本都用VGG的中间输出来演示。
6.3 风格迁移里的经典角色
在神经风格迁移这个方向,VGG是绕不开的。早期那批风格迁移工作几乎都用VGG19的中间层特征来计算内容损失和风格损失,原因是VGG的特征在不同深度上有明确的语义层次,浅层保留纹理和颜色、深层保留内容结构,正好对应风格迁移需要的两种信息。虽然现在有更快的方法,但用VGG做风格迁移依然是理解这个任务最经典的路径。如果你对这块感兴趣,拿VGG19的conv4_2算内容损失、用多个浅层的Gram矩阵算风格损失,是一个很好的练手项目。
不过要提醒一句:风格迁移对显存要求不低,因为要同时保留多个中间层的激活值来算损失。用VGG19的话,建议从较小的输入尺寸(比如256)开始试,跑通了再往上加。
7. 几个我实际踩过的坑和对应解法
7.1 展平维度对不上:八成是输入尺寸问题
前面提过一次,这里再强调,因为这是VGG复现最高频的报错。报错信息通常是mat1 and mat2 shapes cannot be multiplied,看到这个先检查输入是不是224x224。如果不是,要么resize,要么在features后面加一个nn.AdaptiveAvgPool2d((7,7))把尺寸强行拉到7x7再展平。后者是我现在写VGG的默认做法,因为它让模型对输入尺寸更宽容,省得每次换数据都要改分类头。
7.2 训练loss正常但验证精度上不去
这种情况我遇到过好几次,排查下来通常是三个原因之一:一是数据预处理和训练时不一致(比如训练做了归一化、验证忘了做),二是验证集的类别分布和训练集差太多,三是过拟合已经开始但没察觉。第一个最好查,把预处理流程写成函数两边共用就行;第二个要看数据本身;第三个就回到dropout、权重衰减、数据增强那套组合拳。我一般会先画训练和验证的loss曲线,两条线一分开就基本能定位到过拟合。
7.3 用预训练权重但精度反而比从零训练低
这个坑比较隐蔽。常见原因是输入归一化没对上(前面说过),或者你换了分类头但忘了把新加的层初始化好(默认初始化对4096维的全连接不一定合适)。还有一种可能是预训练权重的类别和你任务差异太大,冻结太多层导致特征不适应。解法是:先确认归一化一致,再把新分类头用较小的标准差初始化,最后尝试多解冻一层一起微调。我一般会做个小实验,冻结不同层数各跑几轮,看哪个配置验证精度最高,虽然费点时间但很值。
7.4 多卡训练时的batch norm问题
如果你用多卡(DataParallel或DDP),要注意BatchNorm的统计量是在每张卡上独立算的,等效batch size变小了,统计可能不准。VGG本身没有BatchNorm(原版没有,有些变体加了),所以这个问题在原版VGG上不存在,但如果你自己加了BN层就要留意。我的建议是:要么保证每张卡的batch不太小(至少8),要么用SyncBN把统计量跨卡同步。这个细节在单卡上完全感知不到,一上多卡就容易出问题。
8. 写在最后的一点个人体会
VGG这个模型,我这些年反复用、反复讲,最大的感受是:它的价值不在性能,而在它把卷积网络设计的几个基本问题——卷积核多大、网络多深、通道怎么变——用最直白的方式摆在了台面上。你今天看ResNet的残差、看各种高效网络的深度可分离卷积,追根溯源都能在VGG这里找到参照。所以哪怕它又大又慢,我依然觉得每个学深度学习的人都该亲手复现一遍,把每一层的维度、参数量、感受野都算清楚,这个基本功打扎实了,后面看再复杂的网络都不会慌。
代码层面,我建议你别满足于跑通,而是动手改几个地方试试:把3x3换成5x5看参数量怎么变、把某个stage的堆叠次数加一看看感受野和精度的影响、把全连接换成全局平均池化对比一下参数量和精度。这些改动都不难,但做完之后你对"设计选择如何影响结果"的理解会比看十篇解读都深。我当初就是这么一点点摸过来的,踩的坑现在都成了讲课时最生动的素材。