☰
VGG16深度解析:从卷积核原理到PyTorch实战与迁移学习
2026/10/5 13:36:31 网站建设 项目流程

1. VGG16为什么是深度学习绕不开的经典

说到深度学习里的卷积神经网络,VGG16绝对是一个绕不开的名字。2014年,牛津大学视觉几何组(Visual Geometry Group)提出的VGG系列模型,在当年的ILSVRC图像分类竞赛中拿下了亚军,Top-5错误率降到7.3%。虽然冠军被GoogleNet拿走,但对后来整个深度学习领域影响最深远的反而是VGG——因为它的设计思路足够简单、足够规整,简单到任何一个人拿着论文就能复现。

我在带新人入门深度学习的时候,几乎第一课就是让他们把VGG16的代码手写一遍。原因很直接:VGG16的结构极具规律性,没有残差连接、没有注意力机制、没有各种精巧的trick,就是一堆卷积层、池化层和全连接层老老实实堆叠起来,但这恰好让它成为理解CNN基础组件的绝佳教材。很多后续的经典网络(ResNet、DenseNet、各种检测分割模型的主干网络)都直接或间接沿用VGG的模块化设计思想。

这篇博文,我想把VGG16从设计思路、网络结构、参数计算、PyTorch实现到训练踩坑完整过一遍。如果你正在学习深度学习、准备面试、或者打算用预训练VGG16做迁移学习,这篇文章应该能省下你不少自己摸索的时间。我不打算只罗列网络结构表,而是把每个设计决策背后的"为什么"也讲透,因为这些东西才是真正值钱的。

2. VGG16的核心设计思路:小卷积核堆叠的胜利

2.1 从AlexNet的大卷积核到VGG的小卷积核

在VGG之前,2012年称霸ImageNet的AlexNet用的是11x11和5x5这种大卷积核。大卷积核的好处是一个卷积层就能获得较大的感受野,但问题也很明显——参数量爆炸,而且整个网络结构设计得比较随意,没有太强的规律性。

VGG团队做了一个关键创新:抛弃大卷积核,统一使用3x3的小卷积核,并且把卷积层组织成一个个block,每个block内用相同数量的卷积核,block之间用最大池化隔开。整个网络从头到尾规则得可怕,这就是它的设计哲学:用深度换广度,用堆叠换感受野。

我还记得我第一次看VGG论文时的感受:这结构也太"朴素"了吧。但就是这种朴素,让VGG在当年的竞赛中碾压了大多数花哨的对手。后来的实验也证明,多个小卷积核堆叠的效果优于单个大卷积核,这在理论上是有依据的。

2.2 为什么两个3x3卷积等效一个5x5卷积

这是VGG最核心的原理之一。两个stride为1、padding为1的3x3卷积堆叠,其感受野等效于一个5x5卷积;三个3x3卷积堆叠,感受野等效于一个7x7卷积。画个图就一目了然:第一层3x3卷积让每个输出像素看到3x3的区域,第二层3x3卷积再让输出像素看到上一层3x3区域,叠加起来就是5x5。

这个等效性带来的收益有两个方面。

第一个收益是参数量的减少。一个7x7卷积,假设输入输出通道都是C,参数量是C x 7 x 7 x C,也就是49C²。三个3x3卷积的参数量是C x 3 x 3 x C再乘以3,也就是27C²。算下来用3个3x3卷积替代1个7x7卷积,参数量直接打了55折。

第二个收益是更深=更多非线性。每经过一个卷积层后面都跟着一个ReLU激活函数,三个3x3卷积后面就有三个ReLU,而单个7x7卷积只有一个ReLU。更多的非线性层意味着网络对特征的学习能力更强,能够表达更加复杂的函数映射。这就是为什么同样感受野下,深而窄的网络比浅而宽的网络效果更好的根本原因。

2.3 为什么选择3x3而不是5x5或1x1

有人可能会问,既然堆叠越小的卷积核越好,那为什么不全部用1x1?1x1卷积(Network in Network,NIN提出的)确实能增加非线性,但它不改变感受野,无法捕捉空间上的局部特征。如果全部用1x1,卷积就退化成了逐像素的全连接变换,失去了卷积层利用空间局部相关性的意义。

