
VGG 这个网络放到今天来看结构简单得有点“过分”——十几层卷积堆一堆后面接三个全连接没有残差连接没有注意力机制也没有各种花哨的正则化模块。但恰恰是这种简单让它成了很多人入门深度学习时第一个真正动手复现的模型。我自己当年第一次跑通 VGG 的时候盯着终端里那一行行 loss 输出才算是真正理解了“卷积核堆叠”“感受野”“特征图尺寸变化”这些概念到底在干什么。这篇内容就围绕 VGG 展开把它的设计思路、结构细节、PyTorch 实现、训练调参和踩坑经验一次讲透适合刚接触卷积网络、想拿一个经典模型练手的朋友也适合需要把 VGG 当作基线模型做对比实验的从业者参考。1. 为什么还要认真拆一遍 VGG1.1 VGG 到底解决了什么问题在 VGG 出现之前卷积网络的设计还比较“随性”卷积核大小、层数、池化方式各家有各家的做法没有一个相对统一的范式。VGG 的核心贡献是把“用多个小卷积核替代大卷积核”这件事做成了系统性的设计原则并且用实验证明了深度对性能的影响。它用 3×3 的小卷积核反复堆叠在保持感受野等效的前提下把网络做深同时显著减少了参数量。举个具体的账一个 5×5 卷积核的感受野可以用两个连续的 3×3 卷积核来等效替代。假设输入输出通道数都是 C那么一个 5×5 卷积的参数量是 25C²而两个 3×3 卷积的参数量是 2×9C²18C²参数少了将近三成但感受野一样还多了一次非线性激活。这个账算下来小卷积核堆叠在表达能力和参数效率上都更划算。VGG 就是把这个思路贯彻到底的产物。它解决的问题很实际在算力有限、数据规模中等的条件下如何设计一个结构规整、容易实现、性能又不差的卷积网络。VGG 给出的答案是——把卷积核统一成 3×3把网络按“卷积块池化”的方式分层堆叠通道数从 64 开始逐段翻倍最后接全连接层做分类。这套范式清晰到你可以凭记忆把整个结构画出来这也是它至今仍被当作教学和基线模型的重要原因。1.2 结构规整带来的工程便利VGG 的结构规整性体现在两个层面。第一是卷积块内部的高度重复每个块里都是若干个 3×3 卷积加 ReLU块与块之间用 2×2 最大池化降采样。第二是通道数的规律变化64、128、256、512、512每经过一次池化通道数翻倍特征图尺寸减半。这种规律性让代码实现变得非常干净你可以用一个配置列表把整个网络描述出来循环生成各层而不需要一层层手写。工程上的好处不止是代码短。规整结构意味着调试时容易定位问题——如果某一层输出尺寸不对你很容易根据“第几次池化”反推是哪一段出了问题。另外预训练权重的加载、特征层的抽取、迁移学习时的层冻结都因为结构统一而变得简单。我在做图像检索项目时直接拿 VGG 的卷积部分当特征提取器按块截断、按块冻结几乎不用改结构代码这种便利性是结构混乱的网络给不了的。1.3 适合哪些人拿来练手VGG 特别适合三类人。第一类是刚学完卷积基础、想找一个完整模型从头实现一遍的初学者它的结构足够简单不会让你在复杂模块上卡住但又足够完整能让你走完“定义网络—准备数据—训练—评估—保存加载”的全流程。第二类是需要一个可靠基线做对比的实验者VGG 的性能稳定、复现性好拿来当对照组很合适。第三类是做迁移学习的人VGG 的预训练特征在不少视觉任务上依然有不错的泛化能力尤其是数据量不大的场景冻结卷积层只训分类头往往能快速拿到一个可用的结果。需要提醒的是VGG 参数量大、显存占用高如果你只有一张小显存的卡训练完整版 VGG 会比较吃力这时候更适合用它的浅层版本或者只做推理和特征提取。后面我会具体讲怎么根据显存调整。2. 结构拆解从配置列表到每一层的尺寸账2.1 用配置字典描述整个网络VGG 的不同版本如 11 层、13 层、16 层、19 层结构差异其实只在于每个卷积块里堆了几个卷积层。所以最自然的实现方式是用一个配置列表描述每个块的卷积层数然后用循环生成网络。常见做法是用字符表示层类型比如用 M 表示最大池化用数字表示该层输出通道数。以 16 层版本为例配置可以写成这样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], }这个配置列表就是整个网络的“图纸”。数字代表该卷积层的输出通道数M 代表一次 2×2 最大池化。生成网络时维护一个当前输入通道数的变量遇到数字就生成“卷积ReLU”遇到 M 就生成池化层。这样写出来的代码改层数只需要改配置不用动生成逻辑非常省心。2.2 特征图尺寸和参数量的手算过程理解 VGG 最有效的方式是亲手把尺寸账算一遍。假设输入是 224×224×3 的图像卷积层统一用 3×3 卷积、padding1、stride1池化统一用 2×2、stride2。3×3 卷积配 padding1 的好处是输出尺寸与输入一致尺寸只在池化时减半这让整个尺寸变化非常可控。以 VGG16 为例逐段算下来输入 224×224×3经过第一个块的两个 3×3 卷积通道 64尺寸保持 224×224×64第一次池化后变成 112×112×64第二个块两个卷积到 128 通道池化后 56×56×128第三个块三个卷积到 256 通道池化后 28×28×256第四个块三个卷积到 512 通道池化后 14×14×512第五个块三个卷积到 512 通道池化后 7×7×512。到这里卷积部分结束展平后是 7×7×51225088 维接第一个全连接层 4096 维。参数量的大头其实在全连接层。第一个全连接层 25088×4096 就有约 1.02 亿参数第二个 4096×4096 约 1677 万第三个 4096×1000 约 409 万。卷积部分的参数加起来大概 1470 万左右。所以 VGG16 总参数约 1.38 亿其中七成以上集中在全连接层。这个账算清楚你就明白为什么 VGG 显存占用高、为什么很多改进版本会把全连接层换掉或者砍掉。阶段操作输出尺寸通道数输入-224×2243块12×conv3×3 pool112×11264块22×conv3×3 pool56×56128块33×conv3×3 pool28×28256块43×conv3×3 pool14×14512块53×conv3×3 pool7×7512分类头fc4096-fc4096-fc10001000-2.3 感受野与深度带来的表达力VGG 用深度换表达力的思路值得单独说。三个连续的 3×3 卷积感受野等效于一个 7×7 卷积但参数更少、非线性更多。感受野逐层扩大的过程本质上是网络从局部纹理逐步抽象到全局语义的过程。浅层卷积看到的是边缘、颜色块中层组合成纹理和局部形状深层才能覆盖较大区域形成对物体部件的响应。这里有个容易被忽略的细节感受野的理论计算和有效感受野是两回事。理论感受野按公式逐层累加但实际网络中中心区域的像素对输出的影响远大于边缘区域有效感受野往往小于理论值。VGG 通过堆叠多层小卷积让有效感受野的增长更平滑这也是它特征质量不错的原因之一。理解这一点对后面做特征层选择、迁移学习时判断“哪一层特征适合什么任务”很有帮助。3. PyTorch 实现从零写一个可训练的 VGG3.1 网络定义代码与逐段说明下面这份实现是我自己常用的写法结构清晰改层数只改配置。核心是用make_layers函数把配置列表翻译成nn.Sequentialimport torch import torch.nn as nn class VGG(nn.Module): def __init__(self, features, num_classes1000, init_weightsTrue): super(VGG, self).__init__() self.features features 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 forward(self, x): x self.features(x) x torch.flatten(x, 1) 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, 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 make_layers(cfg, batch_normFalse): layers [] in_channels 3 for v in cfg: if v M: layers [nn.MaxPool2d(kernel_size2, stride2)] else: conv2d nn.Conv2d(in_channels, v, kernel_size3, padding1) if batch_norm: layers [conv2d, nn.BatchNorm2d(v), nn.ReLU(inplaceTrue)] else: layers [conv2d, nn.ReLU(inplaceTrue)] in_channels v return nn.Sequential(*layers)features部分是卷积主干classifier是分类头forward里先过主干再展平。展平用torch.flatten(x, 1)从第 1 维开始展平保留 batch 维这是标准写法。权重初始化里卷积层用 Kaiming 正态初始化配 ReLU全连接层用较小的正态初始化偏置清零。这套初始化在 VGG 上实测收敛比较稳比全零初始化或者简单均匀初始化靠谱得多。3.2 输入尺寸不匹配时的处理方案上面的分类头写死了512*7*7这是假设输入 224×224 的情况。如果你用其他尺寸的输入比如 128×128 或者 256×256展平后的维度会变全连接层就会报维度不匹配。处理方式有两种一是调整输入尺寸到 224×224二是把分类头的第一层改成自适应结构。更稳妥的做法是用nn.AdaptiveAvgPool2d((7, 7))在卷积主干后加一层自适应池化把任意尺寸的特征图统一到 7×7这样分类头就不用改。代价是引入了一点额外的池化操作但对分类任务影响很小反而让网络对输入尺寸更鲁棒。我在做多尺寸输入实验时就用这个办法省去了反复改全连接维度的麻烦。self.avgpool nn.AdaptiveAvgPool2d((7, 7)) # forward 中 x self.features(x) x self.avgpool(x) x torch.flatten(x, 1)注意加了自适应池化后如果加载官方预训练权重需要确认权重里是否包含这一层。官方实现里是有 avgpool 的所以结构能对上如果你自己改结构又加载预训练权重务必用strictFalse并检查缺失的键。3.3 数据准备与训练循环的关键参数数据部分用torchvision.datasets.ImageFolder配合DataLoader就够了。训练时的关键参数我一般这样设优化器用 SGDmomentum0.9weight_decay5e-4初始学习率 0.01配合余弦退火或者阶梯衰减。batch size 根据显存定224×224 输入下8GB 显存大概能跑 batch 16 到 32具体看是否用混合精度。optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay5e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs) criterion nn.CrossEntropyLoss()训练循环里记得每轮做验证、记录准确率和 loss保存验证集上表现最好的权重而不是最后一轮的权重。VGG 训练前期 loss 下降可能比较慢别急着调学习率先确认数据预处理和标签没问题。我见过不少人因为标签映射错位训练半天准确率卡在随机水平排查半天才发现是文件夹命名和类别索引对不上。4. 训练调优与显存优化的实战经验4.1 显存不够时的几种取舍VGG 的显存占用主要来自两方面一是全连接层的巨量参数二是卷积中间激活值。如果显存吃紧可以按下面的优先级依次尝试。第一减小 batch size这是最直接的办法但太小会影响 BatchNorm 的统计稳定性如果用了 BN。第二降低输入分辨率比如从 224 降到 160 或 128激活值会按面积比例下降效果明显。第三把全连接层换成全局平均池化加一个小的分类层参数量骤降显存和模型体积都会小很多代价是可能损失一点精度。第四用混合精度训练torch.cuda.amp能把显存占用降下来不少速度也有提升。优化手段显存收益精度影响实现难度减小 batch size中小低降低输入分辨率大中低全连接换全局池化大中中混合精度训练中小低梯度累积模拟大 batch无省显存小中梯度累积值得单独提一句当显存只够跑小 batch但你又想要大 batch 的训练效果时可以累积几个小 batch 的梯度再更新一次参数。实现上就是在 loss.backward() 后不立即 optimizer.step()而是每累积 N 步再 step 并清零梯度。这样等效 batch size 放大了 N 倍显存占用不变。4.2 学习率与正则化的配合VGG 参数量大过拟合风险不低正则化要跟上。原论文用了 dropout全连接层 0.5和权重衰减这两者在实现里都要保留。学习率方面SGD 配 momentum 是经典组合初始 0.01 在多数分类任务上是个合理起点。如果训练集较小可以适当降低初始学习率避免前期震荡。有个细节用了 BatchNorm 的版本学习率可以设得稍大一些收敛也更快但要注意 BN 在小 batch 下统计量不准batch size 太小时反而可能拖累性能。我一般建议要么 batch 不小于 16 再用 BN要么干脆不用 BN 版本靠 dropout 和权重衰减控制过拟合。这个取舍没有绝对答案取决于你的数据和显存条件。4.3 迁移学习中的层冻结策略拿 VGG 做迁移学习时常见的做法是冻结卷积主干只训练分类头。冻结的实现是把主干参数的requires_grad设为 False优化器只传分类头的参数。这样训练快、显存省适合目标数据量小、和原任务领域接近的场景。如果目标数据和原任务差异较大可以解冻后面几个卷积块一起微调前面的浅层保持冻结。判断解冻到哪一层可以看特征可视化或者简单做个消融实验。我的经验是数据量在几千张量级时冻结主干加训练分类头通常够用数据量上万且领域差异明显时解冻最后两个块微调能带来明显提升但要注意用较小的学习率避免把预训练特征破坏掉。提示冻结参数后记得在训练前调用 model.train()但被冻结的 BN 层最好保持在 eval 模式否则它会继续更新 running mean 和 running var影响特征稳定性。这个坑我在早期项目里踩过冻结了参数却没冻结 BN 的统计更新结果微调效果一直不稳定。5. 常见问题排查与避坑清单5.1 训练不收敛的排查顺序训练不收敛时我一般按这个顺序排查先看数据确认输入归一化是否正确、标签是否和图像对应、类别是否平衡再看损失打印前几个 batch 的 loss如果 loss 完全不降检查学习率是否过大或过小然后看梯度打印几层梯度的范数梯度爆炸或消失都能从这里看出来最后看结构确认展平维度、输出类别数和任务匹配。数据问题是最常见的。归一化用 ImageNet 的均值和标准差是常规操作但如果你的数据分布差异很大可以换成自己数据集的统计量。标签错位也很隐蔽尤其是用自定义数据集时类别到索引的映射一定要打印出来核对。5.2 预训练权重加载的坑加载预训练权重时最常见的报错是键名不匹配。原因通常是分类头的类别数不同或者结构里多了/少了某些层。处理办法是用strictFalse加载然后打印缺失和多余的键确认缺失的只是分类头相关的层。如果缺失的是卷积层的键那说明结构定义和权重来源不一致需要检查配置。另一个坑是输入预处理不一致。预训练权重是在特定归一化下训练的如果你加载权重却用了不同的均值和标准差特征分布会对不上微调效果打折。这个细节容易被忽略但对迁移学习的影响不小。5.3 常见问题速查表现象可能原因排查与解决loss 不下降学习率不当、数据标签错位打印 loss 曲线核对标签映射调整学习率显存溢出batch 过大、输入分辨率高降 batch、降分辨率、用混合精度验证准确率远低于训练过拟合加强 dropout、权重衰减增加数据增强加载权重报键不匹配类别数不同、结构差异strictFalse核对缺失键微调后性能反而下降学习率过大、BN 统计被破坏降学习率冻结 BN 统计更新训练速度慢未用 GPU、数据加载瓶颈确认设备增加 DataLoader 的 num_workers数据加载瓶颈经常被低估。如果 GPU 利用率上不去先看 DataLoader 的 num_workers 是否设得太小pin_memory 是否开启。图像解码和增强是 CPU 密集操作worker 数量合适时 GPU 才能跑满。我一般从 4 个 worker 起步根据 CPU 核数调整。6. 特征提取与模型改造的延伸用法6.1 把卷积主干当特征提取器VGG 的卷积部分在很多视觉任务里都能直接拿来当特征提取器。做法是取model.features把输入过一遍得到 7×7×512 的特征图再根据需要做全局池化或者展平。做图像检索时我常用全局平均池化后的 512 维向量作为图像表示配合余弦相似度做近邻搜索效果稳定且实现简单。选择哪一层的特征取决于任务。浅层特征保留更多细节和纹理适合细粒度匹配深层特征语义更强适合类别级别的检索。实际使用时可以多尺度拼接把不同块的特征池化后拼起来兼顾细节和语义。这个思路在不少检索和风格迁移任务里都能用。6.2 改造分类头适配自定义类别自定义类别数时只需要替换分类头的最后一层把输出维度改成你的类别数。如果类别数很少可以把中间的全连接层也缩小减少参数量和过拟合风险。改造后记得重新初始化新加层的权重前面的层可以加载预训练权重。model VGG(make_layers(cfg[VGG16]), num_classes10) # 加载预训练权重忽略分类头 state torch.load(vgg16_pretrained.pth) model.load_state_dict(state, strictFalse)改造时要注意如果改了分类头结构预训练权重里对应的键会缺失这是正常的。关键是确认卷积主干的键都能对上这样特征提取能力才保住了。6.3 用 VGG 做风格迁移的接口VGG 在风格迁移里是经典的特征提取网络因为它的多层特征能很好地分离内容和风格。做法是取几个特定卷积层的输出内容损失用深层特征风格损失用多层特征的 Gram 矩阵。实现时把 VGG 参数冻结只优化生成图像这样整个流程就是一个特征匹配的优化问题。用 VGG 做风格迁移的关键是选层。浅层特征偏纹理深层偏语义风格损失通常用多个层的 Gram 矩阵加权组合。这个用法对理解 VGG 的特征层次很有帮助跑一次风格迁移你就能直观感受到不同深度的特征在表达什么。7. 一些实测下来的经验体会VGG 这个模型结构简单到你可以手绘出来但真正跑起来、调起来细节一点不少。我自己最深的体会是不要一上来就追求高精度先把整个流程跑通——数据能正确加载、前向能出结果、loss 能正常下降、权重能保存和加载这四件事确认了再去调参和改结构。很多所谓的“训练不收敛”其实是流程里某个环节没对齐。另一个体会是关于显存和 batch 的取舍。VGG 的全连接层是显存大户如果你的任务不需要 ImageNet 级别的分类完全可以把分类头砍掉换成轻量结构省下来的显存用来加大 batch 或者提高分辨率往往比死守原版结构更划算。模型是为任务服务的经典结构是起点不是终点。最后说一个实操小技巧调试结构时先用一个极小的假数据比如 batch2 的随机张量跑一遍前向打印每层输出尺寸确认尺寸账和你的预期一致再去接真实数据。这一步花不了几分钟但能省掉大量因为维度不匹配导致的报错排查时间。我在改分类头、加自适应池化、换输入尺寸时都是先跑这个假数据检查屡试不爽。