1. VGG16的“笨功夫”设计哲学:为什么堆小卷积核反而更香
大家有没有一种感觉:卷积神经网络的教程刷了好几篇,ResNet、Inception、EfficientNet这些“花活”越看越多,真到自己动手做一个图像分类任务时,反而有点不知道该从哪个网络下手。我做鸟类图片4分类这个项目时也有同样的纠结,后来兜兜转转还是用回了VGG16,原因其实很朴素——它结构足够简单,简单到你能一层层说清楚它在干什么,而且预训练权重好找,迁移学习用起来非常顺手。
先聊聊VGG16最核心的设计思想。VGG16在2014年被提出来的时候,干了一件看起来特别“笨”的事:整个网络从头到尾只用3×3卷积核(以及少数1×1卷积做维度变换),但把网络挖得很深。当时主流思路是LeNet、AlexNet这种“大卷积核+浅层数”的路子,觉得7×7、11×11的大核才能看得足够远。但VGG的作者用一组非常扎实的实验证明了一件事:多个小卷积核堆叠,可以等效替代一个大卷积核,而且效果更好。
我来算一笔账。假设输入特征图是224×224,想要获得一个感受野为7×7的卷积结果:
- 用一层7×7卷积核,直接覆盖7×7区域,参数量是7×7×C×C = 49C²,C是输入输出通道数(先简化一下,假设输入输出通道一样)。
- 用三层3×3卷积核堆叠,第一层感受野3×3,第二层叠加到5×5,第三层叠加到7×7,参数量是3×(3×3×C×C) = 27C²。
参数量从49C²降到27C²,直接省了接近45%。而且中间多了两个非线性激活层,特征表达的非线性能力反而更强了。这就像你把一个大而笨的部门拆成三个小而精的流水线,每一层做一点点特征提炼,最后看到的“视野”一样大,但细节分层更细,整体更高效。
VGG16的另一个特点是通道数逐级翻倍:从64到128、256、512,最后到512。前几层卷积提取边缘、颜色、纹理这些低级特征,不需要太宽的通道;越往后特征越抽象,每个通道相当于在问“这个位置是不是某种鸟类特有的斑点”“这里是不是翅膀轮廓的一部分”,所以得加宽通道来容纳更多种类的抽象特征。每经过一次MaxPool(2×2、步长2),特征图尺寸减半,空间信息被压缩,通道数补上来,这其实是一种信息编码的平衡:把“位置在哪”的精度降下来,把“这是什么”的表达能力提上去。
我在复现的时候顺手整理了一张VGG16的层配置表,项目里做迁移学习、改分类头、数参数量都用得上:
| 模块 | 卷积层配置 | 输出尺寸 | 参数量 |
|---|---|---|---|
| 输入 | 224×224×3 图像 | 224×224×3 | - |
| Block 1 | conv3-64 ×2 | 224×224×64 | 约3.9万 |
| MaxPool | 2×2,步长2 | 112×112×64 | - |
| Block 2 | conv3-128 ×2 | 112×112×128 | 约22万 |
| MaxPool | 2×2,步长2 | 56×56×128 | - |
| Block 3 | conv3-256 ×3 | 56×56×256 | 约58万 |
| MaxPool | 2×2,步长2 | 28×28×256 | - |
| Block 4 | conv3-512 ×3 | 28×28×512 | 约230万 |
| MaxPool | 2×2,步长2 | 14×14×512 | - |
| Block 5 | conv3-512 ×3 | 14×14×512 | 约230万 |
| MaxPool | 2×2,步长2 | 7×7×512 | - |
| 全连接层 | FC1 4096 + FC2 4096 + FC3 1000 | 1×1×1000 | 约1.02亿 |
看到没,VGG16总共约1.38亿参数,大头全在最后三个全连接层上。前五个卷积块总共也就五千多万参数,后面三个FC层直接吃掉一亿多参数。这也是为什么VGG16模型文件那么大(大约528MB),训练和推理都比后来的一些网络更吃显存和算力。但换个角度看,正是因为全连接层特别重,迁移学习时只要把最后那个1000类的输出头换掉,前面积累的通用特征提取能力几乎能原封不动地保留下来。
那2014年的模型放到现在还有多大价值?我的判断是:作为动手学习迁移学习的载体,VGG16依然是极好的选择。它没有残差连接,没有注意力机制,没有多尺度特征融合,正因为如此,反向传播的路径非常清晰,每一层梯度怎么流、特征图怎么变化,都能直接看出来。你拿它跑一个项目,踩的坑是“可解释的坑”,不是黑盒那种云里雾里的坑。等你把VGG16搞明白了,再上手ResNet、EfficientNet,思路会顺很多。而且各个深度学习框架对VGG16的预训练权重支持非常完善,ImageNet上训出来的权重文件随处可得,这对做小数据集分类来说简直是雪中送炭。
2. 鸟类4分类数据集准备:这个环节决定模型上限
很多教程会把数据准备一笔带过,好像“下载个数据集、解压、放到文件夹里”就完事了。但真做鸟类图片4分类你会发现,数据这块的脏活累活比训练本身更考验耐心,而且直接决定你最终准确率能到多少。
2.1 数据来源与清洗:千万别直接拿爬来的图喂模型
鸟类图片分类任务最麻烦的点在于:鸟类照片的背景太复杂了。同一只鸟,可能在树枝上、水里、天空、草地、铁丝网上,背景里的绿色、蓝色、灰色极其容易干扰模型。我自己做这个项目时,用的是某个公开的鸟类图像数据集,拿到的原始图片有几千张,粗看起来数量够用,但实际查看后发现至少有这些坑:
- 有些图片是合成图或者插画,不是真实照片,这类样本必须删,否则训练集和测试集分布会错乱;
- 同一只鸟的不同姿态差异很大,有的在飞行中只拍到翅膀,有的在觅食只露出头,如果某一类里这种“局部图”特别多,模型很容易走捷径(比如看到一片蓝色就当它是某类);
- 有的图片标签明显标错了,尤其是外观相近的鸟,得人工过一遍。
我的做法是:每类先按候选清单把图片摊开看一遍,删除分辨率过低(比如小于100×100)、明显跑题、重复度太高的样本,然后剩下的按比例分成训练集和验证集。这一步听起来简单,四类鸟每类筛几百张,实际上花了我一整个晚上。但别嫌烦,输入数据里的脏东西,模型一定会原封不动地学进参数里,后面你再怎么调学习率都是白费。
2.2 类别不平衡:四类数量差距不大的时候也别掉以轻心
鸟类4分类的四个类别,理想情况是每类样本数差不多。但现实往往是某一种鸟特别容易拍到,另一种比较少见,数据量可能差出两三倍。如果你的训练集里A类有600张、B类只有200张,模型天然会偏向预测A类,因为把B类猜成A类对训练loss的“惩罚”被大量A类样本稀释了。
处理这个问题的几种做法,我按推荐度排序:
- 数据增强补量:对样本少的类别加大增强力度,比如多裁几组不同比例的随机裁剪、多旋转几次、颜色抖动强一点,把200张变成500张的等效数据量。这个做法最通用,也不改变原始数据分布。
- 权重采样:在采样器里给少样本类别更高的采样权重,每次epoch多抽几次少样本类。PyTorch里可以用
WeightedRandomSampler实现,代码不复杂但效果很直接。 - 类别权重损失:在CrossEntropyLoss里传入
weight参数,给少样本类更大的loss权重。这是我的备选手段,一般不作为首选,因为权重设不好容易让训练震荡。
我最后用的是“数据增强补量+WeightedRandomSampler”组合,没有动损失函数的权重。实测下来训练更稳,验证集上四类的召回率也相对均衡。
2.3 预处理与数据增强:差一个CenterCrop都可能让结果崩掉
图像分类任务里,预处理这一段看起来就是几行标准化代码,但在迁移学习场景下是有严格讲究的。VGG16在ImageNet上训练时的输入是224×224的RGB图像,归一化用的是ImageNet数据集的均值和标准差:(0.485, 0.456, 0.406)和(0.229, 0.224, 0.225)。你用PyTorch自带的预训练权重时,必须按这套参数做归一化,否则预训练模型接收到的是“一个它没见过分布的数据分布”,相当于跟一个只学过普通话的人突然说方言,效果当然会打折。
数据增强这里,我踩过一个很典型的坑。训练阶段我用了RandomResizedCrop(随机裁剪缩放)、RandomHorizontalFlip(随机水平翻转)、ColorJitter(颜色抖动)、RandomRotation(随机旋转),效果不错,验证集准确率很快上去了。但测试阶段图省事,只做了Resize(256)和CenterCrop(224)。问题就来了:训练时模型看到的图像是“各种位置、各种尺度的鸟”,测试时全变成了“正中间一小块”,两者的分布差异会在迁移学习中放大,最终线上表现比验证集低好几个百分点。
正确的做法是测试阶段用和训练阶段一致的预处理逻辑:先Resize到256,然后CenterCrop到224,再做同样的归一化。这一个小细节,往往比调整损失函数更影响最终效果。
3. 迁移学习还是从零训练?VGG16在这个问题上的答案毫无悬念
3.1 为什么小数据集+从头训练VGG16是死路一条
先说结论:如果你的4分类任务,每类只有几百张图片,就放弃从零训练VGG16这个念头吧。原因在于VGG16有1.38亿个参数,它需要海量数据来约束这些参数不跑飞。从头训练你面临的不只是训练时间长,而是根本训不起来——几百张图片对1.38亿参数来说,约束项严重不足,模型会把训练集的噪声背下来,验证集一测就露馅。
从信息论的角度理解这件事:每个训练样本相当于给模型提供了一组约束条件,参数越多,需要的约束条件就越多。Backbone参数那么多,而你真正能用到的信息只有几千张图片,从零训练就是在“用漏桶接水”,桶太大,水不够。
但你的分类任务本身又很简单:只区分4类鸟,而且这4类的特征(羽毛颜色、体型轮廓等)在ImageNet的1000类中大概率都有对应的低级和中级特征。所以最合理的路线是:把预训练权重当作一个已经见过世界万千生物的“特征提取器”,你只需要在它后面加一个“小脑袋”来做最终决策。
3.2 替换分类头与冻结特征层:PyTorch实操代码
我用PyTorch做的迁移学习,核心操作分三步。第一步,加载预训练模型时把原本分类1000类的最后一个全连接层换成输出4类的新全连接层:
import torch import torch.nn as nn from torchvision import models, transforms model = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1) # 查看原本的分类器结构 # VGG16的classifier是一个Sequential容器 # 最后一层是 Linear(in_features=4096, out_features=1000) in_features = model.classifier[-1].in_features model.classifier[-1] = nn.Linear(in_features, 4) print(model)第二步,冻结特征提取层的全部参数,只让新加的4分类头可训练:
for name, param in model.named_parameters(): if name.startswith("features"): # features对应前五个卷积块 param.requires_grad = False # classifier部分保持requires_grad=True为什么要先冻结?因为刚换上去的分类头是随机初始化的,如果一开始就让特征层更新,梯度会携带着随机分类头带来的巨大噪声,反向传播到卷积层后极有可能破坏预训练权重里已经学得很好的特征。先让分类头在特征层「静止」的状态下找到合适的输出方向,训练稳定后再解冻部分特征层做微调,成功率会高非常多。
第三步,训练时会发现一个很有意思的现象:前几个epoch里,features层的参数虽然不更新,但它们的输出特征其实在不断“被使用得更充分”——因为分类头在快速适应任务。等分类头loss降到平台期,再解冻最后一个Block(比如Block5)的所有参数,用很小的学习率继续训,这时模型的整体能力会再上一个台阶。
3.3 两阶段调参的节奏:别一上来就把学习率拉满
迁移学习里最经典的错误之一,是解冻后还用原来的学习率。我自己的实践节奏是:
- 阶段一(冻结特征层):优化器选SGD,momentum=0.9,weight_decay=5e-4,初始学习率设0.001,batch_size给16(4类小数据量,不需要大batch)。训练20个epoch左右,观察验证集准确率进入平台期,loss不再下降。
- 阶段二(解冻Block5及之后的分类器):把学习率降到原来的十分之一,也就是0.0001,再训10~15个epoch。这一步相当于对预训练特征做“微调手术”,让最后几个卷积层输出的高级特征更贴合“鸟类4分类”这个任务。
很多入门者会在阶段一就发现验证集准确率已经到了90%以上,觉得万事大吉。但如果你继续做阶段二,往往能再往上推进两到三个百分点。这两到三个百分点,恰恰是从“能跑通”到“像那么回事”的差别。
4. 训练过程诊断:怎么判断模型是真的学会了,还是背答案
4.1 训练集和验证集的loss曲线分叉,是过拟合的第一信号
我在第一次训这个项目时,训练集loss一路从1.3降到0.15,验证集loss却降到0.6就不动了,然后开始缓慢回升。这是非常典型的过拟合信号:模型已经在用参数“背诵”训练集里的鸟类照片细节,包括背景里的草地纹理、光线角度、甚至摄影师的拍摄习惯。
遇到这种情况,首选手段不是换模型,而是调增强策略和正则化。我把RandomRotation的角度从10度放大到20度,ColorJitter的亮度、饱和度幅度加强了一点,同时把分类器部分的dropout从默认的0.5调高到0.6。这两个改动让验证集loss从回升变成继续下降,最终稳定在0.35左右。
4.2 混淆矩阵比准确率诚实得多
准确率的数字会骗人,混淆矩阵不会。4分类任务里,假设总体准确率93%,你根本看不出来是哪一类拉低了成绩。我训练完第一版直接输出了混淆矩阵,发现翠鸟和白胸翡翠这两个类别之间的误判占了全部错误样本的70%以上。原因是这两类鸟的羽毛蓝色区域都很大,而且数据集里拍得远的图片占比较高时,模型只能看到一团蓝色,根本分辨不出体型差异。
针对这个问题,我做了两件事:一是把训练集里“鸟占画面比例过小”的图片筛出来,要么删掉,要么在增强阶段加大RandomResizedCrop的裁剪尺度下限,迫使模型看到更多局部的鸟体细节;二是在测试时对这两类的预测概率做了专门的阈值检查,看到底是特征没学到,还是样本本身就该被判为“不确定”。最终把这两类的Recall分别从88%和91%提到了95%和96%。
4.3 一个差点翻车的验证细节:冻结层训练时,BatchNorm统计量怎么办
VGG16的卷积块里其实没有BatchNorm层(VGG16的BN变体是VGG16_BN),我用的标准VGG16只有ReLU和Dropout。但如果你选择的是带BN的版本,这里有一个巨坑:冻结整个features层时,BN层默认处于训练模式,仍然会更新running_mean和running_var。如果你在阶段一忘了把BN层设置为eval模式或者锁住BN的统计量更新,预训练模型的归一化分布会在训练过程中被悄悄带偏。
两种常见解法:一是冻结阶段干脆用model.eval()固定BN统计量,只让分类头的Dropout和训练行为生效;二是直接把bn_layer的weight和bias设为requires_grad=False,同时在forward时用训练模式但传入一个自定义的钩子保持running统计量不变。标准VGG16没有这个问题,但如果你换了带BN的Backbone就一定要注意。
4.4 学习率调度的另一种打开方式
很多人喜欢用ReduceLROnPlateau这类自动降低学习率的调度器,我也用过,但在这个项目里觉得它太“被动”。我更习惯的做法是手动观察验证集loss曲线,在它进入平台期时主动把学习率降到当前值的0.2倍。这样做的好处是,训练过程中的每个阶段自己心里都有数:前10个epoch是在拟合分类头,中间5个epoch是在稳固特征层边界,最后5个epoch是在用极小的步长做精细调整,每一步都不会突然让学习率跳变太大。
5. 测试阶段最容易翻车的三个细节:预处理、置信度阈值和类别映射
Academically naive的教程通常只教你怎么算验证集准确率,但真实场景里模型跑完训练代码只是完成了三分之一,后面测试和部署阶段的坑才真正考验水平。
5.1 训练用RandomResizedCrop,测试却忘了CenterCrop
这个问题我在第2章提过,但因为它真的太容易犯了,必须再强调一次。很多初学朋友会把数据增强代码写在训练脚本里,测试脚本就只写一个Resize(path, 224),然后直接把图片送给模型。这样做的结果是模型看到的测试图片不是它熟悉的“中心裁剪后缩放到224”,而是“直接压缩变形到224”,物体比例和空间位置都和训练时有偏差,准确率下降几个点是意料之中的事。
标准的测试预处理流水线,我建议固定成这一段,并且把它单独抽成一个函数,不要嵌套在训练代码里:
def get_test_transform(mean=..., std=...): return transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean, std) ])5.2 Softmax置信度到底能不能信
模型输出的4个概率加起来等于1,但你千万不能以为0.97的概率就意味着“模型有97%的把握是对的”。Softmax有一个臭名昭著的特性:它会把很小的logit差异放大成很悬殊的概率差。换句话说,模型输出0.97,它可能只是“相对比较确定”,而不是“绝对确定”。
在测试阶段,我会额外做一个“拒识”逻辑:如果模型对某张图片的最高置信度低于0.8,就不做预测,标记为“待人工确认”。别看这个小小的门槛,它能帮你把模型的误判率大幅降低。我把置信度阈值从0调到0.8后,测试集上错误样本从17张降到了6张,代价是还有8张被标记为待确认,但它们在业务上远比“机器给出一个错答案”更好处理。做工程不是非要让模型什么都答,让它知道自己不知道,反而是更高级的能力。
5.3 最容易翻车的不是网络结构,而是类别名和预处理的映射
最后一个小经验:项目收尾时,我会把类别对应的中文名、训练时的预处理参数、模型的输入输出约定,一并写进一个配置文件或者模型封装类的注释里。这看起来跟模型训练毫无关系,但等你过一个月回来看这段代码,或者有其他同事接手你的工作,这个文件能省掉大量沟通成本。我的鸟类4分类项目,四个类别保存为字典映射,输出层4个神经元的位置和类别名的对应关系全部写死在配置里,测试时从不会出现“第三个输出是哪个鸟”的疑问。
收尾的几句心里话
这个项目做完,我最大的感触是:卷积神经网络和各种预训练模型,本质上都是工具,工具选得顺手、用得有分寸,比单纯追求最尖端网络结构重要得多。VGG16虽然老了,但它的预训练权重在ImageNet上积累了非常稳健的自然图像特征,拿到小规模鸟类分类任务里做迁移学习,依然能打。如果你也在做相似的小规模分类项目,我建议你老老实实把数据清洗和预处理这两件“不打眼”的事情做好,再用迁移学习跑通VGG16,这套流程走一遍之后,你对卷积神经网络的理解会从“看过原理”变成“能动手调通”,后面的ResNet、EfficientNet甚至自定义网络,都只是在这个基础之上的延展而已。