ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VGG16+MLP猫狗分类实战:从数据清洗到部署的工程细节

VGG16+MLP猫狗分类实战:从数据清洗到部署的工程细节

1. 项目缘起与整体设计思路

猫狗图像分类这个题目,在深度学习圈子里算是“Hello World”级别的入门任务了。但越是经典的题目,越能看出一个从业者对模型结构、训练策略和工程细节的理解深度。我这次选用的方案是VGG16作为特征提取骨干网络,后接多层感知机(MLP)完成二分类,而不是直接端到端微调整个VGG16。这个选择背后有很实际的考量,下面展开聊。

1.1 为什么选VGG16而不是ResNet或EfficientNet

VGG16的结构极其规整——13个卷积层加3个全连接层,全部使用3×3卷积核和2×2最大池化,没有残差连接、没有分组卷积、没有深度可分离卷积这些“花活”。这种简洁性带来两个好处:一是特征图的可解释性强,你能很清楚地知道每一层在提取什么级别的特征;二是迁移学习的接口非常干净,直接砍掉最后的全连接层,拿到的就是512×7×7的特征向量,接什么下游结构都方便。

ResNet当然更强,但残差连接的存在让特征提取的“截断点”选择变得不那么直观——你从哪个stage切出来,特征感受野和语义层次的对应关系需要更多实验去验证。EfficientNet的复合缩放策略虽然高效,但它的特征维度随版本变化较大,对于教学和快速原型验证来说,反而不如VGG16来得直接。

注意:如果你的数据集规模超过5万张,或者对推理延迟有硬性要求,VGG16的参数量(约1.38亿)和计算量会成为瓶颈,这时候应该优先考虑轻量级骨干网络。

1.2 MLP分类头的设计逻辑

VGG16原生的分类头是三层全连接:4096→4096→1000。我把它替换成一个更轻量的MLP结构,具体是512→256→2,中间加ReLU激活和Dropout。为什么这么设计?

第一,输入维度从25088降到了512。VGG16的conv5_3输出是512×7×7,如果直接flatten就是25088维,接4096的全连接层参数量是25088×4096≈1亿,这还只是一层。而先用全局平均池化把512×7×7压成512维向量,参数量直接降到512×256≈13万,训练速度快了一个数量级。

第二,全局平均池化自带正则化效果。每个特征图取平均,相当于强制网络在通道维度上做信息聚合,减少了空间维度上的过拟合风险。对于猫狗分类这种类间差异明显、类内差异也大的任务,这个操作很划算。

第三,MLP的深度和宽度可控。512→256→2这个结构,隐藏层只有一层,参数量约13万+512,总参数量不到15万。在几千到几万张图的规模下,这个容量足够拟合决策边界,又不容易过拟合。

1.3 整体架构的数据流向

整个模型的数据流是这样的:输入224×224×3的RGB图像→VGG16卷积部分(13层)→输出512×7×7的特征图→全局平均池化→512维向量→全连接层(512→256)→ReLU→Dropout(0.5)→全连接层(256→2)→Softmax输出。

训练时,VGG16的卷积层冻结权重,只更新MLP部分的参数。这样做的好处是训练极其稳定,学习率可以设得比较大(比如1e-3),收敛快,而且不容易出现灾难性遗忘。代价是特征提取能力受限于预训练权重的质量,如果你的数据集和ImageNet分布差异很大(比如医学影像、遥感图像),冻结策略可能不是最优的。

2. 数据准备与预处理的关键细节

数据这块,猫狗分类的经典数据集是Kaggle上的Dogs vs Cats,包含25000张带标签的训练图(猫狗各12500张)。但实际项目中,你拿到的数据往往更脏、更少、更不均衡。我这次用的是一个模拟数据集,规模在8000张左右,猫狗比例大约是1:1.2,算是比较接近真实场景的分布。

2.1 数据清洗的实操要点

拿到原始图片后,第一件事不是急着写DataLoader,而是先做一轮人工抽检。我随机抽了200张图,发现几个典型问题:有十几张是损坏文件(打开报错),有二十多张是灰度图(单通道),还有几张是动图格式(GIF)被错误地当成静态图读取。

