用PyTorch实现猫狗图像分类:从数据加载到模型训练避坑指南
记得我第一次跑猫狗二分类的时候训练到一半 loss 突然冲到 9 还怎么都降不下来后来发现是标签和图片顺序对不上。这种看起来很小的问题恰恰是这类入门项目最容易劝退人的地方。用 Pytorch 实现猫狗二分类听起来就是一个标准得不能再标准的练习但你真正完整跑一遍会一连串接触数据加载、图像预处理、卷积网络设计、训练调参、模型保存和推理评估每一步都有隐藏细节。这篇文章我想用自己的实操经验把这套流程从头到尾拆开讲明白重点说清楚每一步为什么这么做、参数怎么定、出了问题去哪查。适合刚学完 Pytorch 基础、打算完整跑一个图像分类项目的同学也适合课程设计或者快速 Demo 需要短时间交付的开发者。我会把可以直接照着改的代码贴出来也把容易踩的坑单独整理成一章。1. 动手前的整体设计与方案选型为什么选了这条路1.1 为什么用 Pytorch 而不是顺手拿别的框架做图像分类现在可选的路很多有高层的训练平台有封装比较狠的库甚至有些工具能让你不写模型就出结果。但如果你想真正理解分类模型是怎么工作的Pytorch 依然是目前最合适的选择之一。我自己的使用体会有三点。第一Pytorch 是动态图机制模型在前向传播的时候可以随时打印中间张量的 shape也可以在 loss 不收敛的时候用断点检查每一层的输出。这种“边跑边看”的能力对新手调试来说太重要了。第二torchvision 自带了常用的数据集读取工具、图像预处理函数和经典网络结构写猫狗二分类根本不需要从零造轮子但又能让你看到轮子里面是什么。第三Pytorch 的 Dataset 和 DataLoader 抽象非常接近工业界训练脚本的标准写法你在这个小项目里养成的习惯后面做大项目依然用得上。也有人问我为什么不直接用封装更简单的框架。我的看法是封装程度越高出问题时你能干预的空间就越小。猫狗二分类是一个练手项目练的就是“自己掌控训练流程”的能力所以用 Pytorch 这种偏底层的框架反而更合适。1.2 数据集怎么组织目录结构决定后续复杂度很多公开的猫狗图片数据集下载下来是一个压缩包解压后可能是两个大文件夹比如 cat 和 dog也可能是一个文件夹里所有图片混在一起文件名里带猫狗的标记。这一步如果没有提前整理好后面写数据加载代码会非常痛苦。我强烈建议动手写代码之前先把数据目录整理成标准结构data/ train/ cat/ dog/ val/ cat/ dog/这样做的核心原因是torchvision 里的 ImageFolder 可以直接读取这种目录结构并且自动根据文件夹名字生成标签。你不用自己维护一份“文件名到标签”的映射表也不用手动写图片路径列表。ImageFolder 在处理的时候会把子文件夹按字母排序cat 对应标签 0dog 对应标签 1这个映射关系保存在 dataset.class_to_idx 里后面做推理脚本的时候需要用到。另外不要偷懒只分一个训练集。哪怕是几百张图片的练手项目也应该划出一部分作为验证集。我常用的做法是先把全部图片放到一个临时目录然后写一个简单脚本按 8:2 比例随机抽出来移动到 data/train 和 data/val 下面。移动的时候注意用随机数不要直接用 os.listdir 的顺序切否则很可能前一半全是猫或者全是狗验证集就失去意义了。1.3 模型选型从零训练还是用预训练网络选模型是第二件容易纠结的事。很多人一上来就想用一个很深的网络或者直接加载预训练模型这其实不太适合第一次完整跑通流程。我建议把路线分成两步。第一步从零搭建一个小型卷积神经网络结构自己写训练流程自己控制目的是把整个链路跑通深刻理解每一层的作用。第二步把模型换成经典的预训练网络在同样数据上做迁移学习对比一下精度差距。你会发现预训练模型在同样 epoch 数下准确率明显更高而且训练速度也不慢因为大部分层不需要更新太多。这样对比一次你对“特征提取”这件事就有了非常直观的认识。具体到这个项目数据量如果只有几千张从零训练的小 CNN 验证集准确率大概能到 80% 上下而用预训练网络微调轻松能超过 90%。所以如果你的目标不是练手而是想拿到一个好看的准确率直接从预训练模型开始会更划算。但背后的原理我还是建议先自己写一遍小网络再去体会。2. 核心代码实现数据加载、网络结构与训练循环2.1 数据增强哪些操作必须加哪些是锦上添花数据增强是提升图像分类模型泛化能力最有效的手段之一。猫狗二分类这类任务图片往往来自不同场景有旋转、裁剪、亮度差异如果不做增强模型很容易过拟合。我常用的训练集预处理是这样写的from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])先说 Resize 和 RandomCrop。把图片先放大到 256x256再随机裁剪出 224x224这样每次训练看到的图片位置都不一样相当于给模型提供了更多样化的样本。验证集用的是 CenterCrop固定裁剪中间区域保证评估结果稳定。RandomHorizontalFlip 是猫狗分类里非常自然的增强方式左右翻转不会改变猫狗身份但能让模型对朝向更鲁棒。ColorJitter 是对亮度、对比度、饱和度做随机扰动属于锦上添花的操作如果数据量很小加上它能缓解过拟合。最后 Normalize 的 mean 和 std 是很多开源项目通用的统计值把像素值从 0-1 范围转成近似标准正态分布有利于模型收敛。这里有一个新手容易犯的错误验证集不要加 RandomHorizontalFlip 和 ColorJitter更不要用 RandomCrop。验证集的作用是稳定评估模型表现加了随机操作会导致每次评估的准确率都不一样你很难判断模型到底变好了还是变坏了。2.2 Dataset 与 DataLoader最容易写错的两个细节数据目录整理好之后读取代码很简单from torchvision import datasets train_dataset datasets.ImageFolder(rootdata/train, transformtrain_transform) val_dataset datasets.ImageFolder(rootdata/val, transformval_transform) print(train_dataset.class_to_idx) # 输出类似 {cat: 0, dog: 1}这个 class_to_idx 一定要打印出来看一眼因为它决定了你的模型输出 0 代表猫还是狗。后面写推理脚本的时候你会需要这个映射关系否则模型预测出 0你都不知道该显示“猫”还是“狗”。DataLoader 的参数也值得逐个解释from torch.utils.data import DataLoader train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue)batch_size 我一般先用 32如果你的显存不够改成 16 或者 8 都行。shuffleTrue 是训练集必须的每个 epoch 打乱样本顺序防止模型学到数据里的顺序规律。验证集不需要 shuffle因为评估结果不受顺序影响而且 shuffle 会让标签和图片的对应关系更难核对。num_workers 是数据加载时使用的子进程数。Windows 上如果设超过 0 可能遇到多进程报错一个省事的办法是设成 0让主进程直接加载虽然慢一点但稳定。Linux 上可以设成 4 或者 8。pin_memoryTrue 在 GPU 训练时能提升数据传输效率这个参数加上没坏处。如果你需要自己写 Dataset 类我只提醒一件事getitem里返回的图片和标签必须是一一对应的千万不要一个来自 list A、一个来自 list B后面各自 shuffle 了导致错位。我最初就是因为手动维护了两个列表shuffle 的时候只打乱了图片顺序标签顺序没跟着变结果 loss 直接冲到 9.0 不回来。2.3 网络结构一个小而完整的 CNN 设计从零训练用的网络不用太复杂我建议用一个三层卷积的小网络。下面这个结构是我自己改过多次比较顺手的版本import torch.nn as nn class CatDogCNN(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 16, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 28 * 28, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, 2), ) def forward(self, x): return self.classifier(self.features(x))这个网络的设计逻辑很简单。输入是一张 3 通道的 224x224 图片每经过一次 MaxPool2d宽高减半所以经过三次池化后特征图变成了 28x28。通道数从 16 逐步增加到 64相当于在空间尺寸缩小的同时增加特征表达的丰富度。kernel size 选 3x3 配合 padding1是现在最常用的组合计算量小效果稳定。Dropout(0.5) 是防止过拟合的一个关键设计。它会在训练时随机丢弃一半的神经元让模型不能过分依赖某几个特征。要注意的是Dropout 只在训练时生效评估时自动关闭这就是为什么后面说的 model.eval() 那么重要。最后一层 Linear(256, 2) 输出两个值对应猫和狗两个类别。这里不要在最后一层手动加 Softmax因为后面要用的 CrossEntropyLoss 内部已经包含了 Softmax你再加一层反而会让梯度传播出问题。2.4 训练循环把标准流程写对比追求花活重要训练循环是整篇文章从零训练环节里最核心的部分。我不建议一开始就堆各种高级技巧先把标准流程写对后面再慢慢加调度器、混合精度这些优化。一个完整的训练循环长这样import torch import torch.nn as nn device torch.device(cuda if torch.cuda.is_available() else cpu) model CatDogCNN().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(15): model.train() running_loss 0.0 correct 0 total 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() epoch_loss running_loss / total epoch_acc correct / total print(fepoch {epoch1}: loss{epoch_loss:.4f}, acc{epoch_acc:.4f}) # 验证集评估 model.eval() val_correct 0 val_total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) val_total labels.size(0) val_correct (predicted labels).sum().item() val_acc val_correct / val_total print(fval acc: {val_acc:.4f})这里每一行都值得认真看。optimizer.zero_grad() 必须放在 loss.backward() 之前因为 Pytorch 的梯度是累积的不清零的话每个 batch 的梯度会叠加loss 就会呈现出奇怪的波动。model.train() 和 model.eval() 切换一定要做尤其是模型里有 Dropout 或者 BatchNorm 的时候忘了切会导致训练和评估结果对不上。验证集阶段用 torch.no_grad() 包裹可以关闭梯度计算省显存也加快速度。训练完模型保存权重时我习惯保存整个 checkpoint 文件包括模型参数、优化器状态、当前 epoch 和最佳验证准确率torch.save({ model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), epoch: epoch, val_acc: val_acc, }, checkpoint.pth)这样之后做推理或者断点续训都很方便。3. 训练效果怎么看准确率、过拟合与调参记录3.1 一组实际的训练过程记录下面这组数据是我用上面这个网络跑一个小数据集时的记录一个 epoch 大概几十秒15 个 epoch 就能跑完epochtrain_losstrain_accval_acc10.68157.2%53.1%30.52073.4%68.5%50.41282.6%76.8%80.29388.1%80.3%100.23191.5%79.6%120.18693.2%78.9%看这张表能发现一个典型趋势训练集的准确率一路涨loss 一路降但验证集准确率在 epoch 8 左右达到峰值之后开始小幅回落。这就是过拟合的信号。模型已经开始“背”训练集里的某些图片细节而不是学习普适的猫狗特征。很多人在这一步会继续猛跑训练期待验证集准确率能再涨上去实际上基本不会。正确的做法是在验证集准确率开始下滑或者不再上升时停止训练保留之前验证集表现最好的那一份权重。3.2 过拟合的信号与处理方法过拟合的判断其实非常简单训练集准确率明显高于验证集准确率并且两者差距还在拉大。比如训练集已经 93% 了验证集只有 78%中间差出的 15 个点就代表模型记住了训练集里不该记的东西。碰到这种情况我的处理顺序通常是这样的。先增强数据增强的强度比如把 Resize 和 RandomCrop 的组合改成 RandomResizedCrop让每次裁剪的尺寸和比例都随机变化相当于让模型见过更多样子的猫狗。然后把 Dropout 从 0.5 提高到 0.6或者给优化器加上 weight_decay 参数。weight_decay 本质上是 L2 正则会让模型权重趋向更小的值降低复杂度。代码上只需要改一行optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4)如果这些手段都用上了验证集还是上不去那就该考虑换模型了。同一个数据集上预训练网络的泛化能力会明显好于从零训练的小网络因为它的参数起点已经包含了大量通用视觉特征。还有一点要提个醒正则化不是加得越多越好。数据增强过猛、Dropout 过高会让模型欠拟合训练集和验证集准确率一起卡在一个比较低的水平。我的经验是一点点加每次只改一个变量观察两个 epoch 的效果再决定下一步。3.3 验证集和测试集别被训练集准确率骗了很多入门项目只分训练集和测试集这是不够严谨的。测试集应该只在模型完全调好之后用一次用来估计模型上线的真实效果。如果你反复用同一份测试集调参模型其实会间接“见过”测试集的信息最终评估结果就会虚高。在猫狗二分类这个体量的项目里我建议至少保证有独立的验证集。训练过程中每轮或者每隔几轮用验证集算一下准确率用它来决定什么时候保存模型、什么时候降低学习率。如果下载的数据集本身没有划分就自己写脚本按比例随机分割不要直接整个数据集训练否则你压根不知道模型泛化得怎么样。另外如果模型在训练集上的准确率一开始就很低不要去调正则化先去查学习率、数据归一化和标签对应关系。数据没归一化会导致梯度更新不稳定标签错位则会让 loss 始终保持在一个异常高的水平这类问题加再多的 Dropout 都没用。4. 新手最容易踩的坑问题排查与避坑清单4.1 标签错位最常见也最难发现的错误我文章开头提到的 loss 冲到 9 的消失就是标签错位引起的。这类错误很隐蔽因为程序不会崩溃loss 也能算出来只是完全学不到东西。最容易发生标签错位的地方是手动读取图片和标签的时候。有些人喜欢这样写images [f for f in os.listdir(train) if f.endswith(.jpg)] labels [cat if cat in name else dog for name in images]这段代码本身是能对上号的但一旦你在中间对 images 做了一次排序或者随机打乱而 labels 没有跟着变错位就发生了。避免方法有三个第一优先用 ImageFolder 配合目录结构不要手动维护列表第二如果非要自己写 Dataset在getitem里通过同一个索引去取图片和标签不要在外部预处理之后再传入第三训练脚本里加一行 assert len(images) len(labels)至少把数量不匹配这种低级错误拦住。怎么排查我推荐看两类信息。首先看 loss 的数值如果 CrossEntropyLoss 在训练初期就在 0.7 以上甚至更高且十个 epoch 都降不下来先怀疑标签错位。其次直接把 DataLoader 取出来的一个 batch 保存成图片配着标签一起看一眼人眼判断一下标签对不对比什么都直观。4.2 训练不收敛学习率、维度与数据顺序排查训练不收敛的问题类型很多我整理了一个速查表按出现频率排序现象可能原因排查方法解决办法loss 保持不变学习率太小或太大打印 loss 数值变化学习率先从 1e-3 试不收敛就调大/调小loss 变成 NaN数据没归一化、学习率过大检查输入数据的数值范围正确 Normalize降低学习率loss 异常高降不下来标签错位、输出维度不对检查 class_to_idx、打印 batch 标签修正标签映射检查 Linear 输出维度验证集准确率一直是 50% 左右模型还没训练起来、类别不均衡查看训练集 acc多跑几个 epoch或者增大 lr显存不够batch_size 太大报错提示 CUDA out of memory减小 batch_size、减小输入尺寸二分类的随机基线是 50%如果你的模型训练了十几个 epoch验证集准确率还在 50% 附近晃那基本说明训练没有生效。先不看模型结构把数据预处理、标签、学习率这三件事查清楚大概率能找到问题。还有一个容易被忽略的细节图片张量的通道顺序。Pytorch 里卷积层期望的输入是 [batch_size, channels, height, width]也就是通道数在前。如果你自己读图的时候用了一些图像处理库可能得到的是 [height, width, channels]这时候需要做维度交换transforms.ToTensor() 会自动把 HWC 转成 CHW所以用 torchvision 的预处理链一般不会出错。但如果绕过 transforms 自己转这一步就特别容易踩坑。4.3 推理阶段与训练阶段不一致的坑模型训练好了写推理脚本时还会碰到几个经典问题。第一个是忘了调 model.eval()。如果你的模型里有 Dropout不调 eval 的话每次预测同一个图片的结果可能都不一样因为 Dropout 还在随机丢弃神经元。新手往往以为模型加载出错实际上只是模式没切到位。第二个是推理时的预处理必须和训练时的验证集预处理一致。训练时我用的是 Resize((256, 256)) 然后 CenterCrop(224)推理时也应该完全一样。如果你直接 Resize(224) 或者用训练集的 RandomCrop尺寸和裁剪方式都对不上模型输入分布变了准确率自然会掉。第三个是类别顺序问题。ImageFolder 生成的标签 0、1 是按文件夹名称字母排序的cat 在 dog 前面所以 0 是 cat。你单独写推理脚本时把这个映射关系写死在代码里或者从训练时保存一个 class_to_idx.json 出来。不要在模型输出一个数字时靠猜。第四点是加载 checkpoint。上面保存的 checkpoint 是一个字典恢复模型权重要用checkpoint torch.load(checkpoint.pth, map_locationdevice) model.load_state_dict(checkpoint[model_state_dict])直接用 torch.load 拿到整个模型再调用是很多自定义网络容易报错的做法因为反序列化时需要能找到类的定义保存方式稍不一致就会出问题。4.4 亲测有效的几个提分技巧如果前面这些都调通了想让准确率再往上走一截我有几个简单有效的手段值得一试。第一设置随机种子保证实验可复现。加一段代码放在训练脚本最前面import random import numpy as np import torch random.seed(42) np.random.seed(42) torch.manual_seed(42) torch.cuda.manual_seed_all(42)训练过程本身有随机性不固定种子的话你换一台电脑或者多跑一次结果会有波动很难判断一个改动到底有没有效果。第二用学习率调度器。我常用 ReduceLROnPlateau当验证集准确率好几个 epoch 不涨的时候自动把学习率降一半。代码如下scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, factor0.5, patience2 ) # 每个 epoch 结束后 scheduler.step(val_acc)这里 modemax 是因为我们希望验证集准确率越大越好。每次验证准确率连续两个 epoch 没有创新高学习率就减半模型会在更细的尺度上继续搜索。第三保存验证集表现最好的模型而不是最后一个 epoch。在循环里加一句判断if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), best_model.pth)这样最后拿到的权重是模型在验证集上表现最好的状态而不是可能已经过拟合的最后一轮状态。5. 从二分类到更多场景项目扩展与个人体会5.1 从二分类改成多分类改动量比想象中小猫狗二分类练完之后你会发现把它扩展成多分类是一件非常自然的事。假设你想识别猫、狗、鸟三类只需要在 data/train 和 data/val 下面各加一个 bird 文件夹然后把网络的最后一层 Linear(256, 2) 改成 Linear(256, 3)训练循环和评估逻辑不需要动。ImageFolder 会自动根据文件夹列表生成 0、1、2 三个标签CrossEntropyLoss 本身就支持多分类。这个项目的结构完全可以复用到其他图像分类任务里比如判断图片里是什么水果、什么车型核心流程就是数据整理、预处理、模型设计、训练评估这几步。很多同学做完猫狗二分类之后觉得没有实际用途其实是低估了这套流程的通用性。5.2 数据量增大之后一个朴素但有效的优化路径如果你后续把任务升级成更大规模的数据集网络还是现在这个小 CNN 的话会发现训练时间明显变长准确率也可能到头了。优化的路径很朴素第一步换预训练模型第二步用混合精度训练第三步如果单卡显存不够做数据并行或者分布式训练。这三步每一步都有成熟的 Pytorch 内置实现不需要自己造轮子。不过我不建议在猫狗二分类这个阶段就追求这些高级特性。一个练手项目最重要的价值是让你把整个链路亲手走通把容易出错的地方都踩一遍。等到后面做真实项目时你才有能力快速定位问题是出在数据、模型还是训练设置上。我自己做完这个项目的最大体会是深度学习项目百分之七八十的时间其实都花在数据整理和排错上真正让模型训练起来只占很小一部分。很多人误以为难的是模型结构实际执行下来你会发现标签对齐、预处理一致、训练评估模式切换这些细节才是决定项目能不能顺利跑完的关键。这篇文章把我知道的坑都列出来了希望你能用更少的弯路完成这个经典练习也希望你把上面这份代码当成一个可用的起点而不是仅仅抄完就结束。