基于CNN的猫狗图像识别:从数据准备到模型部署的完整实战
简介这份Python实战项目基于CNN的猫狗图像识别检测分类源码包面向正在完成期末大作业、毕业设计或需要项目实战的计算机专业学生。项目由导师指导并高分通过评审得分98分源码均在本地编译调试可运行难度适中适合作为图像分类项目的完整练手案例。压缩包共2000个文件约218.49MB以1990张jpg猫狗图片数据集为主附带Python源码、XML标注文件、PDF文档及工程配置可直接用于模型训练与结果验证。已有222人学习下载。内容包含完整的CNN模型实现、数据集划分与预处理逻辑文档PDF对设计思路和关键代码做了说明方便快速理解并复现同时附有.gitignore和.iml等工程配置便于导入PyCharm后直接运行。对需要快速搭建图像识别项目或完善课程报告的学习者这是一份结构清晰、开箱即用的参考资料。1. 基于CNN的猫狗图像识别为什么它是你该认真做完的第一个深度学习项目如果你正在找第一个深度学习练手项目猫狗图像识别几乎是绕不开的起点。这个题目听上去简单但它覆盖了一条完整的链路数据组织、预处理、模型搭建、训练调参、效果评估、模型导出。对于只想在简历上写“我跑过MNIST”的人来说把猫狗分类做到验证集准确率95%以上才有资格说自己真正碰过卷积神经网络。标题里的“高分项目”也印证了这件事——只要是认真做完并写了文档的项目放在课程设计、毕业设计或者个人作品集里都很能打。这个项目的价值在于它不是一个“调包就能跑”的玩具。同样一份数据集不同的人跑出来效果可以差10个百分点差别全在数据划分、预处理、超参数和模型结构的选择上。新手照着教程敲一遍能跑通熟手能在同样的框架下通过迁移学习和数据增强把准确率从80%拉到接近98%。这中间每一步都是可验证、可解释的不像很多工业项目那样黑匣子到底。这篇笔记我会把完整方案拆开来讲从环境搭建到模型训练再到那些容易让人翻车的细节。2. 猫狗分类的技术选型从特征工程到CNN数据目录为什么必须这样建2.1 为什么用CNN而不是传统特征方法卷积层到底替你做了什么在深度学习流行之前做猫狗图像分类的常见套路是把图片缩放到固定尺寸提取颜色直方图、HOG特征或SIFT特征然后丢进SVM或随机森林里训练。这套方案的问题在于特征是人手工设计的你设计什么模型就学什么。猫和狗的差异在颜色上不明显在纹理上很接近靠手工特征很难找到一个稳定的分界面。CNN解决的是“特征自动提取”。卷积层通过滑动窗口在图像上扫描底层卷积核学到的是边缘、角点、颜色块这些低级特征高层卷积核把这些组合成耳朵形状、眼睛位置、毛发纹理等语义特征。这个过程不需要人工干预反向传播会自动调整卷积核的数值。用一句行业里的黑话讲CNN把“特征工程”变成了“特征学习”这也是为什么它在图像任务上明显优于传统方法。对猫狗这个任务来说CNN还有一个额外的好处它对位置平移不敏感。猫可能在图片左边也可能在右边可能大头特写也可能全身照池化操作让模型在局部区域内有了一定的平移容忍度这比传统方法里硬性对齐要鲁棒得多。2.2 数据集目录结构train/val/test 怎么分文件名和类别标签的坑拿到数据集之后第一件事不是写模型而是先把目录结构理顺。PyTorch的torchvision.datasets.ImageFolder要求按类别分子目录存放图片这是最省事的组织方式也是绝大多数开源代码默认的格式。标准结构如下data/ ├── train/ │ ├── cat/ │ │ ├── cat.1.jpg │ │ ├── cat.2.jpg │ │ └── ... │ └── dog/ │ ├── dog.1.jpg │ ├── dog.2.jpg │ └── ... ├── val/ │ ├── cat/ │ └── dog/ └── test/ ├── cat/ └── dog/这个结构里有两个隐含约定第一类别名就是子目录名ImageFolder会按目录名自动生成类别索引cat对应0、dog对应1第二每个子目录下的图片文件名不重要但格式要统一.jpg和.png混着放不会报错但可能在读取时因为通道数不一致png可能有透明通道带来维度错误。划分比例上我一般用train : val : test 7 : 2 : 1。数据量在2万张左右时这个比例够用如果数据只有几千张可以把验证集压到15%但测试集不要动它是你最终评估效果的唯一标准。不要在同一个文件里既做训练又做验证——这种“数据泄漏”会让你的准确率虚高答辩时一换数据就露馅。2.3 用ImageFolder还是自己写DataLoader一个评分项目的关键差别大部分开源项目用ImageFolder直接读取它简单、不容易出错。但如果你想在项目文档里写出“我对数据做了精细控制”那就需要自己写一个Dataset类。两者的差别在于ImageFolder只能做“按目录读取-返回图片和标签”这件事而自定义Dataset可以让你在数据层做更多判断。我见过很多课程设计项目用ImageFolder能跑但论文里写“我做了数据清洗”实际上什么清洗都没做。反过来一个自定义Dataset可以让你在读取时检查图片是否损坏过滤掉小于某个尺寸的图甚至可以做类别均衡采样。如果这是你的高分项目写一个自定义数据类并在文档里说明原因绝对比默认的ImageFolder更显功夫。自定义Dataset的骨架代码就几十行核心就是实现__len__和__getitem__两个方法。__getitem__里除了读图还承担了在线数据增强的职责——每读一次就做一次随机变换相当于让模型在训练时看到同一张图的不同版本这是缓解过拟合最有效的手段之一。3. 环境搭建与数据读取PyTorch下从零跑通最小训练链路3.1 环境准备conda虚拟环境、依赖安装与常见翻车点猫狗分类项目的技术栈选择PyTorch是更稳的选择。一是它的调试体验比TensorFlow直观print(tensor.shape)想看什么都能看二是torchvision里集成了图像数据集和预训练模型省去很多造轮子的时间。环境搭建我建议用conda创建独立虚拟环境避免把系统Python搞坏。python安装教程里最常见的问题就是有人在基础环境里直接pip install torch装到一半和系统包冲突最后整个环境都废了。conda create -n catdog python3.9 conda activate catdog pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install matplotlib scikit-learn如果机器没有NVIDIA显卡把最后一行换成CPU版本即可pip install torch torchvision安装完成后立刻验证CUDA是否可用这一步能提前暴露很多问题import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else CPU mode)torch.cuda.is_available()返回False的原因通常有两个一是装了CPU版的torch二是CUDA版本和显卡驱动不匹配。前者重装即可后者需要去NVIDIA官网确认显卡驱动支持的最高CUDA版本然后选对应的pytorch安装命令。第一次配环境的人经常在这里卡一整天我的建议是先用CPU版本把整个流程跑通再去追求GPU加速不要一上来就同时处理环境问题和代码问题。3.2 transforms.Compose缩放、裁剪、归一化每个参数为什么这么设数据读取之后必须经过预处理才能喂给模型。torchvision.transforms里最常用的是Compose它把多个变换按顺序串成一个管道。下面是一个标准配置训练集和验证集用的预处理不一样from torchvision import transforms # 训练集随机增强提升泛化能力 train_transforms transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ) ]) # 验证集/测试集固定尺寸不增强 val_transforms transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ) ])这里面的参数不是随便填的。Resize(256)先把短边拉到256像素RandomResizedCrop(224)在256x256的图上随机裁一个224x224的区域同时做了缩放和裁剪相当于每个epoch模型看到的都是同一张图的略不同版本。RandomHorizontalFlip是概率50%的水平翻转猫狗的左右对称性让它成为一个安全的增强手段。Normalize的四个参数是ImageNet数据集的统计值使用预训练模型时必须沿用这套数值否则模型输入的分布和它训练时不一致效果会明显下降。如果你用的是自己从零训练的CNN不加载预训练权重mean和std用[0.5, 0.5, 0.5]也说得过去但有预训练权重时一定不要乱改。还有一点常被忽略推理阶段的预处理必须和训练阶段保持一致我见过好几个项目训练时用RandomResizedCrop测试时也用了随机裁剪导致同一张图每次预测结果都不同——这属于写进代码里的逻辑错误。3.3 训练集与验证集划分脚本这才是评判项目完整度的地方很多人拿到数据直接开训训完才发现验证集和训练集有重叠图片模型见过“考题”导致验证准确率虚高。正确做法是写一个划分脚本一次性完成数据洗牌、按比例复制到不同目录、打印各类别数量。这一步是项目文档里最值得写的部分因为它体现了你对“数据泄漏”这个概念的理解。import os import random import shutil source_dir raw_data # 原始数据集路径 output_dir data # 划分后的根目录 split_ratio (0.7, 0.2, 0.1) random.seed(42) for class_name in [cat, dog]: class_path os.path.join(source_dir, class_name) images [f for f in os.listdir(class_path) if f.endswith(.jpg)] random.shuffle(images) n_train int(len(images) * split_ratio[0]) n_val int(len(images) * split_ratio[1]) splits { train: images[:n_train], val: images[n_train:n_train n_val], test: images[n_train n_val:] } for split_name, split_images in splits.items(): target_dir os.path.join(output_dir, split_name, class_name) os.makedirs(target_dir, exist_okTrue) for img in split_images: shutil.copy( os.path.join(class_path, img), os.path.join(target_dir, img) ) print(f{class_name} {split_name}: {len(split_images)} images)脚本的逻辑很简单对每个类别分别洗牌按比例切三段复制到新目录。两个细节必须注意。第一是random.seed(42)固定随机种子才能保证每次运行结果一致否则别人复现你的实验时数据划分不同效果就对不上。第二是shutil.copy不会删除原文件重复运行脚本会向目标目录追加图片而不是覆盖建议划分前先检查目标目录是否为空。类别不平衡的情况也要在划分后打印出来确认比如猫有10000张、狗有8000张7:2:1划分后train/cat是7000张、train/dog是5600张类别比例还算接近可以直接训但如果一个类别只有另一个的十分之一就必须考虑重采样或者类别加权损失函数了。4. 模型与训练参数搭建CNN网络并把它练到90%以上的完整配置4.1 从零搭一个小型CNNconv-bn-relu-pool的标准结构理解CNN最好的方式是亲手搭一个小的。下面这个网络只有四组卷积块加一个全连接层参数量不到500万在CPU上也能在半小时内完成一次训练。它足够胜任猫狗二分类也足够让你看清楚每一层的输入输出形状变化。import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes2): super().__init__() self.features nn.Sequential( # Block 1: 3x224x224 - 16x112x112 nn.Conv2d(3, 16, kernel_size3, padding1), nn.BatchNorm2d(16), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2), # Block 2: 16x112x112 - 32x56x56 nn.Conv2d(16, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2), # Block 3: 32x56x56 - 64x28x28 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2), # Block 4: 64x28x28 - 128x14x14 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 14 * 14, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): x self.features(x) x self.classifier(x) return x每池化一次特征图的宽高减半通道数翻倍这是CNN设计的经典模式。padding1配合kernel_size3保持宽高不变可计算性更强。BatchNorm2d放在卷积和激活之间它做的事情是让每一层的输入分布稳定下来。没有BatchNorm的网络需要手动调低学习率才能稳定训练加了之后收敛速度会明显加快。Dropout(0.5)是全连接层专用的防过拟合手段训练时随机丢弃一半神经元推理时会自动补偿不需要你在forward里做额外处理。4.2 用ResNet18迁移学习更高精度的捷径与改造最后一层自己搭的CNN能到90%左右再往上就要增大模型或加数据性价比最高的方案是换迁移学习。用ImageNet预训练的ResNet18作为特征提取器只替换最后的全连接层输出维度。代码极其简洁import torchvision.models as models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) # 把最后一层全连接改成2输出 in_features model.fc.in_features model.fc nn.Linear(in_features, 2)model.fc.in_features是ResNet18分类头的输入维度值为512换成自己的Linear层时维度必须对齐。冻结特征层是一个可选的加速手段for param in model.parameters(): param.requires_grad False for param in model.fc.parameters(): param.requires_grad True冻结之后反向传播只更新全连接层显存占用大幅下降训练速度也快得多。但代价是精度上限低因为卷积层的特征是为ImageNet的1000类设计的猫狗虽然也在其中但迁移过来时底层特征不一定最优。我的建议是分两阶段先冻结特征层只训练融合层跑10个epoch然后解冻全部层用很小的学习率比如1e-5再微调10个epoch。这比直接全量微调更容易收敛效果也普遍更好。4.3 训练循环与超参数表学习率、batch_size、epochs的合理范围训练代码的核心是一个循环这里给出一段可用的最小实现import torch import torch.optim as optim from torch.optim.lr_scheduler import ReduceLROnPlateau device torch.device(cuda if torch.cuda.is_available() else cpu) model SimpleCNN(num_classes2).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) scheduler ReduceLROnPlateau(optimizer, modemin, factor0.5, patience2) num_epochs 15 best_val_acc 0.0 for epoch in range(num_epochs): 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() avg_loss running_loss / len(train_loader) # 验证 model.eval() correct 0 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) total labels.size(0) correct (predicted labels).sum().item() val_acc correct / total print(fEpoch {epoch1}/{num_epochs} | Loss: {avg_loss:.4f} | Val Acc: {val_acc:.4f}) if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), best_model.pth)这段代码的关键点有三个。第一是optimizer.zero_grad()必须每轮调用否则梯度会累积loss表现为剧烈震荡。第二是训练和验证模式切换model.train()和model.eval()影响的是BatchNorm和Dropout的行为忘了切回train()会导致验证后继续训练的效果异常。第三是torch.no_grad()包裹验证过程不计算梯度省显存也提速。关于超参数直接给一张我常用的参考表参数取值范围说明学习率0.0001 ~ 0.001Adam下1e-3起步迁移学习微调用1e-5batch_size32 ~ 648G显存上限64更大显存可到128epochs10 ~ 20小数据集10轮足够看验证集早停优化器Adam / SGDSGD需要更大epochs但泛化更好损失函数CrossEntropyLoss二分类多分类通用内部含softmax输入尺寸224x224兼顾细节与显存占用早停策略是必须写的每轮验证后记录最佳验证准确率连续3个epoch不提升就降低学习率连续5个不提升就停止训练。这条规则能保证你不会在训练后期白白浪费算力还会让模型的“final”版本恰好停在最优位置而不是训练循环的最后一步。5. 踩坑记录CNN训练中的5个高频问题与排查方法5.1 训练acc在50%附近震荡数据顺序与归一化的问题现象loss一直在0.69左右训练准确率始终在50%不管怎么加大epochs都上不去。原因0.69这个数值很典型它是二分类交叉熵在均匀概率下的理论值说明模型完全没有在学习。最常见的原因是标签错位——读取数据时猫和狗的索引顺序和预期不符或者ToTensor()之前忘了Normalize导致输入数值范围异常。另一个隐蔽的原因是DataLoader里忘了设置shuffleTrue模型每个epoch看到的样本顺序完全一样容易陷入糟糕的局部最优。解决先打印一个batch的数据确认维度——images.shape应为(batch_size, 3, 224, 224)labels.shape应为(batch_size,)。再打印labels.unique()确认两类标签都在。如果都正常把学习率调低十倍重试。最后确认Normalize里的mean/std没有用错位置。5.2 验证acc远低于训练acc过拟合的典型信号与对策现象训练集准确率98%验证集只有78%并且访问量越高差距越大。原因这是过拟合也就是模型把训练集的细节特征背下来了而没有学到泛化规律。小数据集、高模型容量、缺乏数据增强三个条件里中了两个必然过拟合。ResNet18不冻结直接全量训练时最容易出现这个问题。解决第一优先级是加数据增强RandomResizedCrop、RandomHorizontalFlip是最低成本的组合。第二是加Dropout如果你用的是迁移学习模型在全连接层前插入nn.Dropout(0.3)。第三是换用SGD优化器并配上较小的batch_sizeSGD加Momentum的泛化能力普遍优于Adam这是经验之谈。如果以上都没用回到数据量本身考虑用已有的图片做离线增强旋转90度、左右翻转、调亮度或者收集更多数据。5.3 CUDA out of memory小显存机器怎么跑大图现象开始训练第一个epoch程序报错CUDA out of memory显存占用瞬间打满。原因224x224的输入加上batch_size 64在6GB显存上练ResNet18很容易爆掉。更常见的误操作是DataLoader的num_workers设得过大每个worker都会拷贝一份数据到显存多个worker同时开跑直接撑爆。解决按优先级操作。先把batch_size降到16或8。然后改输入尺寸把Resize从256降到192甚至可以用160精度损失在1个百分点以内。第三步是检查代码里有没有把验证集或测试集也放到了GPU上——验证不需要梯度把torch.no_grad()加对位置能省出约三分之一的显存。最后确认后即删除了显存缓存的变量或者用del和torch.cuda.empty_cache()手动释放。如果是自己的机器直接换更大显存的卡是最终解但调参技巧本身也是项目文档的素材。5.4 加载预训练权重报size mismatch原因与正确的加载方式现象model.load_state_dict(state_dict)报size mismatch for fc.weight。原因预训练模型的fc层是1000输出你改成2输出后参数形状对不上。这是正常的最多余的错误不是bug而是预期中的不匹配。解决加载时忽略不匹配的键ckpt torch.load(resnet18_weights.pth) filtered {k: v for k, v in ckpt.items() if fc not in k} model.load_state_dict(filtered, strictFalse)strictFalse会跳过缺失的键但你需要在加载后确认fc层已正确初始化为2输出。用strictFalse的代价是如果模型和预训练权重结构存在其他差异也会被静默跳过所以加载后务必打印model.fc确认参数形状。5.5 自己找的图片全部被分到同一类预处理不一致与类别先验现象训练时验证集准确率很高但拿网上下载的猫图去预测全部输出“狗”。原因大概率是你的分类模型学到的不是猫和狗的差异而是训练集里两类图片的背景差异或其他混淆特征。另一个常见原因是推理时忘了做和训练时一致的预处理尤其是Normalize直接加载一张PIL图片丢进模型数值范围在0-255模型完全懵。解决推理代码必须复用val_transforms不要另写一套。同时检查数据集的单方面特性比如训练集中猫的图片普遍是室内背景、狗的图片普遍是草地——模型学到的是背景区分而不是动物区分这种情况需要重新清洗数据。快速验证方法是把验证集里错误分类的图片可视化出来看模型到底在关注什么区域。6. 模型验证与导出用混淆矩阵和真实图片确认效果6.1 分类报告与混淆矩阵怎么看模型真正的问题准确率只能告诉你整体表现但猫和狗各自的查准率、查全率才是你发现问题的窗口。用scikit-learn一句话就能输出全部指标from sklearn.metrics import classification_report, confusion_matrix import numpy as np y_true [] y_pred [] model.eval() with torch.no_grad(): for images, labels in val_loader: images images.to(device) outputs model(images) _, predicted torch.max(outputs, 1) y_true.extend(labels.cpu().numpy()) y_pred.extend(predicted.cpu().numpy()) print(classification_report(y_true, y_pred, target_names[cat, dog])) print(confusion_matrix(y_true, y_pred))classification_report输出的recall表示“所有猫里面有多少被正确识别了”precision表示“被识别为猫的里面有多少真的是猫”。如果狗的precision高但recall低说明模型倾向于把狗识别成猫也就是保守——宁可将狗判为猫也不错杀猫。这种倾向可能来自数据类别不平衡也可能是模型学到的是“有圆耳朵就是猫”。混淆矩阵是4个数字的事情但它是答辩时最直观的展示材料。把混淆矩阵做成热力图配上一句“误分类集中在长毛猫和短毛狗之间这类样本本身视觉差异小”比贴十行训练日志更有说服力。6.2 单张图片推理代码把模型用起来的最后一步训练完成后的落地动作是写一个推理脚本它接收任意一张图片输出预测类别和置信度from PIL import Image import torch import torchvision.transforms as transforms def predict_image(img_path, model, device): model.eval() img Image.open(img_path).convert(RGB) img_tensor val_transforms(img).unsqueeze(0).to(device) with torch.no_grad(): outputs model(img_tensor) probabilities torch.softmax(outputs, dim1) confidence, predicted torch.max(probabilities, 1) class_names [cat, dog] result class_names[predicted.item()] conf confidence.item() return result, conf # 使用 model SimpleCNN(num_classes2).to(device) model.load_state_dict(torch.load(best_model.pth)) result, conf predict_image(test_image.jpg, model, device) print(f预测: {result}, 置信度: {conf:.4f})unsqueeze(0)把形状从(3, 224, 224)变成(1, 3, 224, 224)补充batch维度。torch.softmax拿到各类别的概率分布torch.max返回最大值和索引——索引映射到类别名。注意Image.open之后要加.convert(RGB)否则遇到带透明通道的PNG图或灰度图通道数不一致会导致预处理维度错误。6.3 保存与加载训练好的模型避免重训的流程训练完成后模型权重必须落盘。两种保存方式差别很大# 方式一只保存权重推荐 torch.save(model.state_dict(), catdog_best.pth) model SimpleCNN(num_classes2) model.load_state_dict(torch.load(catdog_best.pth)) # 方式二保存整个模型不推荐 torch.save(model, catdog_best_full.pth) model torch.load(catdog_best_full.pth)只保存权重的方式要求加载时先实例化一个结构完全相同的模型再通过load_state_dict填入权重。这种方式跨环境迁移更稳定模型结构变了也不会加载失败。保存整个模型的方式在路径迁移时经常因为类名或模块路径不一致而报错我吃过一次亏后就没再用过。正确落盘策略是同时保存权重和训练中间信息torch.save({ model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), best_val_acc: best_val_acc, epoch: epoch 1 }, catdog_checkpoint.pth)checkpoint方案多一个好处如果训练意外中断你可以从断点恢复不需要从头再来。我把这个习惯保留到了所有训练任务里因为没人保证训练过程中不出现断电、死机或者一个人手抖关掉了终端。每5个epoch存一个checkpoint是我用血泪经验换来的习惯。做这类项目我一直坚持一条原则模型能跑通只是底线能解释每一行代码为什么这么写才是高分。数据集怎么划分、预处理参数怎么选、为什么用这个优化器、踩过什么坑这些才是一个项目文档真正有价值的地方。这套猫狗分类的方案从数据到模型到验证已经完整跑通希望帮到你。本文还有配套的精品资源点击获取