1. 从零构建猫狗分类器:为什么选 VGG16 + MLP 这套组合
猫狗图像分类算是计算机视觉里最经典的入门项目之一,几乎每个刚接触深度学习的人都会拿它练手。但真正动手做过的人都知道,这个任务看着简单,坑却不少:数据集里猫狗姿态千奇百怪、光照背景差异巨大、模型要么过拟合要么欠拟合。我前后用不同方案跑过好几轮,最后发现VGG16 做特征提取 + MLP 做分类头这套组合,在效果、训练速度和实现难度之间取得了相当好的平衡,特别适合想快速拿到一个能跑通、精度还不错的结果的朋友。
先说说这个方案到底在干什么。VGG16 是一个在 ImageNet 上预训练好的卷积神经网络,它的卷积层已经学会了提取各种层次的视觉特征——浅层抓边缘、纹理,深层抓语义部件。我们不需要重新训练这些卷积层,而是把它当成一个固定的“特征提取器”,把每张猫狗图片转换成一个高维特征向量。然后在这个向量之上,接一个多层感知机(MLP)来做二分类。这样做的好处很直接:训练量大幅减少,因为卷积部分不用更新参数;同时又能利用 ImageNet 上学到的通用视觉知识,泛化能力比从头训练一个小 CNN 强得多。
那为什么不用 VGG16 直接微调全连接层?其实也可以,但 VGG16 原本的全连接层参数量巨大(第一个全连接层就有上亿参数),在小数据集上极容易过拟合,而且训练慢。换成轻量 MLP 之后,参数量降到可控范围,训练几个 epoch 就能收敛,对算力要求也友好很多。这套思路本质上属于迁移学习中的“特征提取”范式,是工业界和竞赛中非常常用的起手式。
这篇文章适合谁看?如果你已经了解 Python 和 PyTorch 基础,知道什么是张量、什么是全连接层,但还没完整走过一个图像分类项目,那这篇就是写给你的。我会把数据准备、模型搭建、训练调参、问题排查整个流程拆开讲,每个关键选择都说明为什么这么做,参数怎么定,坑在哪里。你也可以直接照着代码抄作业,跑出一个准确率 90% 以上的猫狗分类器。
2. 核心思路拆解:迁移学习为什么能省下大量算力
2.1 特征提取与微调的本质区别
很多人一上来就纠结:到底该冻结卷积层还是微调?这里得把两种范式讲清楚。特征提取是把预训练网络当作一个固定的特征变换函数,卷积层的权重全程不变,只训练后面新加的分类器。微调则是解冻部分或全部卷积层,让它们在新的数据上继续更新。两者的核心差异在于:特征提取假设 ImageNet 学到的特征足够通用,直接拿来用就行;微调则假设目标域和源域有差异,需要让网络适应新数据。
对于猫狗分类这个任务,ImageNet 里本来就有大量的猫和狗类别,所以预训练特征已经非常贴合,特征提取完全够用。我实测过,冻结卷积层只训练 MLP,验证集准确率能到 92% 左右;如果解冻最后几个卷积块做微调,大概能再涨 1 到 2 个百分点,但训练时间翻倍,还容易过拟合。所以除非你的数据域和 ImageNet 差得特别远(比如医学影像、卫星图),否则特征提取是性价比最高的选择。
2.2 VGG16 的结构特点与特征维度
VGG16 的结构非常规整:13 个卷积层加 3 个全连接层,卷积部分全部用 3x3 小卷积核,池化统一用 2x2 最大池化。这种“堆叠小卷积核”的设计是它的精髓——两个 3x3 卷积的感受野等于一个 5x5,但参数更少、非线性更强。它的卷积部分可以分成 5 个 block,每个 block 后面跟一次池化,特征图尺寸依次减半:224 → 112 → 56 → 28 → 14 → 7。
当我们把 VGG16 的卷积部分拿出来做特征提取时,输入一张 224x224x3 的图片,经过最后一个池化层后得到的是 7x7x512 的特征图。展平之后就是 25088 维的向量。这个维度相当高,直接接分类器参数量会很大,所以通常会在前面加一个降维层。我一般习惯先降到 512 或 256 维,再接输出层,这样既保留主要信息,又能控制参数量。
2.3 为什么用 MLP 而不是直接线性分类
你可能会问,既然只是二分类,为什么不直接用一个线性层?原因在于,VGG16 提取的 25088 维特征里,各个维度之间并不是线性可分的。猫和狗在高层语义特征上的差异,往往需要非线性组合才能有效区分。MLP 通过隐藏层和激活函数引入非线性,能把特征映射到更容易分类的空间。我做过对比实验:同样用 VGG16 特征,直接线性分类验证准确率大概 88%,加一层 512 维隐藏层(配 ReLU)能到 92%,再加一层效果提升就不明显了。所以一层隐藏层的 MLP 是性价比最高的配置。
另外,隐藏层后面一定要加 Dropout。25088 维输入接 512 维隐藏层,参数量是 1280 多万,不加正则化几乎必然过拟合。Dropout 率设 0.5 是经典选择,实测下来训练和验证曲线最稳。
3. 数据准备与预处理:决定上限的关键一步
3.1 数据集划分与目录结构
猫狗分类最常用的公开数据集包含 25000 张图片,猫狗各半。我建议按 8:1:1 划分训练集、验证集、测试集,也就是训练 20000 张、验证 2500 张、测试 2500 张。划分时一定要保证随机性,并且固定随机种子,否则每次跑结果都不一样,没法对比调参效果。
目录结构我习惯这样组织,用 PyTorch 的 ImageFolder 直接就能读:
data/ train/ cat/ dog/ val/ cat/ dog/ test/ cat/ dog/这种按类别分文件夹的结构,ImageFolder 会自动给每个类别分配标签,省去手写 Dataset 的麻烦。有一点要注意:划分时最好用软链接或者复制文件,别移动原文件,方便后续换划分比例重新实验。
3.2 图像预处理与数据增强策略
VGG16 要求输入 224x224 的 RGB 图片,而且因为是在 ImageNet 上预训练的,预处理必须和当年训练时一致:先缩放到 256,再中心裁剪 224,然后转张量,最后用 ImageNet 的均值和标准差做归一化。均值是 [0.485, 0.456, 0.406],标准差是 [0.229, 0.224, 0.225]。这三个数字别自己乱改,用错了归一化,预训练特征就废了一半。
训练集必须做数据增强,这是小数据集上防过拟合最有效的手段。我常用的增强组合是:随机水平翻转、随机旋转 10 度以内、随机裁剪缩放、颜色抖动(亮度、对比度、饱和度各 0.2)。验证集和测试集只做确定性的缩放和中心裁剪,不做随机增强,保证评估结果可复现。
注意:颜色抖动幅度别开太大,猫狗的颜色是重要判别特征,抖过头反而伤精度。我试过饱和度开到 0.5,验证准确率掉了快 2 个点。
3.3 批大小与数据加载的实操细节
批大小我一般设 32 或 64。设太小(比如 8)梯度噪声大,训练不稳;设太大(比如 256)显存吃紧,而且小数据集上泛化可能变差。32 是个稳妥的起点。DataLoader 的 num_workers 设成 CPU 核心数的一半左右比较合适,设太高反而因为进程调度开销拖慢速度。pin_memory 设为 True 能加速 GPU 传输,这个在小图片上提升不明显,但大 batch 时有用。
还有一个容易忽略的点:shuffle 只在训练集开,验证和测试集千万别开,否则评估指标会波动。另外如果数据集里图片尺寸不一,预处理里的 Resize 会统一处理,但极端长宽比的图片可能变形,必要时可以先做中心裁剪再缩放。
4. 模型搭建:VGG16 特征提取器 + MLP 分类头
4.1 加载预训练 VGG16 并冻结卷积层
用 PyTorch 的 torchvision 加载 VGG16 非常方便。关键操作是把 features 部分(卷积层)单独拿出来,并把所有参数的 requires_grad 设为 False,这样反向传播时就不会更新它们,省下大量计算和显存。
import torch import torch.nn as nn from torchvision import models vgg = models.vgg16(pretrained=True) features = vgg.features for param in features.parameters(): param.requires_grad = False这里有个细节:VGG16 的 features 部分最后是一个 MaxPool 层,输出 7x7x512。我们不需要 avgpool 和 classifier,直接用 features 就行。加载预训练权重时,第一次运行会下载权重文件,确保网络通畅,或者提前把权重放到缓存目录。
4.2 构建 MLP 分类头的参数计算
分类头的结构我推荐这样:先展平 25088 维,接一个 512 维全连接层,配 ReLU 和 Dropout(0.5),最后接 2 维输出。参数量计算一下:25088 × 512 + 512 = 1284 万左右,加上输出层 512 × 2 + 2 = 1026。总共约 1285 万参数,相比 VGG16 卷积部分的 1400 多万,量级相当,但训练时只更新这部分,速度快很多。
class VGG16MLP(nn.Module): def __init__(self, num_classes=2): super().__init__() vgg = models.vgg16(pretrained=True) self.features = vgg.features for p in self.features.parameters(): p.requires_grad = False self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(512 * 7 * 7, 512), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(512, num_classes) ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x如果你想进一步降参数量,可以把隐藏层降到 256 维,参数量减半,精度大概掉 0.5 个点。我一般用 512,因为显存够用,精度优先。
4.3 损失函数与优化器选择
二分类用 CrossEntropyLoss 就行,它内部已经包含了 Softmax,输出层不用再加激活。优化器我强烈推荐 Adam,学习率设 1e-3。为什么不用 SGD?因为这里只训练 MLP,参数量不大,Adam 的自适应学习率收敛更快,通常 10 个 epoch 内就能稳定。SGD 需要仔细调学习率和动量,起步慢,对新手不友好。
学习率调度可以用 StepLR,每 5 个 epoch 乘 0.1,或者用 CosineAnnealingLR 平滑下降。我实测 StepLR 更稳,Cosine 在后期可能降得太快导致欠拟合。权重衰减设 1e-4 能进一步抑制过拟合,但别设太大,否则 MLP 学不动。
5. 训练流程与关键环节实现
5.1 完整训练循环的代码骨架
训练循环看着简单,但细节决定成败。下面是我常用的骨架,包含训练和验证两个阶段:
def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total = 0, 0, 0 for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(imgs) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * imgs.size(0) correct += (outputs.argmax(1) == labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total验证阶段要把 model.eval() 打开,并且用 torch.no_grad() 包住,否则 Dropout 和 BatchNorm 行为不一致,评估结果会偏低。这一点新手特别容易忘,我见过不少人验证准确率死活上不去,最后发现是忘了切 eval 模式。
5.2 训练轮数与早停策略
因为只训练 MLP,收敛很快。我一般设 20 个 epoch,配合早停:如果验证损失连续 3 个 epoch 不下降,就停。实测下来,大多数情况 8 到 12 个 epoch 就到最优了。训练太久反而过拟合,验证准确率会往下掉。
判断过拟合的信号很明确:训练损失持续下降,验证损失开始上升,两者差距越来越大。这时候要么早停,要么加大 Dropout,要么加数据增强。我一般先看数据增强够不够,因为增强是免费的,不增加推理成本。
5.3 学习率与批大小的联动调整
学习率和批大小是有联动关系的。批大小翻倍,学习率通常也可以适当放大,因为梯度估计更准。但这里批大小固定 32,学习率 1e-3 是经过验证的稳妥值。如果你显存大想用 64 的批大小,学习率可以试 1.5e-3 或 2e-3,但别直接翻倍,容易震荡。
还有一个技巧:warmup。前 2 个 epoch 用很小的学习率(比如 1e-4)预热,再升到 1e-3,能让训练更稳。不过对于这个任务,warmup 提升有限,属于锦上添花,不是必须。
6. 常见问题与排查技巧实录
6.1 准确率卡在 50% 不动怎么办
这是最典型的问题,基本可以锁定几个原因。第一,标签和图片没对上,ImageFolder 按文件夹名排序分配标签,如果文件夹名是 cat 和 dog,标签就是 0 和 1,但如果你自己写 Dataset 搞错了映射,模型就学反了。第二,归一化参数用错,比如用了 [0.5, 0.5, 0.5] 而不是 ImageNet 的均值方差,特征分布偏移,预训练权重失效。第三,学习率太大导致 loss 爆炸,检查一下 loss 是不是 NaN。
排查顺序我建议:先打印一个 batch 的图片和标签,肉眼确认对应关系;再检查预处理参数;最后把学习率降到 1e-4 试跑两个 epoch,看 loss 是否下降。
6.2 验证准确率高但测试准确率低
这说明验证集和测试集分布不一致,或者验证集被“偷看”了。常见原因是划分数据时没打乱,比如按文件名顺序切,导致验证集全是某种背景的图片。解决办法是划分前先整体 shuffle,固定种子。另外,如果验证集做了数据增强(不该做),评估会虚高,测试时露馅。
还有一种可能是测试集里有训练集没见过的猫狗品种,泛化不够。这时候可以解冻 VGG16 最后一个卷积 block 做微调,让特征更贴合你的数据。
6.3 显存不够用的降级方案
VGG16 特征提取对显存要求不算高,但如果你显卡比较小,可以这样降级:批大小降到 16 或 8;把输入尺寸从 224 降到 160 或 128(注意要相应调整全连接层输入维度);或者用半精度训练(amp)。我试过 128 输入,准确率掉 3 个点左右,但显存占用减半,适合入门级显卡。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 准确率卡 50% | 标签错位、归一化错误 | 打印 batch 检查 | 修正映射和预处理 |
| 验证高测试低 | 数据划分不均、验证集增强 | 检查划分和 transform | 重新划分、去掉验证增强 |
| 训练 loss 不降 | 学习率过大、梯度消失 | 看 loss 曲线 | 降学习率、加 warmup |
| 过拟合严重 | 数据少、正则不足 | 对比训练验证曲线 | 加增强、加 Dropout |
| 显存溢出 | 批大小过大、输入过大 | 看报错信息 | 降批大小或输入尺寸 |
实操心得:每次改完一个超参数,只改一个,其他不动,跑完记录结果。同时改多个参数,你根本不知道是哪个起了作用。我习惯用表格记录每次实验的配置和结果,调参效率高很多。
7. 效果评估与后续优化方向
7.1 评估指标不只看准确率
准确率在类别平衡时够用,但如果你想更全面,可以看混淆矩阵、精确率、召回率、F1。猫狗分类里,如果模型偏向预测某一类,准确率可能还行,但召回率会暴露问题。我一般会画混淆矩阵,看看有没有系统性偏差。另外,ROC 曲线和 AUC 也值得一看,能反映模型在不同阈值下的表现。
7.2 进一步提升精度的几个方向
如果 92% 的准确率还不满足,可以尝试:解冻 VGG16 最后一个卷积 block 做微调,学习率设小一点(1e-4);换更强的骨干网络,比如 ResNet50 或 EfficientNet,特征质量更高;用 K 折交叉验证,把多折模型集成,通常能涨 1 到 2 个点;测试时增强(TTA),对每张测试图做多次增强预测再平均,也能小幅提升。
不过要提醒一句,精度提升和工程复杂度是成正比的。如果只是练手或者做个 demo,92% 已经足够说明问题。把精力花在理解流程和排查问题上,比盲目刷点更有价值。
7.3 模型保存与推理部署要点
训练完别忘了保存模型,推荐保存 state_dict 而不是整个模型,加载时更灵活。推理时记得切 eval 模式,并且用和训练一致的预处理。如果部署到线上,可以把 VGG16 特征提取和 MLP 分开,特征提取部分可以预计算并缓存,因为卷积层是冻结的,同一张图特征永远一样。这样推理时只跑 MLP,速度极快。
我实际部署过这套方案,把 25000 张图的特征提前算好存成向量,训练 MLP 只用几秒,推理单张图也就毫秒级。这个思路在数据量大、模型固定的场景下特别实用,值得记下来。