处理方案很直接:用PIL的Image.open()加verify()方法批量扫描,损坏文件直接剔除;灰度图用convert('RGB')强制转三通道;GIF只取第一帧。这些操作看起来琐碎,但如果不做,训练到一半突然报错,排查起来很浪费时间。

from PIL import Image import os def validate_images(root_dir): bad_files = [] for cls in ['cat', 'dog']: cls_dir = os.path.join(root_dir, cls) for fname in os.listdir(cls_dir): fpath = os.path.join(cls_dir, fname) try: img = Image.open(fpath) img.verify() img = Image.open(fpath) if img.mode != 'RGB': img = img.convert('RGB') img.save(fpath) except Exception as e: bad_files.append((fpath, str(e))) return bad_files

2.2 数据增强策略的选择

猫狗分类的数据增强,我见过有人上来就堆十几层变换,结果训练集准确率死活上不去。问题出在增强过度——猫的耳朵被旋转45度、狗的眼睛被颜色抖动到发绿,模型学到的特征和测试集分布严重不匹配。

我的策略是保守增强:随机水平翻转(p=0.5)、随机小角度旋转(±15度)、随机裁剪并缩放到224×224(scale=(0.8, 1.0))、颜色抖动只调亮度和对比度(各±0.2),饱和度和色调不动。这四类变换覆盖了拍摄角度、距离和光照的常见变化,又不会引入不合理的形变。

实操心得:验证集和测试集绝对不要做增强,只做Resize和CenterCrop。我见过有人把增强后的图也拿去验证,结果验证准确率比测试准确率低了5个点,白白调了一周参数。

2.3 数据加载器的性能优化

PyTorch的DataLoader有几个参数对训练速度影响极大:num_workers、pin_memory、prefetch_factor。在Linux环境下,num_workers设为CPU核心数的2-4倍比较合适,我用的机器是8核,设了16。pin_memory=True可以加速CPU到GPU的数据传输,尤其是当你的GPU显存足够大、batch size设得比较大时,效果明显。

还有一个容易被忽略的点:把图片预先resize到256×256存成numpy数组或LMDB格式,训练时直接读取,比每次从JPEG解码快3-5倍。如果数据集不大(比如1万张以内),内存足够的话,直接全部加载到内存里,训练速度能再提升一个档次。

3. 模型搭建与训练配置详解

模型搭建这部分,PyTorch的torchvision.models.vgg16已经提供了预训练权重,直接拿来用就行。关键是怎么改结构、怎么设训练参数。

3.1 VGG16特征提取部分的截取

import torch import torch.nn as nn from torchvision import models class VGG16MLP(nn.Module): def __init__(self, num_classes=2, dropout=0.5): super().__init__() vgg = models.vgg16(pretrained=True) self.features = vgg.features for param in self.features.parameters(): param.requires_grad = False self.gap = nn.AdaptiveAvgPool2d((1, 1)) self.classifier = nn.Sequential( nn.Linear(512, 256), nn.ReLU(inplace=True), nn.Dropout(dropout), nn.Linear(256, num_classes) ) def forward(self, x): x = self.features(x) x = self.gap(x) x = torch.flatten(x, 1) x = self.classifier(x) return x

这里有几个细节值得说。AdaptiveAvgPool2d((1,1))比直接x.mean(dim=[2,3])更规范,因为它在计算图中显式定义了池化操作,反向传播时梯度回传更清晰。torch.flatten(x, 1)从第1维开始展平,保留batch维度,这是标准写法。

3.2 训练参数的设定依据

优化器选Adam,学习率1e-3,权重衰减1e-4。为什么不用SGD?因为只训练MLP部分,参数量少,Adam的自适应学习率能更快找到好的解。如果后面要解冻卷积层做微调,再换成SGD+余弦退火更合适。

Batch size设64。这个值是在显存和梯度稳定性之间折中的结果。我试过128,显存占用多了但验证准确率没提升;试过32,训练波动明显变大。64在单卡2080Ti上刚好,显存占用约4.2GB。

损失函数用CrossEntropyLoss,标签平滑设0.1。标签平滑的作用是防止模型对训练样本过度自信,在猫狗这种二分类任务上,能把验证准确率提升0.5-1个点。实现上,PyTorch 1.10以上版本直接支持label_smoothing参数。