而5x5和7x7相比3x3,在参数量和计算量上明显吃亏,而且奇数的卷积核尺寸本身就为了保留中心像素的位置信息,3x3是奇数卷积核中最小、最经济的尺寸。VGG选3x3,实际是在感受野、参数效率、非线性表达能力之间取了一个非常优的平衡点。这个设计思想后来被大量网络继承,直到今天,3x3卷积依然是绝大多数CNN模型的基础构件。

3. VGG16网络结构逐层拆解

3.1 整体架构总览:13个卷积层加3个全连接层

VGG16的"16"指的就是13个卷积层加3个全连接层,一共16个带权重的层。整个网络被划分为6个模块(block),前5个是卷积特征提取部分,最后1个是分类部分。

网络输入是224x224的RGB三通道图像,经过预处理后送入网络。特征提取部分卷积核数量从64开始,每经过一次池化就翻倍,最终达到512,这个设计让网络能够在保留空间信息的前提下逐步增加特征通道。分类部分则是三个全连接层,前两层输出4096维,最后一层输出1000维对应ImageNet的1000个类别。

这个结构里的每个数字都不是随便定的。卷积核数量从64翻倍到512,是因为池化层把特征图的空间尺寸减半,翻倍通道数可以在一定程度上保持信息量不衰减;全连接层输出4096维则是参考了AlexNet的设计,在当时被认为是一个足够表达高级语义的维度。

3.2 各层详细配置与输出尺寸变化

我整理了一张VGG16的结构配置表,对照着看会很清晰:

Block层操作输出尺寸(HxWxC)卷积核/池化配置
输入-224x224x3-
Block1conv3-64224x224x643x3, padding=1
Block1conv3-64224x224x643x3, padding=1
Block1maxpool112x112x642x2, stride=2
Block2conv3-128112x112x1283x3, padding=1
Block2conv3-128112x112x1283x3, padding=1
Block2maxpool56x56x1282x2, stride=2
Block3conv3-25656x56x2563x3, padding=1
Block3conv3-25656x56x2563x3, padding=1
Block3conv3-25656x56x2563x3, padding=1
Block3maxpool28x28x2562x2, stride=2
Block4conv3-51228x28x5123x3, padding=1
Block4conv3-51228x28x5123x3, padding=1
Block4conv3-51228x28x5123x3, padding=1
Block4maxpool14x14x5122x2, stride=2
Block5conv3-51214x14x5123x3, padding=1
Block5conv3-51214x14x5123x3, padding=1
Block5conv3-51214x14x5123x3, padding=1
Block5maxpool7x7x5122x2, stride=2
分类Flatten250887x7x512
FC1Linear409625088x4096
FC2Linear40964096x4096
FC3Linear10004096x1000

注意Block4和Block5的卷积核数量都是512,这里的512在原文中可能因为内存限制是论文团队在部分机型上使用4块卡并行计算的结果,后来成为固定配置。

3.3 参数量计算:1.38亿参数都去哪了

VGG16总参数量约1.38亿(138M),其中绝大部分集中在全连接层,占了约1.03亿。我自己手算过一次,这块其实不难,关键是搞清楚每一层参数怎么算。

卷积层的参数量公式是:C_in x K x K x C_out,再加上C_out个偏置。以第一个卷积层为例,输入3通道输出64通道,3x3卷积核,参数量就是3x3x3x64=1728,加64个偏置,总共1792。Block3的第一个卷积层,输入128通道输出256通道,参数量是128x3x3x256=294912个权重加256个偏置。

全连接层才是参数大户。进入全连接层前特征图是7x7x512=25088维,乘以4096,得到约1.028亿个权重参数。这就是为什么VGG16的模型文件高达500多MB,全连接层贡献了绝大部分体积。所以在实际落地的时候,很多人会把全连接层砍掉,保留卷积特征提取部分作为目标检测、分割任务的主干网络,这也是后面Fast R-CNN等检测框架选VGG做backbone的原因之一。

3.4 感受野变化:从3x3到196x196

感受野是理解CNN非常关键的概念,它表示输出特征图上每个像素对应到输入图像上的区域大小。对于VGG16,每经过一个3x3卷积(stride=1),感受野在原有基础上增加2;每经过一个2x2池化(stride=2),感受野在原有基础上乘以2再加1。

粗略算一下感受野的递进过程:第一个conv3-64输出感受野是3x3,第二个conv3-64输出感受野变成5x5,经过第一个maxpool后变成6x6(注意这里因为池化层,感受野计算要乘上stride),再经过Block2的两个卷积后感受野变成10x10,池化后变成12x12。最终到达Block5最后一个卷积层时,感受野是196x196,几乎覆盖了整个224x224的输入图像。

