1. 为什么在2024年还要手写一遍VGG
如果你翻过近几年新出的图像分类模型,会发现VGG这个名字出现的频率越来越低。ResNet、EfficientNet、ConvNeXt、ViT,一个比一个能打,参数量更小、精度更高、推理更快。那为什么还要花时间用PyTorch把VGG从头实现一遍?
我先说结论:VGG不是拿来刷榜的,它是拿来理解卷积神经网络设计哲学的标本。你去看现在任何一个主流骨干网络,里面关于感受野控制、通道数翻倍策略、特征图尺寸与通道数的权衡关系,几乎都能在VGG里找到原型。尤其是它那个"连续两个3×3卷积堆叠等价于一个5×5卷积"的经典论断,至今仍是很多轻量化网络设计的底层逻辑。
另一个现实原因是,很多工业质检、医疗影像、遥感分类的场景里,数据集规模并不大,预训练权重加上VGG的迁移学习,反而比一些花哨的新模型更稳。我见过好几个做表面缺陷检测的团队,最后落地用的还是VGG16的某个变体,原因很简单:特征提取层够深、够规整,微调的时候不容易崩。
这篇文章面向的读者是:已经跑通过MNIST或CIFAR-10简单CNN,想进一步理解经典网络结构的人;或者需要把VGG作为backbone嵌入自己项目、但不想直接调torchvision.models.vgg16黑盒的人。我会从结构原理讲到逐层实现,再到训练调参和踩坑记录,代码全部可复现。
提示:本文默认你已经有PyTorch基础,知道
nn.Module、DataLoader、CrossEntropyLoss这些基本组件怎么用。如果这些还不熟,建议先把官方60分钟入门教程过一遍再回来。
2. VGG的结构哲学:不是堆深就完事
2.1 从AlexNet到VGG,到底改了什么
AlexNet在2012年拿下ImageNet冠军之后,大家的第一反应是"网络越深越强"。但真正动手去加深的时候,问题就来了:参数爆炸、梯度消失、训练不收敛。VGG的贡献不在于"深",而在于用一种极其规整的方式把深度做上去了。
具体来说,VGG相比AlexNet做了三个关键改动:
第一,全部使用3×3卷积核。AlexNet第一层用了11×11,第二层用了5×5,VGG把所有这些大核全部换成3×3。为什么?因为两个3×3卷积堆叠的感受野等于一个5×5,三个3×3堆叠等于一个7×7,但参数量少得多。算一笔账:假设输入输出通道都是C,一个5×5卷积的参数量是25C²,而两个3×3卷积的参数量是2×9C²=18C²,少了28%。同时非线性激活函数多了一层,表达能力反而更强。
第二,通道数按固定倍数增长。VGG的特征提取部分遵循一个非常清晰的模式:64→128→256→512→512。每次经过池化层,空间尺寸减半,通道数翻倍。这个设计背后的直觉是:随着空间分辨率降低,我们需要更多的通道来保留信息量,维持每一层的"信息容量"大致平衡。
第三,结构高度统一。整个网络就是"卷积-卷积-池化"的重复,没有任何花哨的分支或跳跃连接。这种规整性让VGG非常容易实现和修改,也让它成为后来很多网络设计的参照基准。
2.2 感受野的数学:为什么3×3是黄金尺寸
很多人知道"两个3×3等于一个5×5",但未必清楚这个等价关系是怎么推导的。我用最直白的方式说一遍。
感受野的计算公式是:上一层感受野为R,当前层卷积核大小为K,步长为S,则当前层感受野为 R' = R + (K-1) × 前面所有层的步长乘积。
对于连续两个3×3卷积(步长都为1):第一层感受野是3×3,第二层在3×3的基础上再扩展(3-1)×1=2,得到5×5。三个3×3堆叠就是7×7。这个推导假设没有池化层介入,一旦中间有步长为2的池化,感受野的增长会更快。
那为什么不直接用7×7?除了参数量,还有一个重要原因是非线性。每个卷积后面都跟着ReLU,三个3×3就有三次非线性变换,而一个7×7只有一次。非线性越多,决策函数的表达能力越强,这是VGG论文里明确提到的论点。
实际写代码的时候,你可以用下面这个函数来快速验证感受野:
def receptive_field(layers): """layers: list of (kernel_size, stride) tuples""" rf = 1 stride_prod = 1 for k, s in layers: rf += (k - 1) * stride_prod stride_prod *= s return rf # 两个3x3 print(receptive_field([(3,1),(3,1)])) # 5 # 一个5x5 print(receptive_field([(5,1)])) # 52.3 VGG16与VGG19的差异,以及该选哪个
VGG家族最常用的是VGG16和VGG19,数字代表带权重的层数(卷积层+全连接层)。两者的区别只在特征提取阶段:VGG16有13个卷积层+3个全连接层,VGG19有16个卷积层+3个全连接层。多出来的3个卷积层分别加在第三、第四、第五个卷积块里。
| 配置 | 卷积层数 | 全连接层数 | 参数量 | ImageNet Top-5 |
|---|---|---|---|---|
| VGG11 | 8 | 3 | 约133M | 89.8% |
| VGG13 | 10 | 3 | 约133M | 90.5% |
| VGG16 | 13 | 3 | 约138M | 90.1% |
| VGG19 | 16 | 3 | 约144M | 90.0% |
从表里能看出一个有意思的现象:VGG19比VGG16深,但精度并没有明显提升,甚至略低。这说明深度不是无脑加就好,VGG16已经接近这个结构的上限。所以我的建议是:默认用VGG16,除非你的任务特别需要更深的特征层次,否则VGG19带来的收益微乎其微,反而增加训练成本。
还有一个常被忽略的点:VGG的全连接层参数量占了整个网络的绝大部分。以VGG16为例,三个全连接层分别是25088×4096、4096×4096、4096×1000,参数量加起来超过1.2亿,而13个卷积层的参数量加起来才约1500万。这也是为什么后来大家做迁移学习时,普遍会把全连接层砍掉,只保留卷积部分作为特征提取器。
3. 逐层拆解:用PyTorch把VGG搭出来
3.1 特征提取层的模块化设计
直接一层一层写nn.Conv2d当然可以,但那样代码又长又难改。更聪明的做法是用配置列表来定义结构。VGG论文里其实就用了这种表示法,用字母M表示MaxPool,数字表示卷积输出通道数。
import torch import torch.nn as nn cfgs = { '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'就加一个最大池化层。以VGG16为例,第一个卷积块是64、64两个卷积加一个池化,第二个块是128、128两个卷积加一个池化,以此类推。
接下来写生成特征层的函数:
def make_features(cfg): layers = [] in_channels = 3 # RGB输入 for v in cfg: if v == 'M': layers.append(nn.MaxPool2d(kernel_size=2, stride=2)) else: layers.append(nn.Conv2d(in_channels, v, kernel_size=3, padding=1)) layers.append(nn.ReLU(inplace=True)) in_channels = v return nn.Sequential(*layers)这里有几个细节值得说清楚:
padding=1的作用。3×3卷积如果不加padding,每次特征图尺寸会缩小2。VGG的设计原则是"卷积不改变空间尺寸,只有池化才降采样",所以必须用padding=1来保持尺寸不变。这个"same padding"的思路后来被几乎所有网络沿用。
inplace=True的取舍。ReLU的inplace操作可以节省一点显存,因为它直接修改输入张量而不创建新张量。但在某些需要保留原始特征图的场景(比如做特征可视化)下,inplace会带来麻烦。训练时用inplace没问题,推理或调试时可以关掉。
为什么用MaxPool2d(2,2)而不是AdaptiveAvgPool。VGG时代还没有全局平均池化的概念,全部用固定尺寸的最大池化。kernel_size=2、stride=2意味着每次特征图长宽各减半。输入224×224经过5次池化变成7×7,这个尺寸刚好能展平后接全连接层。
3.2 分类头的设计陷阱
特征提取之后是分类头,VGG原始设计是三个全连接层:
class VGG(nn.Module): def __init__(self, features, num_classes=1000, init_weights=True): super(VGG, self).__init__() self.features = features self.avgpool = nn.AdaptiveAvgPool2d((7, 7)) self.classifier = nn.Sequential( nn.Linear(512 * 7 * 7, 4096), nn.ReLU(True), nn.Dropout(), nn.Linear(4096, 4096), nn.ReLU(True), nn.Dropout(), nn.Linear(4096, num_classes), ) if init_weights: self._initialize_weights()这里有个容易踩的坑:输入尺寸不匹配。如果你用的不是224×224的输入,经过5次池化后的特征图尺寸就不是7×7,512*7*7这个维度就会报错。解决办法有两个:一是加一个AdaptiveAvgPool2d((7,7))强制统一尺寸(上面代码就是这么做的),二是根据实际输入尺寸动态计算。
我个人的习惯是加AdaptiveAvgPool,因为它让网络对输入尺寸更鲁棒。但要注意,如果你的输入远小于224,强行pool到7×7会丢失大量信息,这时候应该考虑减少池化次数或者用更小的全连接维度。
另一个坑是Dropout的位置。原始VGG论文里Dropout只加在全连接层之间,卷积层之间没有。有些实现会在卷积块里也加Dropout,这在数据集小的时候可能有用,但大数据集上反而会拖慢收敛。我的建议是:先按原论文来,只在全连接层加Dropout,p=0.5。如果发现过拟合严重,再考虑在卷积层加。
3.3 权重初始化:别小看这一步
VGG论文里提到,由于网络很深,直接用随机初始化会导致训练困难。他们先用一个浅层网络训练到收敛,再用它的权重去初始化深层网络的前几层。这个做法在今天看来有点笨,但当时确实是有效的。
现在我们有了更好的初始化方法。PyTorch默认用Kaiming初始化,对ReLU网络效果不错。但如果你想复现论文效果,可以手动写一个初始化:
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.BatchNorm2d): nn.init.constant_(m.weight, 1) 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)注意全连接层用的是normal_(0, 0.01)而不是Kaiming,这是VGG论文里的原始设置。标准差0.01很小,目的是让初始输出接近0,避免一开始就产生大梯度。实测下来,这个设置在VGG上确实比默认初始化收敛更稳。
注意:如果你打算加载预训练权重,就不要自己初始化了,否则会覆盖掉预训练参数。正确的顺序是先创建模型(init_weights=False),再load_state_dict。
4. 训练VGG的实战配置与调参逻辑
4.1 数据增强:VGG需要的比你想的更多
VGG参数量大,过拟合风险高,数据增强是必须的。原始论文用了随机裁剪、水平翻转、颜色抖动等手段。用PyTorch的transforms可以这样组合:
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.08, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.4, contrast=0.4, saturation=0.4), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])RandomResizedCrop的scale=(0.08, 1.0)是原始论文的设置,意思是随机裁剪原图8%到100%的区域再缩放到224。这个范围很宽,能产生很多尺度变化,对小数据集特别有效。
Normalize用的均值和标准差是ImageNet的统计值。如果你在自己的数据集上训练,最好重新算一下这两个值。计算方法很简单:遍历训练集,累加每个通道的像素值和平方值,最后求均值和标准差。用错归一化参数不会让训练完全失败,但会拖慢收敛速度。
4.2 优化器与学习率:SGD还是Adam
VGG原始论文用的是SGD加动量,初始学习率0.01,每当验证集准确率不再提升就把学习率除以10。这个策略在今天依然有效,但需要手动调。
我的经验是:从头训练用SGD,迁移学习用Adam。从头训练时SGD的泛化性能通常更好,而且配合学习率衰减能稳定收敛。迁移学习时因为只需要微调,Adam的自适应学习率更方便,不用太纠结初始值。
# 从头训练 optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.1) # 迁移学习 optimizer = torch.optim.Adam(model.classifier.parameters(), lr=1e-3)weight_decay=5e-4是VGG论文里的设置,相当于L2正则化。这个值对VGG这种大模型很重要,能明显抑制过拟合。但注意,weight_decay不应该加在偏置和BatchNorm参数上,PyTorch里需要手动分组:
decay_params = [] no_decay_params = [] for name, param in model.named_parameters(): if 'bias' in name or 'bn' in name: no_decay_params.append(param) else: decay_params.append(param) optimizer = torch.optim.SGD([ {'params': decay_params, 'weight_decay': 5e-4}, {'params': no_decay_params, 'weight_decay': 0} ], lr=0.01, momentum=0.9)4.3 Batch Size与显存的平衡
VGG16的参数量约138M,加上梯度、优化器状态,训练时显存占用不小。以224×224输入、batch size 32为例,大概需要10-12GB显存。如果显存不够,有几个选择:
第一,减小batch size。但batch size太小(比如小于16)会导致BatchNorm统计不稳定,训练震荡。这时候可以改用GroupNorm或者SyncBN。
第二,冻结部分层。如果做迁移学习,可以只训练后面的卷积块和分类头,前面的层冻结。这样显存占用和计算量都会大幅下降。
第三,用混合精度训练。PyTorch的torch.cuda.amp能把显存占用降低约40%,速度提升20%-30%。用法很简单:
scaler = torch.cuda.amp.GradScaler() for images, labels in dataloader: with torch.cuda.amp.autocast(): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() optimizer.zero_grad()混合精度唯一的坑是某些操作在fp16下会溢出,比如softmax前的logits如果太大。GradScaler会自动处理这个问题,但如果你自定义了loss函数,需要确认它是否支持fp16。
4.4 学习率预热与余弦退火
原始VGG论文没有用学习率预热,但现代训练实践中预热几乎成了标配。原因是训练初期权重随机,梯度方向不稳定,大学习率容易把模型带偏。预热就是在最初几个epoch用很小的学习率,然后线性增加到设定值。
def warmup_lr(epoch, warmup_epochs=5, base_lr=0.01): if epoch < warmup_epochs: return base_lr * (epoch + 1) / warmup_epochs return base_lr配合余弦退火效果更好:
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-5)余弦退火的好处是学习率平滑下降,不会像StepLR那样突然跳变。实测在VGG上,预热5个epoch加余弦退火,比原始的分段衰减收敛更稳,最终精度也能高0.5到1个百分点。
5. 那些只有跑过才知道的坑
5.1 全连接层维度不匹配的三种情况
这是新手最容易遇到的问题,报错信息通常是mat1 and mat2 shapes cannot be multiplied。根本原因是特征图展平后的维度和全连接层输入维度对不上。具体分三种情况:
情况一:输入尺寸不是224。比如你用32×32的CIFAR图像,经过5次池化变成1×1,展平后是512维,但全连接层期待的是512×7×7=25088维。解决办法是减少池化次数,或者把全连接层输入改成512。
情况二:改了池化层。有人为了保留更多空间信息,把某个MaxPool换成stride=1,结果特征图尺寸变大。这时候需要重新计算展平维度。
情况三:用了AdaptiveAvgPool但输出尺寸设错。AdaptiveAvgPool2d((7,7))的输出一定是7×7,不管输入多大。但如果你设成(1,1),那全连接层输入就应该是512而不是25088。
我的建议是:在写全连接层之前,先用一个假输入跑一遍特征提取部分,打印输出形状。这样能避免90%的维度错误。
dummy = torch.randn(1, 3, 224, 224) features = make_features(cfgs['vgg16']) out = features(dummy) print(out.shape) # torch.Size([1, 512, 7, 7])5.2 BatchNorm加不加,加在哪里
原始VGG论文里没有BatchNorm,因为BN是2015年才提出的。但后来的实践表明,在VGG里加BN能显著加速收敛,而且允许用更大的学习率。torchvision的VGG实现就默认带了BN。
加BN的位置是在每个卷积层之后、ReLU之前:
layers.append(nn.Conv2d(in_channels, v, kernel_size=3, padding=1)) layers.append(nn.BatchNorm2d(v)) layers.append(nn.ReLU(inplace=True))但要注意,加了BN之后,weight_decay不应该作用在BN的参数上(前面已经说过)。另外,BN在batch size很小时统计量不准,如果batch size小于8,建议改用GroupNorm。
还有一个细节:BN的momentum。PyTorch默认是0.1,意思是滑动平均更新较慢。如果数据集分布变化快,可以调小到0.01。但大多数情况下默认值就够了。
5.3 迁移学习时该冻结哪些层
用预训练VGG做迁移学习时,一个常见问题是:到底该冻结多少层?我的经验法则如下:
| 目标数据集规模 | 与ImageNet相似度 | 建议策略 |
|---|---|---|
| 很小(<1k) | 高 | 只训练分类头 |
| 很小(<1k) | 低 | 训练后两个卷积块+分类头 |
| 中等(1k-10k) | 高 | 训练后三个卷积块+分类头 |
| 中等(1k-10k) | 低 | 训练全部层,小学习率 |
| 大(>10k) | 任意 | 从头训练或全部微调 |
冻结的实现很简单:
for param in model.features[:20].parameters(): param.requires_grad = False但要注意,冻结层里的BN仍然会更新running_mean和running_var,除非你显式调用model.eval()。如果冻结了卷积层但BN还在更新,推理时可能出现不一致。稳妥的做法是把冻结部分的BN也设为eval模式:
def freeze_bn(module): if isinstance(module, nn.BatchNorm2d): module.eval()5.4 显存溢出与梯度累积
前面提到减小batch size会导致BN不稳定,但显存又不够,怎么办?梯度累积是一个好办法。它的思路是用小batch多次前向,累积梯度,再一次性更新权重,等效于大batch。
accumulation_steps = 4 optimizer.zero_grad() for i, (images, labels) in enumerate(dataloader): outputs = model(images) loss = criterion(outputs, labels) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()注意loss要除以累积步数,否则梯度会放大。另外,如果用了BN,梯度累积并不能完全等效于大batch,因为BN的统计量还是基于小batch算的。这时候可以考虑SyncBN或者GroupNorm。
6. 从训练日志里读出问题
6.1 损失不下降的排查顺序
训练VGG时如果loss一直不降,按以下顺序排查:
第一步,检查数据。把几张图片和标签打印出来,确认图像没读错、标签没对错。我遇到过有人把归一化参数写反了,mean和std互换,结果loss死活不降。
第二步,检查学习率。学习率太大会震荡,太小会下降极慢。可以做一个简单的学习率扫描:用几个不同的学习率各跑100个iteration,看哪个loss下降最快。
第三步,检查梯度。打印每一层的梯度范数,如果大部分层梯度接近0,说明梯度消失,需要加BN或者换初始化。如果梯度爆炸,需要加梯度裁剪:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)第四步,检查损失函数。分类任务用CrossEntropyLoss,注意它内部已经包含了softmax,不要在模型最后再加softmax,否则会重复计算导致loss异常。
6.2 过拟合的识别与应对
过拟合的典型表现是:训练loss持续下降,验证loss先降后升,验证准确率停滞甚至下降。应对手段按优先级排序:
第一,加数据增强。这是最有效的手段,没有之一。除了前面提到的翻转、裁剪、颜色抖动,还可以加随机擦除(RandomErasing)、MixUp、CutMix。
第二,加正则化。增大weight_decay,或者在全连接层加Dropout。VGG原始设置是Dropout p=0.5,如果过拟合严重可以加到0.6或0.7。
第三,早停。监控验证集准确率,连续N个epoch不提升就停止训练。N一般取5到10。
第四,减小模型。如果数据量确实太小,可以考虑用VGG11或者只取VGG16的前几个卷积块。
6.3 训练速度优化
VGG训练慢是出了名的,尤其是全连接层。几个提速技巧:
使用channels_last内存格式。PyTorch默认是NCHW,但在某些GPU上NHWC更快:
model = model.to(memory_format=torch.channels_last) images = images.to(memory_format=torch.channels_last)开启cudnn benchmark。如果输入尺寸固定,让cudnn自动选择最快的卷积算法:
torch.backends.cudnn.benchmark = True用DataLoader的多进程。设置num_workers=4或8,pin_memory=True,能显著减少数据加载的等待时间。
考虑把全连接层换成全局平均池化。VGG的全连接层参数量巨大,如果任务允许,可以用AdaptiveAvgPool2d(1)加一个线性层替代,参数量从1.2亿降到几万,速度提升非常明显。代价是精度可能略降,但在很多迁移学习任务上影响不大。
7. 把VGG用在你自己的数据上
7.1 小数据集的迁移学习完整流程
假设你有一个几千张图片的分类任务,类别数是10。完整流程如下:
第一步,加载预训练VGG16,替换分类头:
from torchvision import models model = models.vgg16(pretrained=True) model.classifier[6] = nn.Linear(4096, 10)第二步,冻结特征提取层,只训练分类头:
for param in model.features.parameters(): param.requires_grad = False第三步,用Adam训练分类头,学习率1e-3,跑10个epoch。
第四步,解冻后三个卷积块,用SGD微调,学习率1e-4,跑20个epoch。
第五步,如果验证集准确率还在提升,解冻全部层,学习率降到1e-5,再跑10个epoch。
这个渐进式解冻的策略比一次性全部微调更稳,尤其是数据量小的时候。我试过在同一个数据集上,渐进式解冻比直接全量微调最终准确率高2到3个百分点。
7.2 特征提取模式:把VGG当特征生成器
有时候你不需要分类,而是需要VGG的中间层特征。比如做图像检索、风格迁移、或者作为其他任务的输入。这时候可以只取特征提取部分:
model = models.vgg16(pretrained=True).features model.eval() def extract_features(x, layers=[3, 8, 15, 22, 29]): features = [] for i, layer in enumerate(model): x = layer(x) if i in layers: features.append(x) return featureslayers列表里的数字对应VGG16特征提取部分中特定层的索引。这些层的输出分别对应不同尺度的特征:浅层是边缘纹理,深层是语义信息。具体用哪一层取决于你的任务,一般做检索用中间层(如15或22)效果比较好。
7.3 模型保存与加载的注意事项
保存模型时,推荐保存state_dict而不是整个模型:
torch.save(model.state_dict(), 'vgg16_custom.pth')加载时先创建相同结构的模型,再load:
model = VGG(make_features(cfgs['vgg16']), num_classes=10) model.load_state_dict(torch.load('vgg16_custom.pth'))如果保存的是整个模型(torch.save(model, ...)),加载时会依赖原始的类定义,换一个文件或改过类名就会报错。所以永远保存state_dict。
还有一个坑:如果训练时用了DataParallel或DistributedDataParallel,state_dict的key会多一个module.前缀。加载到单卡模型时需要去掉:
state_dict = torch.load('model.pth') new_state_dict = {k.replace('module.', ''): v for k, v in state_dict.items()} model.load_state_dict(new_state_dict)8. 一些个人体会
VGG是我最早手写实现的经典网络之一,当时踩的坑现在还记得。最开始我直接照搬论文里的结构,结果在CIFAR-10上怎么都训不起来,后来才发现是输入尺寸和全连接层维度不匹配。改成AdaptiveAvgPool之后问题解决,但精度一直上不去,又折腾了好久才发现是学习率太大导致震荡。
后来我慢慢意识到,VGG的价值不在于它本身有多强,而在于它提供了一个足够简单、足够规整的基线。你可以在它上面做各种实验:换激活函数、加BN、改池化方式、调整通道数,每一次改动的影响都能清晰地观察到。这种"可控性"是很多复杂网络给不了的。
如果你正在学深度学习,我建议至少手写一遍VGG,不要直接调库。写的过程中你会被迫思考每一个设计决策的原因,这种思考比跑通十个现成模型都有价值。写完之后再去看ResNet、DenseNet,你会发现很多设计其实是在解决VGG暴露出来的问题——梯度消失、参数量大、训练慢。理解了问题,才能理解解决方案。
最后分享一个小技巧:如果你觉得VGG16的全连接层太占显存,可以把classifier的前两层换成nn.Linear(512*7*7, 512)和nn.Linear(512, 512),参数量从1.2亿降到不到3000万,精度损失通常在1%以内。这个改动在显存紧张的时候非常实用。