criterion = nn.CrossEntropyLoss(label_smoothing=0.1) optimizer = torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3, weight_decay=1e-4 ) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=20, eta_min=1e-5 )

学习率调度用余弦退火,周期设20个epoch。猫狗分类通常10-15个epoch就能收敛,设20是留点余量,让学习率在后期降到足够小,帮助模型进入更平坦的极小值区域。

3.3 训练循环中的监控指标

训练时除了loss和accuracy,我还会监控三个指标:学习率(确认调度器正常工作)、梯度范数(判断是否梯度爆炸或消失)、验证集上的F1分数(比准确率更能反映类别不均衡时的真实性能)。

梯度范数的计算很简单,在loss.backward()之后、optimizer.step()之前,遍历所有可训练参数的grad属性,算L2范数。如果发现梯度范数持续大于10,说明学习率可能偏大;如果小于1e-6,说明梯度消失,需要检查网络结构或初始化。

def compute_grad_norm(model): total_norm = 0.0 for p in model.parameters(): if p.grad is not None: total_norm += p.grad.data.norm(2).item() ** 2 return total_norm ** 0.5

4. 训练过程中的典型问题与排查实录

训练深度学习模型,十次有八次会遇到各种幺蛾子。我把这次项目中遇到的问题和排查过程整理出来,供你参考。

4.1 损失不下降的三种可能原因

第一种:学习率太大。表现是loss在第一个epoch就飙到nan或者剧烈震荡。解决办法是把学习率降到1e-4甚至1e-5试试。我这次设1e-3没问题,但如果你用的batch size更小(比如16),学习率要相应调小。

第二种:数据标签错位。表现是loss缓慢下降但准确率始终在50%左右徘徊。排查方法是打印一个batch的图片和标签,肉眼确认猫的图对应标签0、狗的图对应标签1。我这次就遇到了这个问题——DataLoader的class_to_idx映射和文件夹命名不一致,猫的文件夹叫"cats"但映射到了索引1,导致标签全反了。改过来之后,第一个epoch准确率就上了80%。

第三种:BatchNorm的running stats没更新。VGG16的卷积层里有BatchNorm,虽然权重冻结了,但track_running_stats默认是True,前向传播时会更新running mean和var。如果训练集和预训练数据的分布差异大,这些统计量会漂移,影响特征质量。解决办法是在冻结卷积层的同时,把BatchNorm层设为eval()模式。

def freeze_bn(model): for module in model.modules(): if isinstance(module, nn.BatchNorm2d): module.eval()

4.2 过拟合的识别与应对

过拟合的典型信号是:训练准确率持续上升,验证准确率在某个epoch后开始下降,两者差距越来越大。我这次在第8个epoch观察到了这个趋势,训练准确率92%,验证准确率85%,差了7个点。

应对措施有三个,按优先级排序:第一,增加Dropout。我把分类头的Dropout从0.3提到了0.5,验证准确率回升了2个点。第二,加权重衰减。从1e-4提到5e-4,效果不明显,说明过拟合主要来自分类头容量过大。第三,早停。设patience=5,验证loss连续5个epoch不下降就停。最终模型在第14个epoch停止,验证准确率87.3%。

避坑技巧:不要一看到过拟合就加数据增强。增强过度会改变数据分布,反而伤害性能。先调Dropout和权重衰减,不行再考虑增强。

4.3 类别不均衡的处理

我的数据集猫狗比例1:1.2,不算严重,但验证集上猫的召回率比狗低了3个点。处理方法是在损失函数里给猫的样本更高权重。具体权重是1/class_count归一化后乘以类别数,猫的权重约1.09,狗的约0.91。

class_counts = [4000, 4800] # 猫4000,狗4800 weights = [sum(class_counts) / (2 * c) for c in class_counts] class_weights = torch.FloatTensor(weights).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights, label_smoothing=0.1)

加上类别权重后,猫的召回率提升了2.5个点,整体准确率基本不变。如果类别差距更大(比如1:10),可以考虑用Focal Loss或者对少数类做过采样。

4.4 常见问题速查表