这个"从局部细节到全局语义"的感受野变化过程,恰好对应了CNN逐层提取特征的本质:浅层学习边缘、纹理等低级特征,深层学习物体部件、整体结构等高级语义特征。这也是为什么很多人把CNN理解为一种自动的特征工程工具——你不需要手动设计特征提取器,网络自己学会了从像素到语义的层层抽象。

4. 手写PyTorch实现:从零搭建VGG16

4.1 环境准备与数据说明

实战部分我用PyTorch来写。PyTorch 2.x版本都可以,如果要用GPU训练,建议装CUDA版本的PyTorch。没有GPU的话,CPU也能跑通代码,只是训练速度会慢很多。数据方面,我直接用CIFAR-10数据集做演示,它只有10个类别、图像尺寸32x32、共6万张图片,非常适合用来验证模型能不能收得拢、学得会。

这里有一个点要注意:经典VGG16的输入是224x224,CIFAR-10是32x32,直接把32x32的图送进标准VGG16是走不通的,因为经过5次池化后特征图会变成1x1,无法展平成25088维的向量。解决办法有两个:一是把输入resize到224x224,但这会丢失大量细节且计算量暴增;二是修改第一个全连接层的输入维度,把原始VGG16的第一层全连接从Linear(25088, 4096)改成Linear(512, 4096),对应5次池化后1x1x512的特征图。我下面的代码采用第二种方式。

4.2 模型搭建代码与关键参数解析

直接上代码,完整可运行。我写了详细的注释,重点看两个地方:features部分的层堆叠和classifier部分的维度变换。

import torch import torch.nn as nn class VGG16(nn.Module): def __init__(self, num_classes=10, init_weights=True): super(VGG16, self).__init__() # 特征提取部分:13个卷积层 + 5个最大池化层 self.features = nn.Sequential( # Block 1:2个3x3卷积,64通道 nn.Conv2d(3, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(64, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # Block 2:2个3x3卷积,128通道 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(128, 128, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # Block 3:3个3x3卷积,256通道 nn.Conv2d(128, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(256, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(256, 256, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # Block 4:3个3x3卷积,512通道 nn.Conv2d(256, 512, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(512, 512, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(512, 512, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), # Block 5:3个3x3卷积,512通道 nn.Conv2d(512, 512, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(512, 512, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(512, 512, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), ) # 分类部分:3个全连接层 self.classifier = nn.Sequential( nn.Linear(512 * 1 * 1, 4096), # 注意这里针对32x32输入改过 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 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, 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)

代码里的几个关键选择我解释一下。

ReLU用inplace=True,节省显存。inplace操作会直接修改输入张量而不是新建一个张量,在训练VGG这种大网络时能省下不少显存。Dropout放在全连接层之间,概率取0.5,这是针对全连接层参数太多容易过拟合的标准做法。权重初始化用的Kaiming初始化,专门针对ReLU激活函数设计,防止深网络里梯度消失或梯度爆炸。

这里额外提一个经验之谈:如果你是拿官方ImageNet预训练权重做迁移学习,weights初始化最好不要自己乱动,直接load官方参数。自己从头训VGG16在数据量不足的情况下很容易过拟合,这也是很多人踩过的坑。

4.3 训练流程与超参数设置

模型搭好之后,训练流程和超参数设置同样重要。下面这个训练循环是基于CIFAR-10的,超参数我踩过很多次坑后才固定下来:

import torch.optim as optim import torchvision import torchvision.transforms as transforms # 数据增强:这是提升模型泛化能力的关键 transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), # 随机裁剪,相当于数据扩充 transforms.RandomHorizontalFlip(), # 随机水平翻转 transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), ]) transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), ]) trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train) trainloader = torch.utils.data.DataLoader(trainset, batch_size=128, shuffle=True, num_workers=4) testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform_test) testloader = torch.utils.data.DataLoader(testset, batch_size=128, shuffle=False, num_workers=4) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = VGG16(num_classes=10).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.05, momentum=0.9, weight_decay=5e-4) # 余弦退火学习率调度,比固定学习率好用很多 scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100) for epoch in range(100): model.train() running_loss = 0.0 for inputs, labels in trainloader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() scheduler.step() # 每个epoch结束后在测试集上验证 if (epoch + 1) % 5 == 0: model.eval() correct = 0 total = 0 with torch.no_grad(): for inputs, labels in testloader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) _, predicted = torch.max(outputs.data, 1) total += labels.size(0) correct += (predicted == labels).sum().item() print(f'Epoch {epoch+1}, Test Accuracy: {100 * correct / total:.2f}%')

