简介:基于Python与卷积神经网络的猫狗图片分类项目源代码,适合计算机相关专业正在准备毕业设计或期末大作业的学生,也适合需要图像分类实战练习的学习者。该项目获得导师认可,评审分98分,题目难度适中且经助教老师审定;全部源码均通过本地编译和严格调试,可稳定运行。资源zip压缩包约87.79MB,共2000个文件,其中含有1992张jpg格式的猫狗图像、7个Python脚本和1个Markdown说明文档;脚本覆盖数据加载、CNN模型搭建、训练与准确率评估等核心流程,文档便于快速理清项目结构与运行说明。学习者可直接使用现成数据集复现训练,参考网络参数配置调整结构,并结合代码注释理解卷积层、池化层、全连接层之间的关系,从而完成课程设计、毕业设计或深度学习入门实战;项目源码经过严格调试,可按文档指引复现猫狗分类结果。目前已有74人学习/下载,能够作为同类图像分类任务的选题与实现参考。
1. 猫狗图片分类项目:这个源代码到底能帮你省多少事
如果只看 demo 截图,猫狗图片分类给人的感觉是“深度学习入门而已”,但你真把手伸进一个自称“高质量优秀作品”的 Python + CNN 源代码项目,看到的往往是两种极端:要么是一堆别人跑不起来的半成品,要么是封装过度让你改一个卷积核都要翻半天的“大作业”。这个项目要解决的,正是这两件事——用 CNN 把猫狗分类这件事本身做扎实,同时把源代码组织得能改、能续、能复现。对刚入门的读者,它能当作第一个真正意义上的深度学习工程样板;对已经写过分类模型的读者,它的价值在数据处理、训练流程和保存加载策略这些容易被忽略的细节。
我拿到这类项目,第一件事不是读模型文件,而是先把数据加载、目录结构和 checkpoint 逻辑看明白。CNN 模型结构是黑匣子,但数据路径和训练循环不是,后者才决定你能不能在一小时内把它跑起来。这篇笔记就按这个思路展开:从环境准备、数据处理、模型构建、训练调参,到避坑与验证,把整个项目拆成你能直接上手复现的步骤。适合正在学 PyTorch、准备做课程设计或想建一个图片分类基线的读者。
2. 环境准备与猫狗数据集的预处理:把目录结构理顺,训练就成功了一半
2.1 用 venv 隔离 Python 环境,避免把本机装乱
这类深度学习项目最常见的翻车起点,不是代码,而是 Python 环境。猫狗图片分类用的依赖不算多,但 PyTorch、torchvision、matplotlib、numpy、pillow 这几件套版本一旦互相打架,你会浪费一下午在重装库上。我一般先在项目根目录建一个虚拟环境,再用 requirements 一次装齐,不做全局安装,这是血泪经验换来的习惯。
cd cat_dog_cnn python -m venv venv source venv/bin/activate pip install torch torchvision matplotlib numpy pillow建议用 PyTorch 官方推荐的安装命令区分 CPU 和 GPU 版本。上面这条默认装的是 CPU 版,如果你机器有 NVIDIA 显卡并且想用小 batch 快速验证模型,就去官网复制对应 CUDA 版本的命令,例如安装带 CUDA 支持的 PyTorch。接下来的代码都假设你已经在 venv 里。
环境这块有两个容易踩的细节:一是 torch 和 torchvision 版本必须配套,torchvision 的 API 在 0.15 之后有一些变化,老代码直接跑会报ImportError;二是pillow最好指定一个较新版本,老版本对新格式图片的解码支持有问题,训练中途报Image file is truncated的概率会明显升高。
2.2 用 ImageFolder 数据加载:目录名就是标签
猫狗图片分类这类二分类任务,最可靠的数据组织方式不是写一个自定义 Dataset 去读 CSV 标签,而是用torchvision.datasets.ImageFolder。它的规则特别简单:根目录下每个子文件夹的名字就是类别名,文件夹里的所有图片都归到这一类。这样做的好处是你的标签零维护,换新图片只需丢进对应文件夹,而ImageFolder会自动枚举类别并建立索引。
# prepare_data.py import os import shutil import random random.seed(42) src_root = "path/to/raw_images" # 原始图片目录:猫图狗图混在一起 train_root = "data/train" val_root = "data/val" # 手动准备两个类别文件夹 for split in [train_root, val_root]: os.makedirs(os.path.join(split, "cat"), exist_ok=True) os.makedirs(os.path.join(split, "dog"), exist_ok=True) # 假设原始数据结构为 {src_root}/cat/*.jpg 与 {src_root}/dog/*.jpg for cls in ["cat", "dog"]: src_dir = os.path.join(src_root, cls) images = os.listdir(src_dir) random.shuffle(images) val_count = int(len(images) * 0.2) # 留 20% 做验证集 for img in images[:val_count]: shutil.copy(os.path.join(src_dir, img), os.path.join(val_root, cls, img)) for img in images[val_count:]: shutil.copy(os.path.join(src_dir, img), os.path.join(train_root, cls, img)) print("train/val 目录生成完毕")这段脚本做的事就是分层抽样:从每个类里随机抽 20% 作为验证集,剩下进训练集。注意random.seed(42)保证每次执行划分结果一致,这一点很重要,否则你训练到一半发现验证集变了,loss 曲线会突然跳变,看起来像模型玄学,实际是数据划分不稳定。
接下来定义训练用的 transform。CNN 要求输入尺寸固定,所以Resize和CenterCrop是必须的。常见做法是统一缩放到 256x256 再中心裁剪到 224x224,这个尺寸是很多预训练模型的默认输入,后续如果你想换成 ResNet 也方便。
# transforms.py from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(), 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]) ])这里的Normalize用的是 ImageNet 的均值标准差,如果你的猫狗图片风格和 ImageNet 差得很远,理论上应该重新统计,但实际上大多数情况下沿用这套参数就能正常收敛。数据增强方面,我只用了随机裁剪和水平翻转,不要一上来就加太多花样,先保持样本分布稳定,模型能跑通以后再加增强也不迟。
数据加载器上,batch_size我通常先给 32,num_workers在 Windows 上给 0 最省事,Linux 可以给 4 或 8。如果你训练时发现 CPU 占用异常低、GPU 在干等,多半是num_workers没设对。
from torch.utils.data import DataLoader train_dataset = datasets.ImageFolder(train_root, transform=train_transform) val_dataset = datasets.ImageFolder(val_root, transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True)3. CNN 模型构建:从 LeNet 式结构到一个能自定义的卷积网络
3.1 为什么卷积层组合比全连接层更适合图片分类
猫狗图片分类本质上是一个二分类问题,但如果你直接拉平一张 224x224x3 的图片送进全连接网络,第一层就得有 150528 个输入神经元,参数数量直接爆炸,训练不但慢还容易过拟合。CNN 的核心思路是用卷积核在局部区域做特征提取,参数共享让网络规模小了一个数量级。具体到这个项目里,我一般先定义一个“卷积块 + 池化 + 展平 + 全连接”的经典骨架,让新手能直观看到特征图怎么逐层变小、通道数怎么逐层变多。
这里有一个经常被忽略的点:卷积核数量和特征图尺寸的变化不是随便拍的。每次MaxPool2d(2)会让宽高减半,你得大致估算一下最终展平向量的维度,后面全连接层的输入维度才能对得上。与其心算,不如写一行print查看中间维度。
3.2 一个能直接跑通的 CNN 模型类代码
下面这段代码是这类猫狗分类源代码里最核心的部分。我把模型封装成一个类,__init__里定义卷积层、池化层和全连接层,forward里描述数据流向。为了让你看清楚每层输出尺寸怎么变,我在forward里留了注释,训练时可以把print打开核对维度。
# model.py import torch.nn as nn import torch.nn.functional as F class CatDogCNN(nn.Module): def __init__(self, num_classes=2): super(CatDogCNN, self).__init__() # 第一组卷积:3 -> 32 通道,特征图 224 -> 224 self.conv1 = nn.Conv2d(in_channels=3, out_channels=32, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(32) # 第二组卷积:32 -> 64 通道 self.conv2 = nn.Conv2d(in_channels=32, out_channels=64, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(64) # 第三组卷积:64 -> 128 通道 self.conv3 = nn.Conv2d(in_channels=64, out_channels=128, kernel_size=3, padding=1) self.bn3 = nn.BatchNorm2d(128) self.pool = nn.MaxPool2d(kernel_size=2, stride=2) # 经过 3 次池化后,224 -> 112 -> 56 -> 28 # 最后一层卷积输出 128 个通道,所以展平维度为 128*28*28 self.fc1 = nn.Linear(128 * 28 * 28, 256) self.fc2 = nn.Linear(256, num_classes) self.dropout = nn.Dropout(p=0.5) def forward(self, x): x = self.pool(F.relu(self.bn1(self.conv1(x)))) x = self.pool(F.relu(self.bn2(self.conv2(x)))) x = self.pool(F.relu(self.bn3(self.conv3(x)))) # 展平:从 (batch, 128, 28, 28) 变为 (batch, 128*28*28) x = x.view(x.size(0), -1) x = F.relu(self.fc1(x)) x = self.dropout(x) x = self.fc2(x) return x关键参数都写清楚了:卷积核大小统一为 3x3,padding=1保持宽高不变,MaxPool2d尺寸减半。三次池化后 224 变成 28,128 个通道展平得到 100352 维向量,这个数字和fc1的输入维度必须一致。Dropout放在全连接层之间,只在全连接部分做随机失活,卷积层后面不接 Dropout 是常见做法,因为卷积层本身参数少,且有 BatchNorm 在控制激活分布。
如果你的项目里用的不是 224x224 而是其他尺寸,上面这段代码的fc1输入维度就要按新尺寸重新算。我每次改数据尺寸都会先跑一个随机输入张量过一遍模型,而不是去心算维度。
import torch model = CatDogCNN(num_classes=2) fake_input = torch.randn(1, 3, 224, 224) output = model(fake_input) print(output.shape) # torch.Size([1, 2])跑通这一步,模型结构就算立住了。如果此处报错,百分之八九十是fc1的输入维度不对,去改常量就行。
4. 训练循环与调参顺序:损失函数、优化器和学习率怎么配合
4.1 损失函数与优化器:CrossEntropyLoss 和 Adam 的搭配理由
猫狗分类是个二分类任务,但输出层是 2 个神经元,这里最合适的损失函数不是BCELoss,而是CrossEntropyLoss,它内部会自动做 softmax 并计算交叉熵。BCELoss需要你手动把输出压缩到 0 到 1 之间,还要改标签编码方式,纯属给自己找麻烦。优化器方面,项目源代码里我常用 Adam,学习率先给1e-4,这个组合在中小型数据集上收敛稳定,几乎不需要额外调参。如果你愿意多花时间,SGD 配合 momentum 也能达到更好的收敛效果,但新手阶段没必要在优化器上纠结。
4.2 训练循环代码:batch 迭代、loss 记录与 checkpoint 保存
训练循环是这类源代码里最需要考虑工程细节的部分。下面这个版本我加了几个实用组件:验证集评估、早停和 checkpoint 保存,最后会打印每个 epoch 的准确率变化,方便你判断模型是收敛、过拟合还是已经训练崩了。
# train.py import torch import torch.nn as nn import torch.optim as optim device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = CatDogCNN(num_classes=2).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5) num_epochs = 30 best_val_acc = 0.0 patience = 5 bad_epochs = 0 for epoch in range(num_epochs): model.train() running_loss = 0.0 correct = 0 total = 0 for inputs, labels in train_loader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() train_loss = running_loss / total train_acc = 100.0 * correct / total model.eval() val_correct = 0 val_total = 0 with torch.no_grad(): for inputs, labels in val_loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) _, predicted = torch.max(outputs, 1) val_total += labels.size(0) val_correct += (predicted == labels).sum().item() val_acc = 100.0 * val_correct / val_total print(f"Epoch {epoch+1}/{num_epochs} | " f"Train Loss: {train_loss:.4f} | " f"Train Acc: {train_acc:.2f}% | " f"Val Acc: {val_acc:.2f}%") scheduler.step() if val_acc > best_val_acc: best_val_acc = val_acc bad_epochs = 0 torch.save({ "epoch": epoch, "model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "best_val_acc": best_val_acc, }, "best_model.pth") print(" -> 保存最佳模型") else: bad_epochs += 1 if bad_epochs >= patience: print(f"连续 {patience} 个 epoch 验证集未提升,早停。") break几个参数的取舍逻辑说一下。StepLR(step_size=10, gamma=0.5)的意思是每 10 个 epoch 把学习率乘 0.5,学习率从1e-4逐步降到5e-5、2.5e-5,后期训练精细度更高。patience=5是早停的耐心值,如果验证集准确率连续 5 个 epoch 没创新高就停,防止你浪费算力在过拟合阶段。这里还有一个细节,scheduler.step()必须放在每个 epoch 结束时调用,放错位置会严重影响学习率变化节奏。
为什么要保存整个字典而不是只保存model.state_dict()?因为恢复训练时需要把优化器状态、当前 epoch 都找回来。如果你之后想加载模型继续训练,只保存权重是不够的,Adam 的动量信息会丢失,恢复后的前几个 epoch 会有明显震荡。
4.3 调参顺序:先让 loss 降下去,再谈准确率
我见过太多人一上来就调卷积核数量、加层加通道,结果 loss 根本不降,问题根本不出在模型容量上。正确的顺序应该是:先用小数据集(比如每类 500 张)把整个流程跑通,确认 loss 在下降、准确率在提升,然后放大到全量数据训练。如果 loss 震荡得厉害,优先调低学习率,而不是改模型结构。学习率1e-4是个相对保守的起点,Adam 对学习率不敏感,但这个值在图片分类任务里基本不会翻车。如果 loss 一开始就不降,检查数据加载是否正常、标签是否错位、model.train()有没有调用。
5. 猫狗分类源代码的高频坑:现象、原因、解决办法
5.1 训练时 loss 剧烈震荡,准确率在 50% 上下徘徊
现象:loss 曲线像锯齿一样上蹿下跳,训练到十几个 epoch 准确率依然在 50% 附近,和抛硬币没区别。
原因:学习率过大是最常见的原因,Adam 在lr=1e-3时对这类小数据集容易出这种问题;另外,如果数据加载没有shuffle=True,模型每个 batch 学到的全是同一个类别的图片,梯度方向会被带偏。
解决:先把学习率降到1e-4或1e-5,确认shuffle=True,然后观察前两个 epoch 的 loss 是否稳定下降。如果还不行,检查标签是否对得上,打印一个 batch 的inputs和labels看看,猫对应 0、狗对应 1,这个对应关系不能反。
5.2 验证集准确率远低于训练集,模型泛化失败
现象:训练集准确率已经 99%,验证集却只有 75%,两者差距越拉越大。
原因:过拟合,而且数据量太少或者数据增强不够。猫狗分类很容易过拟合,尤其是网络容量偏大、训练 epoch 又长时,模型开始记住训练集细节而不是学习真正的分类特征。
解决:增加随机翻转、随机裁剪等增强手段;加大Dropout概率到 0.5(我这个模型里已经用了);减少 epoch 数并配合早停;必要时用预训练模型做迁移学习,效果会立竿见影。还有一个细节:验证集不要做增强,只做Resize、CenterCrop、Normalize。
5.3 加载 checkpoint 后训练准确率断崖式下降
现象:用torch.load加载保存的模型继续训练,第一个 epoch 的 loss 奇高,准确率掉到接近随机。
原因:保存和加载时模型定义不一致,最常见是加载前模型类的结构变了,或者你加载的是model.state_dict()而保存时保存的是整个字典导致键不匹配。另一种可能是保存时没写model.eval(),如果你在评估时直接加载权重,BatchNorm 和 Dropout 状态不对。
解决:保存时统一用我上面给出的字典格式,加载时统一走model.load_state_dict(checkpoint["model_state_dict"])。恢复训练前要把优化器状态也加载回来:
checkpoint = torch.load("best_model.pth") model.load_state_dict(checkpoint["model_state_dict"]) optimizer.load_state_dict(checkpoint["optimizer_state_dict"])5.4 报错Image file is truncated或Found 0 images in subfolders
现象:数据加载阶段直接崩,ImageFolder报找不到图片,或者训练到一半突然报图片解码失败。
原因:数据集里混进了损坏的图片文件;另一个坑是目录结构不对——ImageFolder要求根目录下必须有一层类别子目录,你直接把图片全放在根目录下就会识别成 0 个类。
解决:检查目录结构是否严格符合data/train/cat/*.jpg和data/train/dog/*.jpg;对损坏图片,训练前做一次过滤,用PIL.Image.open验证每张图能否正常打开,不能打开的直接移到坏图文件夹。这一步虽然费时间,但能避免训练到第 8 个小时突然中断的悲剧。
5.5 GPU 显存充足但训练速度慢,num_workers怎么调都上不去
现象:GPU 利用率只有 30%,CPU 却满载,训练时间比预期翻了一倍。
原因:数据加载成了瓶颈。num_workers太高会导致进程频繁切换,太低又无法预先取数据;在 Windows 上num_workers设大于 0 还可能触发多进程递归问题。
解决:Linux 上num_workers按 CPU 核心数的四分之一到一半来设;Windows 上先设 0 跑通,再逐步升到 2、4 观察变化。pin_memory=True对 GPU 训练有实际加速效果,但只在 CUDA 可用时才有意义。
6. 用混淆矩阵和单图预测核对模型真实水平,别只盯着准确率
训练结束后,很多人只看验证集准确率就宣布“项目完成”,这不够。猫狗二分类里准确率虽然直观,但无法暴露类别偏好——比如模型可能把大部分猫都分对了,狗却错了一半。我每个项目都会补上一张混淆矩阵和一次单图预测,这两个验证手段能在五分钟内让你搞清楚模型到底哪里不行。
# evaluate.py import torch import numpy as np import torchvision.datasets as datasets from torchvision import transforms from sklearn.metrics import confusion_matrix import matplotlib.pyplot as plt 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]) ]) val_dataset = datasets.ImageFolder("data/val", transform=val_transform) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = CatDogCNN(num_classes=2).to(device) checkpoint = torch.load("best_model.pth") model.load_state_dict(checkpoint["model_state_dict"]) model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for inputs, labels in val_loader: inputs = inputs.to(device) outputs = model(inputs) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) print("混淆矩阵:") print(cm)看混淆矩阵的时候,我重点看非对角线数字。如果cm[0][1]明显大于cm[1][0],说明模型更倾向于把猫预测成狗,这时候优先去补猫类样本的多样性,而不是改网络结构。单图预测的代码更简单,加载一张图走同一个 transform 流程,输出类别索引和置信度分数:
from PIL import Image def predict_single(image_path): image = Image.open(image_path).convert("RGB") tensor = val_transform(image).unsqueeze(0).to(device) with torch.no_grad(): output = model(tensor) prob = torch.softmax(output, dim=1) class_idx = torch.argmax(prob, dim=1).item() class_name = val_dataset.classes[class_idx] confidence = prob[0][class_idx].item() print(f"预测类别: {class_name}, 置信度: {confidence:.4f}") return class_name, confidence我自己做这类项目时养成的习惯是:每保存一次最佳模型,就顺手跑十张验证集外的图片做单图预测,看看真实图片上的置信度分布是否合理。如果一张猫图的置信度只有 0.55,虽然分类对了也不敢说模型是稳健的。这类小验证花的时间不到两分钟,但能让你对模型的泛化能力有比准确率更准确的判断。
这个项目值得投入的深度其实比看上去大。把基础 CNN 跑通只是第一步,后续你可以试着换成预训练的 ResNet 做迁移学习,看看同样的数据量下准确率能提升多少,也可以把模型导出为 ONNX 放到移动端推理。关键是先把这个最小闭环做扎实,数据、模型、训练、验证四个环节每一处都要能解释明白,再去谈更复杂的结构。
希望这篇笔记能帮你在猫狗图片分类这个项目上少走弯路,把源码玩转起来。
本文还有配套的精品资源,点击获取