问题现象可能原因排查方法解决方案
loss为nan学习率过大打印每层梯度范数降低学习率,加梯度裁剪
准确率卡在50%标签错位可视化一个batch的图和标签检查class_to_idx映射
验证loss震荡batch size太小对比不同batch size的loss曲线增大batch size或调小学习率
训练速度慢数据加载瓶颈用time.time()测DataLoader耗时增加num_workers,预缓存数据
显存溢出batch size过大用torch.cuda.memory_summary()减小batch size或梯度累积
验证准确率远低于训练过拟合对比训练和验证的loss曲线加Dropout,早停,权重衰减

5. 模型评估与推理部署的实操细节

训练完模型,评估和部署是最后一步,也是最容易出问题的一步。我见过太多人训练时准确率90%+,部署上线后实际效果打七折。

5.1 评估指标的选择与计算

准确率在类别均衡时够用,但不均衡时容易误导。我同时计算了精确率、召回率、F1分数和AUC。猫狗分类的AUC通常能到0.95以上,如果低于0.9,说明模型的特征提取能力有问题,需要检查VGG16的预训练权重是否加载成功。

from sklearn.metrics import classification_report, roc_auc_score def evaluate(model, dataloader, device): model.eval() all_preds, all_labels, all_probs = [], [], [] with torch.no_grad(): for images, labels in dataloader: images = images.to(device) outputs = model(images) probs = torch.softmax(outputs, dim=1) preds = torch.argmax(outputs, dim=1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) all_probs.extend(probs[:, 1].cpu().numpy()) print(classification_report(all_labels, all_preds, target_names=['cat', 'dog'])) print(f"AUC: {roc_auc_score(all_labels, all_probs):.4f}")

5.2 推理速度的优化

VGG16的推理速度在GPU上约15ms/张(224×224输入),CPU上约120ms/张。如果要做实时推理,有几个优化方向:第一,用TorchScript导出模型,去掉Python解释器开销,速度提升约20%。第二,用ONNX Runtime,在CPU上能提速2-3倍。第三,量化,把FP32转成INT8,速度提升3-4倍,精度损失通常在1个点以内。

# TorchScript导出 model.eval() example_input = torch.randn(1, 3, 224, 224).to(device) traced_model = torch.jit.trace(model, example_input) traced_model.save("vgg16_mlp_traced.pt") # ONNX导出 torch.onnx.export( model, example_input, "vgg16_mlp.onnx", input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch_size'}, 'output': {0: 'batch_size'}} )

注意:导出ONNX时,如果模型里有AdaptiveAvgPool2d,需要确认ONNX的opset版本支持。opset 11以上没问题,低于11会报错。

5.3 部署时的预处理一致性

这是最容易踩的坑:训练时用的归一化参数是ImageNet的mean=[0.485, 0.456, 0.406]、std=[0.229, 0.224, 0.225],部署时忘了加,或者用了不同的Resize方式(训练用CenterCrop,部署用直接Resize),导致推理结果和验证结果对不上。

我的做法是把预处理逻辑封装成一个独立的类,训练和推理共用同一份代码。

class Preprocessor: def __init__(self, size=224): self.transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(size), transforms.ToTensor(), transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ) ]) def __call__(self, img): return self.transform(img)

部署时直接from preprocess import Preprocessor,保证线上线下一致。这个习惯帮我省了至少三次线上事故的排查时间。

6. 项目扩展与性能提升的可行方向

这个项目虽然简单,但扩展空间很大。如果你已经跑通了基础版本,下面几个方向可以继续深挖。

6.1 解冻部分卷积层做微调

冻结全部卷积层时,模型只能利用ImageNet的通用特征。如果你的数据集和ImageNet差异较大,解冻最后几个卷积块(比如conv5)做微调,通常能提升3-5个点的准确率。具体做法是:先冻结全部卷积层训练MLP到收敛,然后解冻conv5,用更小的学习率(1e-4)继续训练5-10个epoch。

# 解冻conv5 for name, param in model.features.named_parameters(): if 'conv5' in name or 'features.24' in name or 'features.26' in name: param.requires_grad = True # 用更小的学习率 optimizer = torch.optim.Adam( filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4, weight_decay=1e-4 )

6.2 用特征缓存加速实验迭代

