简介:一份基于PyTorch的猫狗图像分类实战项目教程,主要面向初次接触深度学习的初学者以及有一定基础、希望深入理解并运用PyTorch的开发者。教程以Kaggle猫狗数据集为依托,完整覆盖从项目背景、数据准备与增强(随机裁剪、水平翻转、归一化等)、轻量级CNN模型构建、训练与评估到部署的关键环节。每个阶段均有详细解释与可直接复制的Python代码片段,并加入多种实用调参技巧来提升模型性能,帮助读者快速掌握从数据到模型的完整实战链路。资源包为1个docx文档,压缩包大小约19KB,内容精炼紧凑,便于在个人学习、课堂教学或撰写技术博客时作为参考资料。目前已有152人学习下载。教程结尾还总结了项目中的常见陷阱与改进方向,使读者不仅能跑通代码,更能理解背后的设计思路,适合作为深度学习和图像分类的实战入门指南。
1. 从“识别猫狗”到跑通深度学习全流程:这份实战项目到底解决什么
假设你接到一个需求:做一个能区分猫和狗的APP。听着简单,但真正动手时会发现,数据怎么组织、模型怎么搭、训练到什么时候停、训练完怎么给别人用,每一步都有坑。这份基于PyTorch的猫狗图像分类实战项目,恰好把深度学习的完整闭环走了一遍——从数据准备、模型构建、训练评估到部署导出,全程有代码可跑,有参数可调,有结果可验证。它不是一份只讲概念的教程,而是一份能让你照着敲完就拿到一个91%左右准确率模型的实战脚本。适合刚入门想跑通第一个深度学习项目的人,也适合需要快速产出技术教程或教学案例的开发者。反直觉的一点是:效果不错的模型并不需要很深,一个五层不到的轻量级CNN加上扎实的数据增强,就已经能在Kaggle猫狗数据集上取得可观效果——真正拉开差距的是数据处理和训练纪律,不是网络复杂度。
2. 数据准备与增强:为什么数据质量决定模型上限
2.1 数据集目录组织与ImageFolder机制
Kaggle的猫狗数据集包含25000张带标签图片,下载后是散落的图片文件加一个CSV标注文件。直接用CSV读路径也不是不行,但PyTorch里有一个更省事的方案:torchvision.datasets.ImageFolder。它要求数据按类别放在子目录里,目录名就是标签名。所以第一步是把数据整理成这样的结构:
train_data/ cat/ cat.100.jpg cat.101.jpg dog/ dog.100.jpg dog.101.jpg val_data/ cat/ dog/整理方式可以用脚本按CSV里的标注移动文件,也可以手动按文件名前缀分。写一个Python脚本批量处理是最靠谱的:
import os import shutil import pandas as pd # 假设原始数据在 raw_data/,标注文件 labels.csv 包含 filename,label 两列 df = pd.read_csv('labels.csv') for _, row in df.iterrows(): src = os.path.join('raw_data', row['filename']) dst_dir = os.path.join('train_data', row['label']) os.makedirs(dst_dir, exist_ok=True) shutil.copy(src, os.path.join(dst_dir, row['filename']))这段脚本做的事情很简单:遍历标注表的每一行,把图片复制到以标签命名的子目录里。os.makedirs(dst_dir, exist_ok=True)是先建目录再复制,避免目录不存在时报错。
这里有一个值得注意的细节:如果你在原始数据里发现某个类别的图片数量明显多于另一个,比如猫8000张、狗17000张,那就要做类别平衡处理。常见做法是欠采样,从多的类别里随机抽一部分补齐少数类,或者把少数类复制几份,让两个类的训练样本量接近。ImageFolder本身不做任何平衡,它只会按目录读图,类别不均衡的问题需要你自己提前处理。
2.2 transforms组合:裁剪、翻转、归一化的每一项都在干什么
数据准备阶段的核心代码是torchvision.transforms的组合。项目正文里有这样一段:
import torchvision.transforms as transforms from torchvision.datasets import ImageFolder train_transform = transforms.Compose([ transforms.RandomResizedCrop(224), # 随机裁剪并缩放到224x224 transforms.RandomHorizontalFlip(), # 50%概率水平翻转 transforms.ToTensor(), # 转Tensor并归一化到[0,1] transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])逐个说清楚。RandomResizedCrop(224)会随机选一块原图区域裁剪下来再缩放到224×224。随机裁剪的范围和比例都是随机的,相当于让模型看到同一只猫在不同位置、不同大小下的样子,提升对目标尺度和位置的鲁棒性。RandomHorizontalFlip是50%概率水平翻转——猫狗图片左右翻转后语义不变,这是图像分类里性价比最高的数据增强手段,几乎零成本增加样本多样性。ToTensor把PIL Image或numpy数组转成Tensor,同时把像素值从[0,255]缩放到[0,1]。Normalize用均值[0.485, 0.456, 0.406]和标准差[0.229, 0.224, 0.225]做标准化,这三个均值标准差来自ImageNet数据集的统计值,因为ImageNet上的图片和自然场景下的猫狗图片分布接近,直接借用比自己在小数据集上现算要稳。
| 参数 | 值 | 作用 |
|---|---|---|
| RandomResizedCrop | scale默认(0.08, 1.0) | 随机裁剪区域占原图8%-100% |
| RandomHorizontalFlip | p=0.5 | 50%概率左右翻转 |
| Normalize | ImageNet均值/标准差 | 标准化输入分布,加速收敛 |
验证集上的transform要格外小心:不能加随机翻转和随机裁剪。验证集的作用是模拟真实推理场景,数据分布必须保持原始状态。项目正文里验证集只用了ToTensor(),这个做法是对的。如果验证集也做随机增强,你看到的指标会上下抖动,根本没法判断模型是否真的在变好。
2.3 DataLoader参数配置与训练/验证划分
数据加载用torch.utils.data.DataLoader,代码不长但参数含义要清楚:
from torch.utils.data import DataLoader train_dataset = ImageFolder("train_data/", transform=train_transform) val_dataset = ImageFolder("val_data/", transform=transforms.ToTensor()) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4)batch_size=32意味着每个batch拿32张图喂给模型,同时算梯度和更新参数。batch size太小梯度噪声大、训练不稳;太大则单次迭代时间变长,显存占用高。32是一个在大多数单卡GPU上都能跑的保守值。shuffle=True在训练时必须开,否则每个epoch喂给模型的样本顺序完全一样,会让模型学到batch之间的伪规律,导致收敛变差。验证集不需要shuffle。num_workers=4是数据加载的子进程数,数据预处理是CPU操作,GPU在算的时候CPU同时在准备下一批数据,能明显减少GPU等待时间。
关于训练/验证集划分,建议8:2。把25000张图分成20000训练、5000验证。划分时要确保两个类在两边各占约一半,别出现训练集80%是狗、验证集80%是猫这种离谱情况。最简单可靠的做法是sklearn.model_selection.train_test_split按标签做分层切分,然后再按里面的目录结构落盘。
数据准备这一环做完,先别急着搭模型。我一般会先在笔记本里打印几个样本图看一眼,确认增强后的图片没有变形扭曲到看不出猫狗的程度。RandomResizedCrop的scale参数如果设得太小,比如默认下限0.08,极端情况下会裁到一团模糊的绒毛,人眼都认不出是猫是狗,模型就更难学了。
3. 从零搭一个轻量级CNN:为什么不用ResNet起步
3.1 你需要的不是大模型,而是能解释的模型
很多初学者一上来就想用ResNet50甚至EfficientNetB7。想法可以理解——ImageNet竞赛冠军架构,听起来就靠谱。但在25000张图的猫狗二分类任务上,这样做的问题很明显:训练时间成倍拉长,显存占用翻好几倍,调试周期变长,而且一旦效果不理想,你根本说不清是数据问题、优化器问题还是网络设计问题。轻量级CNN的好处在于每一步都可解释、可定位。网络结构简单意味着每一层的输入输出尺寸、参数数量都能手算,出问题时你能判断是哪一层在搞鬼。
项目正文里的设计思路非常务实:两个卷积块加两个全连接层,总参数不到100万。作为对比,ResNet18的参数量是1100万左右。实践下来,这个轻量级结构在猫狗分类上已经能达到90%以上的准确率,作为入门和教学已经足够。深度学习不是模型越大越好,而是模型的复杂度要和数据量、任务难度匹配。
3.2 网络结构逐层拆解与特征图尺寸计算
import torch.nn as nn class CatDogClassifier(nn.Module): def __init__(self): super().__init__() self.conv_layers = nn.Sequential( nn.Conv2d(3, 16, 3, padding=1), # 输入3通道彩色图 nn.ReLU(), nn.MaxPool2d(2), # 尺寸减半 nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2) # 尺寸再次减半 ) self.fc_layers = nn.Sequential( nn.Linear(32 * 56 * 56, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, 2) ) def forward(self, x): x = self.conv_layers(x) x = x.view(x.size(0), -1) return self.fc_layers(x)为什么nn.Linear(32 * 56 * 56, 256)这里的输入是32×56×56?这是每个从业者都该会算的尺寸变化。输入224×224经过两个MaxPool2d(2)后,边长每次减半:224→112→56。通道数经过两个卷积分别变成16和32,所以全连接层的输入是32通道的56×56特征图,展平后就是32×56×56=100352个数。padding=1的目的是让3×3卷积不改变特征图尺寸,配合池化层来控制下采样节奏。
Dropout(0.5)放在第一个全连接层后面,是全连接层参数多、容易过拟合的地方。Dropout在训练时随机丢弃50%的神经元连接,相当于每次迭代都在训练一个不同的子网络,多个子网络集成起来就有了正则化效果。注意Dropout在model.eval()模式下会自动失效,PyTorch已经处理好了,不需要手动关。
关于输出层用Softmax还是Sigmoid的问题:二分类任务两者数学上等价。但nn.CrossEntropyLoss()在PyTorch里已经内置了LogSoftmax,所以模型最后一层直接输出原始logits就行,不要在前面手动加Softmax——加了反而会让梯度计算变得数值不稳定。如果你想用Sigmoid,那损失函数要换成BCEWithLogitsLoss,逻辑是另一套,没必要在二分类上绕弯子。
3.3 从自定义CNN到迁移学习的进阶路径
轻量级CNN跑通之后,下一步的自然选择是迁移学习。把torchvision.models里预训练好的ResNet18拿过来,把最后一层全连接换成输出为2的新全连接层:
import torchvision.models as models model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, 2)weights=models.ResNet18_Weights.IMAGENET1K_V1会下载在ImageNet上预训练好的权重,模型的浅层已经学会了边缘、纹理、形状等通用特征。猫狗识别和ImageNet图像分类的底层特征高度重合,所以预训练权重可以让模型在少量数据上快速收敛。这时候可以分两阶段训练:前面若干epoch冻结所有卷积层,只训练新替换的全连接层;等loss不再下降时,再解冻部分卷积层并调小学习率做微调。这套做法是迁移学习的标准流程,值得在跑通基础模型后亲手试一遍——你会直观感受到收敛速度和最终准确率的提升。
4. 训练调参与评估:loss不降时先查哪里
4.1 训练循环的正确顺序与设备管理
训练循环的代码本身不复杂,但每一步的顺序是有讲究的:
import torch.optim as optim device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = CatDogClassifier().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) for epoch in range(10): model.train() running_loss = 0.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() print(f"Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}")optimizer.zero_grad()是清空上一轮batch留下的梯度。PyTorch的梯度是累积的,不清空的话,下一轮backward会把新梯度和旧梯度叠加,导致参数更新方向完全错乱。这个位置不能忘,新手最常见的报错之一就是loss曲线震荡不收敛,最后发现梯度根本没清零。loss.backward()计算梯度,optimizer.step()用梯度更新参数,这个顺序是固定的,不能颠倒。model.train()和model.eval()切换也很关键——前者开启Dropout和BatchNorm的训练行为,后者关闭,如果评估时忘了切回eval模式,Dropout会随机丢弃神经元导致推理结果不稳定。
4.2 超参数:学习率、batch size与优化器选择
学习率是深度学习里最该花时间调的超参数,没有之一。项目里用Adam优化器配lr=0.001,这是一个对大多数任务都稳的起步值。0.001过大的表现是loss快速下降后突然变成NaN,或者直接震荡不降;过小的表现是loss缓慢下降,10个epoch后准确率还不到70%。我一般会在0.001的基础上做一次快速探测:前3个epoch分别用0.01、0.001、0.0001试跑,看loss曲线的下降斜率选最合适的。
batch size的选择直接受显存约束。32跑不动就降到16,还不行就8。batch size变小会让梯度方向更嘈杂,但配合适当降低学习率通常也能收敛。如果显存够大,64也能用,但配套要把学习率往上调一点,Adam的默认学习率0.001是按小batch调出来的,直接加大batch size不调学习率效果反而变差。
更进阶的调参技巧是学习率调度器和早停。项目正文里提到了早停机制的思想:验证损失连续3轮不下降就终止训练。实现方式是在每个epoch结束后评估验证损失,如果优于之前的最佳值就保存模型权重,如果连续N次没刷新最佳值就停止:
best_val_loss = float('inf') patience = 0 for epoch in range(10): train_one_epoch() val_loss = evaluate() if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), "best_model.pth") patience = 0 else: patience += 1 if patience >= 3: print("Early stopping...") break早停不是锦上添花,是必须的。模型在训练集上的loss会持续下降,但验证loss降到某个点后就会反弹,这个反弹点就是过拟合开始的位置。早停的价值在于自动在验证loss最低的位置停下,并把当时的权重保存下来——这就是“后悔药”。
4.3 用分类报告而不是单一准确率判断模型好坏
训练结束后,评估代码长这样:
from sklearn.metrics import classification_report model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for images, labels in val_loader: images = images.to(device) labels = labels.to(device) outputs = model(images) preds = outputs.argmax(dim=1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) print(classification_report(all_labels, all_preds))outputs.argmax(dim=1)为什么能当预测值用?因为模型输出的是两个类别的logit分数,取分数高的那个下标就是预测的类别。torch.no_grad()关闭梯度计算,推理阶段不需要反向传播,关掉能省一大半显存和计算时间。
典型输出里,模型准确率在91%左右,precision和recall都在0.88-0.93之间。看这份报告时最该关注的是每个类单独的recall,也就是“真猫被识别的比例”。如果猫的recall是0.88,狗的recall是0.93,说明模型对猫的漏检率更高,这个信息单一准确率给不了你。如果发现这种情况,常见做法是给少数类更高的loss权重,或者用Focal Loss让模型注意力向困难样本倾斜。
5. 避坑指南:猫狗分类训练里的五个典型问题
5.1 Loss变成NaN
现象:训练到第几个batch后,loss突然变成nan,之后越跑越大,完全不可用。
原因:最常见的是学习率过大,梯度更新跨越太大导致数值溢出;其次是输入数据里混入了损坏图片,PIL读取失败或像素值异常;Normalize参数写错也可能导致数值范围失控。
解决:先把学习率从0.001降到0.0001,如果问题消失就说明是学习率问题。然后检查数据管道,逐个加载一下所有图片文件确认没有损坏;Normalize的均值标准差写反也会造成这种问题,对照ImageNet标准值仔细核对一遍。最后把optimizer换成SGD试一次——SGD虽然收敛慢,但很少出现NaN,可以帮定位是优化器问题还是数据问题。
5.2 CUDA Out of Memory
现象:训练一开始或跑到中途,报错CUDA out of memory。
原因:batch size设太大,显存一次性装不下;或者训练还没结束但显存被其他进程占用;还有一种隐蔽情况是验证阶段忘了写torch.no_grad(),推理时也做了反向传播占用显存。
解决:优先把batch size从32降到16,这是最快的解决方案;检查验证循环里有没有no_grad(),没有就加上;用nvidia-smi查看显存占用,确认没有其他程序占着卡;如果数据集图片过大,可以考虑把输入分辨率从224降到160,显存占用会降一半左右。
5.3 Windows下DataLoader卡死或报错
现象:代码在Linux上跑得好好的,换到Windows上,num_workers设为大于0的数字时,程序启动后就卡住,或者出现BrokenPipeError。
原因:Windows的多进程数据加载机制和Linux不同,DataLoader的子进程在某些环境下会和新起线程冲突。这个问题的出现率在Windows上非常高,属于PyTorch在Windows平台的老毛病。
解决:最直接的办法是把num_workers=0,让数据加载在主进程里跑,慢一点但稳定。另一个规范做法是把整个训练逻辑包在if __name__ == "__main__":里,能规避大部分Windows的进程问题。如果训练数据量大、num_workers=0太慢,可以考虑用torch.utils.data.DataLoader的persistent_workers=True参数,但前提依然是要有主入口保护。
5.4 训练准确率高但验证准确率低
现象:训练集准确率稳定在95%以上,验证集却只有75%左右,而且验证loss在某个epoch后开始反弹。
原因:这是标准的过拟合。模型把训练集上的特有模式背下来了,比如记住了某几张图背景的颜色——但在新图上这些特征不存在。数据量少、模型表达能力过强、训练轮次太多都会加重过拟合。
解决:把训练epoch从10降到6或7,观察验证loss的反弹点;检查Dropout是不是因为某些原因失效了;增加数据增强强度,比如把RandomResizedCrop的scale范围调大,或者加ColorJitter做颜色扰动;确认训练集和验证集没有数据泄露——比如同一只猫的照片被同时分到了训练集和验证集。
5.5 分类准确率很高但实际用起来很崩
现象:在测试集上准确率91%,但把模型接到真实APP里,用户随便拍一张猫图过来,结果经常识别错。
原因:真实场景的数据分布和Kaggle数据集不一样。Kaggle的图大部分是正对镜头的宠物照片,用户随手拍的可能是远景、模糊、部分遮挡、光照很差的情况。这属于训练分布和推理分布不一致的问题,不是模型本身有问题。
解决:在部署前收集一批目标场景图片做验证,把不达标的样例加进训练集重新训练——这个流程叫数据闭环。同时建议在输入侧做统一预处理,比如Resize(256) -> CenterCrop(224),保证用户上传的长宽比和训练时看到的一致;如果项目允许,可以引入图像质量过滤,图片太糊直接返回“无法识别”。
6. 模型保存、推理与ONNX导出:训练结束只是开始
6.1 保存state_dict还是完整模型
# 推荐:只保存模型参数 torch.save(model.state_dict(), "cat_dog_classifier.pth") # 推理时重建模型结构再加载 model = CatDogClassifier() model.load_state_dict(torch.load("cat_dog_classifier.pth")) model.eval()model.state_dict()保存的只有参数,不包含网络结构。好处是文件小、结构清晰,坏处是加载时你必须保留定义模型结构的代码。如果你把模型发给别人但没有附带CatDogClassifier的类定义,对方根本加载不了。torch.save(model, "model.pth")保存的是完整模型对象,文件更大,加载时虽然可以直接torch.load,但安全性差,不建议在生产环境用。我自己的习惯是:中间调参过程存state_dict加一个结构说明文件,最后交付时导出ONNX,两边都不依赖。
6.2 推理预处理要与训练对齐
训练时的数据增强是随机裁剪、随机翻转,推理时不能再随机。推理的预处理要保证每一项都跟训练时的空间分布对齐:
from PIL import Image def predict(image_path): image = Image.open(image_path).convert("RGB") transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) image = transform(image).unsqueeze(0).to(device) model.eval() with torch.no_grad(): output = model(image) prob = torch.nn.functional.softmax(output, dim=1)[0][1].item() return "Dog" if prob > 0.5 else "Cat", probResize(256)和CenterCrop(224)的组合是对应训练时的RandomResizedCrop(224)——训练时模型学会的模式是“224×224的猫”,推理时就必须把输入也变成224×224。CenterCrop先缩放到256再居中裁224,比直接Resize(224)保留更多有效信息,这是实践中的标准做法。unsqueeze(0)给单张图片加一个batch维度,因为模型输入要求是四维的(N, C, H, W)。softmax把logits转成概率,[0][1]取第一个样本第二个类别的概率——这里第二个类别对应标签1即“Dog”。
6.3 ONNX导出与数值校验
训练好的PyTorch模型要在生产环境部署,最常见的是导出成ONNX格式:
dummy_input = torch.randn(1, 3, 224, 224).to(device) torch.onnx.export( model, dummy_input, "cat_dog_classifier.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}}, opset_version=11 )dummy_input导出一个形状固定的随机张量,ONNX导出时用它走一遍前向计算,把整个计算图固化下来。dynamic_axes的作用是允许推理时batch size不固定为1——如果你的服务需要同时处理多张图,这个参数很关键。opset_version=11是一个兼容性比较广的版本,太新了某些推理框架不支持,太旧了部分算子无法表示。导出后有一件事必须做:拿同一张图分别跑PyTorch原模型和ONNX Runtime推理,对比输出的logits差值。差值在1e-5以内才算导出成功,如果差异大,大概率是某个算子没有被ONNX完整支持,需要换opset版本或修改网络结构。
ONNX导出后的推理用`onnxruntime`跑,直接`session.run(None, {"input": image_np})`就能拿到输出,不需要PyTorch环境,CPU上速度也不差。想再进一步提速,可以接TensorRT做半精度推理,但前提是目标机器有NVIDIA GPU且安装好TensorRT。从那以后我每次导出ONNX都会先跑一遍数值比对,确认导出前后输出差异在1e-5以内才放心交给下游——这个习惯帮我躲过很多次部署现场的返工,希望也能帮到你。
本文还有配套的精品资源,点击获取