几个超参数我要重点说说。

优化器选SGD加momentum,weight_decay设5e-4做L2正则。很多人喜欢直接用Adam,但在CNN图像分类任务上,SGD+momentum的收敛效果和泛化能力通常更好。Adam收敛快但容易收敛到尖锐的最小值,测试集上表现反而差。学习率0.05配CosineAnnealingLR,前几十个epoch学习率较大快速收敛,后期学习率逐渐减小帮助模型在最优解附近精细调整。收敛后CIFAR-10测试集准确率大概能到90%以上,单张GPU卡训练大约一个多小时。

4.4 用ImageNet预训练权重做迁移学习

如果你不想从零训练,更常见的是用PyTorch官方提供的ImageNet预训练VGG16做迁移学习。代码就几行:

import torchvision.models as models # 加载ImageNet预训练权重 vgg16 = models.vgg16(pretrained=True) # 冻结特征提取层,只训练分类层(或全部解冻) for param in vgg16.features.parameters(): param.requires_grad = False # 替换最后一层全连接,适配自己的分类数量 num_classes = 2 vgg16.classifier[6] = nn.Linear(4096, num_classes) # 把模型放到GPU上 vgg16 = vgg16.to(device)

迁移学习有两个策略:一是冻结卷积层只训练分类头,适用于数据量很少的情况;二是解冻后面几层参与微调,适用于数据量中等的情况。我个人的经验是,当你的数据量在几千张这个级别时,只微调分类头效果就会不错;如果有一两万张图,把Block5甚至Block4解冻一起微调,效果会更好。但千万不要一上来就把全部层都解冻用很大学习率去训,那样很可能把预训练权重破坏掉,损失函数直接震荡。

5. 训练VGG16常见问题排查与避坑实录

5.1 显存不足:OOM的几种解法

VGG16是出了名的显存杀手。Batch size一设大,立刻给你报CUDA out of memory。特别是标准VGG16输入224x224时,前向传播的特征图占用显存相当可观。

解法优先级我的排列是这样的。第一步,减小batch size,从128改到64再改到32,实在不行16也能跑。第二步,图像输入尺寸能降就降,CIFAR-10用32x32,你自己的数据集如果能用128x128做初步实验就不要上224x224。第三步,用梯度累积模拟大batch。第四步,如果显存还是不够,把ReLU的inplace=True打开,maxpool之类的层也会省一点。最后,实在不行就换混合精度训练(AMP),PyTorch里用torch.cuda.amp就能实现,显存占用能降低近一半,而且开了混合精度后训练速度通常还会快一截。

表里总结一下:

方法显存节省效果难易度备注
减小batch size显著最简单模型越小batch,收敛越不稳定
降低输入分辨率显著简单可能影响精度
梯度累积显著中等等效增大batch size
混合精度训练AMP约40%-50%中等同时加速训练
用inplace操作少量最简单代码里顺手改

5.2 过拟合:Dropout、数据增强和正则化三板斧

VGG16全连接层参数极多,在小数据集上训练极其容易过拟合。我自己第一次在几千张图片上训VGG16时,训练集准确率直接冲到99%,测试集只有70%出头,典型的过拟合。

解决过拟合三板斧按有效性排序:数据增强效果最好,RandomCrop、RandomHorizontalFlip、RandomRotation、ColorJitter这些都用上;Dropout次之,模型里已经自带了p=0.5的Dropout,如果还是过拟合可以加大到0.7;weight_decay(L2正则)排第三,CIFAR-10上我设5e-4,如果自己的数据集很小,可以适当调大到1e-3。

还有一招是我后来才学会的:早停(early stopping)。每训完一个epoch看验证集准确率,连续10个epoch没有提升就停止训练并回滚到最佳模型。这个方法虽然没有在网络结构层面做过拟合抑制,但实际效果非常好,能帮你省下大量时间和算力。

5.3 训练不收敛或Loss异常