如果只训练MLP部分,VGG16的卷积计算是重复的——每个epoch都要重新算一遍所有图片的特征。优化方法是预先计算所有图片的512维特征向量并缓存到磁盘,训练时直接读特征,速度能提升10倍以上。

def extract_features(model, dataloader, device): model.eval() features, labels_list = [], [] with torch.no_grad(): for images, labels in dataloader: images = images.to(device) feat = model.features(images) feat = model.gap(feat) feat = torch.flatten(feat, 1) features.append(feat.cpu()) labels_list.append(labels) return torch.cat(features), torch.cat(labels_list)

缓存后,训练MLP一个epoch只需要几秒钟,调参效率极高。这个技巧在特征维度不高、数据集规模中等(1-10万张)时特别有用。

6.3 多模型集成提升鲁棒性

单模型的准确率有上限,集成能稳定提升1-3个点。最简单的做法是训练3-5个不同初始化的MLP,推理时取平均概率。再进一步,可以用不同的骨干网络(VGG16、ResNet50、EfficientNet-B0)提取特征,拼接后训练一个更大的MLP。

集成带来的收益在验证集上可能不明显,但在测试集和实际场景中,鲁棒性提升是实打实的。我做过对比实验,单模型在测试集上87.3%,三模型集成后89.1%,而且对困难样本(比如遮挡、低光照)的识别率提升更明显。

6.4 用Grad-CAM做模型可解释性分析

Grad-CAM能可视化模型关注图片的哪些区域。对于猫狗分类,如果模型关注的是背景(比如草地、沙发)而不是动物本身,说明特征提取有问题,需要检查数据或调整训练策略。

from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image target_layers = [model.features[-1]] cam = GradCAM(model=model, target_layers=target_layers) grayscale_cam = cam(input_tensor=image_tensor) visualization = show_cam_on_image(rgb_img, grayscale_cam[0], use_rgb=True)

我跑了几张验证集的图,发现模型确实主要关注猫狗的头部和躯干,背景区域的激活值很低,说明特征提取是合理的。如果发现模型盯着背景看,大概率是数据集有偏——比如猫的图片大多在室内、狗的图片大多在室外,模型学会了用背景区分而不是用动物本身。

7. 一些零散但重要的经验

最后分享几个我在这个项目中踩过的坑和总结的小技巧,不一定系统,但都挺实用。

关于随机种子:PyTorch的随机种子要设全——torch.manual_seed、torch.cuda.manual_seed_all、np.random.seed、random.seed,还有torch.backends.cudnn.deterministic = True。少设一个,两次训练的结果就可能差1-2个点。但注意,deterministic=True会降低训练速度,正式训练时可以关掉,做对比实验时再打开。

关于模型保存:不要只保存state_dict,把优化器状态、epoch数、最佳验证准确率一起存。这样断点续训时不用重新调学习率调度器。

torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'scheduler_state_dict': scheduler.state_dict(), 'best_acc': best_acc, }, 'checkpoint.pth')

关于日志记录:用TensorBoard或WandB记录loss、accuracy、学习率、梯度范数。训练出问题时,回看日志比凭记忆靠谱得多。我习惯每50个batch记录一次训练指标,每个epoch记录一次验证指标。

关于硬件选择:这个项目在GTX 1660 6GB上就能跑,batch size设32,训练一个epoch约2分钟。如果只有CPU,建议把图片resize到128×128,VGG16的输入尺寸改小,训练时间能缩短到可接受的范围。但注意,改输入尺寸后,预训练权重的空间维度会不匹配,需要重新训练或者用自适应池化兼容。

关于代码组织:把数据、模型、训练、评估拆成独立的模块,用argparse管理超参数。这样换数据集或调参时不用改代码,只改命令行参数就行。我见过太多人把所有代码写在一个Jupyter Notebook里,调参时改来改去,最后自己都忘了哪个版本对应哪个结果。

这个项目从头到尾跑下来,最大的体会是:简单的方法做到极致,比复杂的方法做到一半要强得多。VGG16+MLP这个组合,结构简单、训练快、可解释性强,在猫狗分类这种任务上,认真调参后能达到88-90%的准确率,完全够用。与其花时间追新模型,不如把数据清洗、增强策略、训练监控这些基础环节做扎实。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进