很多人在自己数据集上从头训练VGG16时,发现loss下降非常慢甚至原地不动。我排查这个问题的一般顺序是:先看数据预处理有没有做归一化,图像像素值不归一化直接喂进网络很可能会导致梯度爆炸;再看学习率,VGG在SGD下的常见学习率范围是0.01到0.1,学习率太小收敛慢得像蜗牛,太大loss直接变成NaN;最后看标签有没有分类不平衡,如果某个类别占了90%以上,模型会直接学成一个"分类器输出全是多数类"的偷懒解。

如果loss在训练初期就出现NaN,除了学习率的问题,还要检查数据里有没有异常值、标签有没有错误。我自己遇到过最诡异的一次是数据集中混入了全黑的图片,导致模型输出极端值后梯度爆炸,清理数据后问题立刻消失。

5.4 VGG16相比ResNet等现代网络的局限

聊VGG16的坑,不能不说它的天然局限。首先是计算量大,完整跑一次前向加反向要的FLOPs约为15.5G,相比之下ResNet50只要约4.1G。其次是梯度消失问题,16层的深度在当年算深,但放到今天动辄上百层的网络面前,VGG的层数其实不足以学到非常复杂的特征表示。这就是为什么后来ResNet引入残差连接来解决深层网络的退化问题。

但VGG在现代深度学习里依然有不可替代的价值。它结构透明、特征可视化直观、迁移学习表现稳定,很多论文仍然用VGG16做特征提取的baseline。对它地位的最好概括是:你不需要用VGG16打比赛,但你一定要会用VGG16来理解CNN。

6. VGG16在真实项目里的使用心得

6.1 特征图可视化:亲手看看网络学到了什么

我在带项目时最喜欢做的一件事,就是把训练好的VGG16各层特征图打印出来看。PyTorch里通过注册forward hook就能轻松拿到中间层的输出。看完你会有一种直观的感受:浅层特征图学到的是边缘、颜色、纹理这类低级特征,特征图看起来还像原图的轮廓;越往深层,特征图越来越抽象,越来越难从人的角度解释,但正是这些抽象特征让分类准确率大幅提升。

这个观察对面试和做项目都很有用。比如做图像分类,很多人一开始想的都是"要不要自己设计特征提取器",看了特征图你就会明白,深度学习已经把这件事自动化了,你真正要操心的是数据、网络结构和训练策略。

6.2 用VGG16做迁移学习的实战建议

基于我多次用VGG16做迁移学习的经验,有几点很实在的建议。数据量小(几千张)时,输入图片不要盲目resize到224x224,可以先用小的尺寸比如128x128做几次实验,先验证整个pipeline能不能跑通,再用完整尺寸精调。冻结特征层时一定要把BatchNorm层(如果有)也设为eval模式,否则BN的统计量会在推理时出错。做二分类时,把最后一层的输出从1000改成2,损失函数用CrossEntropyLoss,一切照常。

很多初学者在迁移学习时会犯一个错误:用pretrained=True的模型,却忘了给输入做ImageNet的标准化。ImageNet预训练模型的输入必须是经过mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]归一化的数据,否则模型会认为你喂给它的是完全陌生的数据分布,效果直接差一大截。这个细节不起眼,但掉进去的人真的非常多。

6.3 基于VGG衍生出来的那些变体

VGG家族除了VGG16还有VGG11、VGG13、VGG19等变体,区别主要在于卷积层的数量。VGG19把Block3、Block4、Block5都扩展成4个卷积层,效果略优于VGG16,但计算量明显增加。实际使用中VGG16是最常用的,性价比最高。

在项目落地时,还有一个常见选择是用VGG16的BN版本(nn.Conv2d后面加nn.BatchNorm2d)。BatchNorm可以让模型收敛更稳定,对学习率的敏感度降低,训练时可以稍微放开步子。PyTorch里用models.vgg16_bn(pretrained=True)就能直接加载带BN的预训练权重。我当时在一组对比实验里,相同数据下VGG16_bn比原始VGG16大概高出1到2个百分点的准确率,训练过程也更平稳,如果你不追求和论文严格对齐,直接上BN版本是更好的选择。

我实际用过很多次VGG16之后,最大的感受是它像一个很趁手的工具箱:结构透明、没有黑魔法,出了什么问题都能顺着网络结构一步步排查。真正的工程能力不是会用最先进的模型,而是对经典模型的理解足够深刻,能在各种约束条件下做出正确的取舍。VGG16就是练这种能力的最好起点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询