简介:这份资源是一套基于PyTorch搭建猫狗公鸡三分类卷积神经网络的完整项目包,适合已了解深度学习基础、希望上手PyTorch实战的初学者。内容覆盖数据预处理、模型结构设计、损失函数与优化器选择、训练验证、模型保存加载以及可视化等关键环节,可帮助读者系统掌握图像分类任务的完整流程。资源共1390个文件,以1362张jpg训练图像为主,另有11个Python脚本、模型权重和onnx导出文件、XML标注及说明文档等,压缩包整体约554.92MB。Python脚本对应各个实现步骤,图片数据可直接用于训练,方便对照代码理解CNN的特征提取与分类逻辑。目前已有1379人学习下载,适合用于课程设计、入门练习或作为后续迁移学习的基础模板。通过动手实践该项目,读者能熟悉DataLoader使用、nn.Module自定义网络、CrossEntropyLoss训练流程等核心操作,并获得可直接运行的模型文件与可视化分析思路,为更复杂的深度学习项目打下扎实基础。
1. 用 PyTorch 从零搭一个猫狗公鸡图片分类网络:先把资源里有什么说清楚
这个项目标题看起来像教程,但实际下载下来是一个可以直接跑通的 PyTorch 图片分类网络工程。资源里没有给你完整数据集,而是带了几张典型的测试图片,比如104_dog.jpg、355_cock.jpg、test1.jpeg,对应猫、狗、公鸡三个类别。也就是说,这份资源适合两类人:一是刚学完 PyTorch 基础、想拿一个真实能训练的 CNN 工程练手的初学者,二是想在已有模型上快速做推理验证、不想从零写数据管线的从业者。它能解决的核心问题是:从 1200 张训练图片出发,怎么走完数据预处理、模型搭建、训练验证、保存加载、单张图片预测这条完整链路,而不是停留在 mnist 那种玩具例子上。下面我按实际拆这个工程的顺序,把每一步的参数和踩坑点都铺开讲。
2. 数据预处理:从 1200 张原始图片到 DataLoader 的完整链路
2.1 图像数据进入 CNN 前的标准形态:从 HWC 到 NCHW
PyTorch 处理图像和 OpenCV 有一个典型差异,OpenCV 读出来是 HWC 排列,也就是高、宽、通道,通道在最后,而且默认是 BGR 顺序;而 PyTorch 的 Conv2d 接收的输入是 NCHW,即 batch、通道、高、宽,通道在第二位。这个差异是初学者第一个翻车点。
项目中 1200 张训练图片是散落在文件夹里的,文件名没有严格的类别前缀,所以工程里通常靠子目录划分类别,这也是 ImageFolder 能直接用的前提。整理后的目录结构应该是:
data/ ├── train/ │ ├── cat/ │ ├── dog/ │ └── cock/ └── val/ ├── cat/ ├── dog/ └── cock/如果你的图片不是按这个结构组织的,就需要先把文件名映射到类别,常见做法是维护一个 CSV 映射表,或者直接按文件名前缀批量移动到对应目录。我在处理这份资源时,是把104_dog.jpg这类带_dog后缀的文件名自动归类的:
import os import shutil src_dir = "raw_images" train_dir = "data/train" val_dir = "data/val" os.makedirs(train_dir, exist_ok=True) os.makedirs(val_dir, exist_ok=True) categories = ["cat", "dog", "cock"] for f in os.listdir(src_dir): if not f.endswith(".jpg"): continue for c in categories: if f"_{c}." in f: dest = os.path.join(train_dir, c, f) shutil.copy(os.path.join(src_dir, f), dest) break这段代码的意图是把所有带_dog、_cat、_cock后缀的文件按类别复制到训练目录。注意这里用的是copy而不是move,备份原始文件,防止后续误删。实际项目中你应该按 8:2 或 9:1 划分训练集和验证集,我建议在复制时用随机数决定去向,而不是手动挑文件。
2.2 预处理管线参数:Resize、Normalize 和数据增强的选型
图片进入模型之前,必须统一尺寸、统一数值范围、统一通道顺序。这个小工程里,输入分辨率一般设为 224×224,这是 ImageNet 预训练模型的标准输入,如果你后续要换 ResNet 或 VGG 做迁移学习,224 可以直接对齐。
预处理管线我用torchvision.transforms组织,下面是实际可用的配置:
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(15), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])Resize((224, 224))直接把图硬拉到正方形,简单粗暴但对小数据集够用;RandomHorizontalFlip以 0.5 概率水平翻转,对猫狗公鸡这类分类任务几乎没有副作用,因为左右翻转不会改变类别语义;RandomRotation(15)旋转 15 度以内,增强模型对轻微姿态变化的鲁棒性。验证集不做增强,只做 Resize 和 Normalize,保证评估时输入是确定性的。
Normalize 的 mean 和 std 用的是 ImageNet 统计值,这是最常见的做法。如果你用的是自己训练的模型而不是预训练权重,更严谨的做法是先统计你数据集的均值和方差再填入,但工程上直接沿用这三个数也能收敛,只是初始 loss 会偏高一点。参数含义是:(x - mean) / std,让每个通道的像素值大致落在 -1 到 1 之间,这对梯度稳定性有帮助。
2.3 Dataset 与 DataLoader:1200 张图怎么喂给训练循环
数据预处理管线定义好后,需要包装成 Dataset 并用 DataLoader 加载。这份资源没有提供完整 Dataset 类,但工程里最常见的做法是用torchvision.datasets.ImageFolder,它要求目录结构严格按类别分文件夹:
from torchvision import datasets from torch.utils.data import DataLoader batch_size = 32 num_workers = 4 train_dataset = datasets.ImageFolder(root="data/train", transform=train_transform) val_dataset = datasets.ImageFolder(root="data/val", transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, num_workers=num_workers) val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False, num_workers=num_workers) print(f"训练集类别映射: {train_dataset.class_to_idx}") print(f"训练集样本数: {len(train_dataset)}")batch_size=32是 CPU 和低端 GPU 都能接受的折中值,显存紧张就降到 16 或 8。shuffle=True只在训练集开,验证集不开,保证验证时每个 batch 的顺序固定,方便对比不同 epoch 的表现。num_workers=4用多进程加载图片,能显著缓解 CPU 预处理和 GPU 训练速度不匹配的问题,但如果你的机器内存紧张,降到 2 更稳妥。
class_to_idx输出类似{'cat': 0, 'cock': 1, 'dog': 2},这个映射在推理阶段必须记住,因为模型输出的是索引而不是字符串类别名。很多人在加载模型做预测时发现输出数字对不上实际类别,就是没有保存这个映射关系。
3. 模型搭建与训练:Conv2d 堆叠、损失函数和优化器的配合
3.1 nn.Module 子类化:定义一份能跑起来的 CNN 架构
资源正文明确提出用nn.Module定义自定义网络结构。对于猫狗公鸡三分类任务,不需要特别深的网络,我自己惯用的结构是三层卷积加两层全连接,参数量适中,在 CPU 上也能在可接受时间内完成训练:
import torch.nn as nn class CatDogCockCNN(nn.Module): def __init__(self, num_classes=3): super(CatDogCockCNN, self).__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 28 * 28, 256), nn.ReLU(inplace=True), nn.Dropout(p=0.5), nn.Linear(256, num_classes) ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x输入是 224×224 的 RGB 图,通过Conv2d(3, 32, kernel_size=3, padding=1)后尺寸不变,通道从 3 扩到 32;MaxPool2d(kernel_size=2, stride=2)把长宽各缩一半,从 224 变 112,再经过两次同样的卷积池化组合变成 28×28、128 个通道。self.classifier里的nn.Flatten()把 128×28×28 展平成向量,这是进入全连接层前必做的一步,很多人漏掉这一步会直接报维度错误。Dropout(p=0.5)在全连接层之前随机丢弃一半神经元,是防止过拟合的重要开关。
3.2 CrossEntropyLoss 与 Adam:多分类任务的标准组合
损失函数和优化器的选择直接影响训练曲线走向。三分类任务用nn.CrossEntropyLoss()是标准答案,它内部把 LogSoftmax 和 NLLLoss 合并了,所以网络最后一层不需要手动加 Softmax:
import torch.optim as optim device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = CatDogCockCNN(num_classes=3).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4)CrossEntropyLoss有两个值得注意的点。第一,它期望的输入是原始 logits,不是概率值,所以在模型定义里最后一层Linear后面不加Softmax;第二,如果类别不平衡,你可以在初始化时传weight参数,给样本少的类别更高的惩罚权重。这个工程里猫狗公鸡三类图片数量大致均衡,不传也行。
优化器我选 Adam 而不是 SGD,理由是小数据集上 Adam 对学习率不敏感,默认lr=0.001基本能跑出可接受的结果,省去手工调学习率的精力。weight_decay=1e-4是 L2 正则化,通过对大权重加惩罚抑制过拟合,它的值不是越大越好,超过 1e-2 可能会导致欠拟合。如果你想用 SGD 替代,常见的配置是optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=1e-4),但要把学习率调小到 0.001 左右才能和 Adam 的收敛速度相当。
3.3 训练循环:三个关键细节决定 loss 能不能降下去
训练循环是整份资源的核心,也是最容易出现隐性 bug 的地方。下面是完整可运行的训练脚本骨架:
from tqdm import tqdm num_epochs = 20 best_val_acc = 0.0 for epoch in range(num_epochs): model.train() running_loss = 0.0 correct = 0 total = 0 for inputs, labels in tqdm(train_loader, desc=f"Epoch {epoch+1}/{num_epochs}"): 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_acc = correct / total train_loss = running_loss / 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) loss = criterion(outputs, labels) _, predicted = torch.max(outputs, 1) val_total += labels.size(0) val_correct += (predicted == labels).sum().item() val_acc = val_correct / val_total print(f"Epoch {epoch+1}: Train Acc {train_acc:.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 震荡。初学者最常犯的错误是把zero_grad()放在loss.backward()之后,那相当于每一轮都在用上一次的梯度叠加更新。
第二,model.train()和model.eval()必须成对出现。Dropout在训练时随机丢弃神经元,在验证时应该关闭;BatchNorm在训练时用当前 batch 的统计量,在验证时用全局统计量。忘记切换模式,验证集准确率会忽高忽低,这种问题特别隐蔽。注意这个网络用的是 ReLU 而没加 BatchNorm,但保留train/eval切换仍然是好习惯。
第三,计算准确率用torch.max(outputs, 1)拿到每个样本预测的类别索引,然后和labels逐元素比较。torch.max返回两个值,第一个是最大值本身,第二个是最大值对应的索引,取索引才是分类结果。如果你只取第一项,准确率会永远是 0。
4. 常见问题与踩坑排查:图像分类训练中高频翻车的四个点
4.1 图片数量不一致导致 batch 加载失败
现象:训练刚开始或中途报错RuntimeError: stack expects each tensor to be equal size,有的 DataLoader 直接卡住不迭代。
原因:数据集中存在尺寸异常或损坏的图片,比如某张图是灰度图只有单通道,有的图是 RGBA 四通道,Resize 之后 tensor 形状不一致,Dataloader 无法把它们 stack 成一个 batch。
解决:在预处理管线里强制转换通道,给 Dataset 加载前加一道检查。常见做法是自己写一个过滤函数,把非 RGB 图片剔除或转换:
from PIL import Image def verify_image(path): try: img = Image.open(path) img = img.convert("RGB") img.load() return True except Exception: return False valid_paths = [p for p in all_paths if verify_image(p)]这段代码的作用是逐张验证图片能否被 Pillow 正常打开并转换为 RGB。img.convert("RGB")会把灰度图和 RGBA 图统一转为三通道,避免通道数不一致的报错。img.load()强制加载像素数据,这一步能识别出那些文件头正常但数据损坏的半截图片。我的习惯是在首次跑训练前先全量扫一遍数据,把坏图单独移到corrupted/目录,而不是直接在原目录删除,方便溯源。
4.2 维度错误:Channel 位置不对导致 Conv2d 输入崩溃
现象:模型前向传播时报RuntimeError: Expected 4-dimensional input for 4-dimensional weight [32, 3, 3, 3], but got 3-dimensional input之类的错误。
原因:transforms.ToTensor()会把 PIL Image 从 HWC 转成 CHW,如果你的图片是通过 cv2.imread 读取的 numpy 数组,它保持 HWC 且是 BGR 顺序,直接喂给模型就会维度错乱。更隐蔽的情况是有些 Dataset 类返回的图片是灰度图,只有 2 维,没有通道维。
解决:统一在 transforms 里做转换,不要让数据以 numpy 形式绕过管线。如果确实需要手动处理,用torch.from_numpy(img).permute(2, 0, 1)把 HWC 转为 CHW,再用.float()归一化。从项目正文看,图片都是 jpg 彩色图,用 ImageFolder 搭配 ToTensor 不会出现这个问题,但如果你自己写 Dataset 类就要格外注意。
4.3 Adam 和 SGD 选择不当导致 loss 不下降
现象:loss 在初始值附近震荡,或者经过 10 个 epoch 后准确率始终在 40% 左右,和随机猜测差不多。
原因:学习率设置不合理。Adam 虽然对学习率不敏感,但lr=0.01仍然偏大,会导致 loss 剧烈震荡;SGD 如果不用 momentum,收敛极慢,在小数据集上很容易看起来像没在训练。
解决:先用 Adam 配lr=0.001跑 10 个 epoch 观察趋势,如果 loss 平稳下降就不动;如果下降过慢,可以尝试lr=0.005;如果 loss 上升或震荡,降到0.0001。我自己的排查顺序是:先确认 loss 有没有下降趋势,再看验证集准确率有没有跟随训练集上升。如果训练集准确率很高但验证集低,那问题不在优化器,而是过拟合,要加数据增强或 Dropout。
4.4 训练准确率接近 100%、验证集准确率大幅掉点
现象:训练集准确率 98%,验证集只有 60% 左右,而且每个 epoch 的验证集准确率波动很大。
原因:模型把训练集的细节特征记住了,没有学到泛化的分类模式。1200 张训练图属于中小规模数据集,三层卷积网络参数足够多,完全可以把训练集背下来。
解决:从三个方向同时入手。第一,增加数据增强强度,比如把RandomRotation从 15 度提高到 20 度,加上RandomResizedCrop随机裁剪一部分再 Resize,迫使模型学习到更鲁棒的特征;第二,提高Dropout比例到 0.6 或 0.7;第三,引入torchvision.models.resnet18做迁移学习,用 ImageNet 预训练权重而不是从头训练。迁移学习的做法是替换最后一层全连接:
import torchvision.models as models model = models.resnet18(pretrained=True) model.fc = nn.Linear(model.fc.in_features, 3)注意pretrained=True会下载权重,如果网络不稳定要提前准备好。model.fc.in_features自动读取原全连接层的输入维度,ResNet18 是 512,替换成输出 3 的全连接层即可。如果数据集更小,建议把前面的卷积层冻结,只训练全连接层,降低过拟合风险。
5. 模型保存、加载与推理:从 .pth 到 CPU 上的单张图片预测
5.1 保存 checkpoint 的两种方式和推荐做法
训练完成后模型的参数需要持久化。PyTorch 有两种常见保存方式,区别很重要。
第一种是保存整个模型对象:
torch.save(model, "model_full.pth")第二种是只保存状态字典:
torch.save(model.state_dict(), "model_state.pth")我强烈推荐第二种。torch.save(model)会把模型类定义一并序列化,加载时必须保证类定义在可导入的模块里,换个环境或改个类名就报错。state_dict只保存参数张量,加载时先实例化模型结构再填参数,灵活得多。如果你还想要训练过程中的附加信息,可以打包成一个字典:
torch.save({ "model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "epoch": epoch + 1, "best_val_acc": best_val_acc, "class_to_idx": train_dataset.class_to_idx }, "checkpoint.pth")这份 checkpooint 里同时存了优化器状态和类别映射,续训练和推理都不需要再找原始数据。注意torchvision新版可能不再直接提供pretrained=True参数,改用weights="DEFAULT",如果你本机装的是 0.13 以上版本,迁移学习那行要改成models.resnet18(weights=models.ResNet18_Weights.DEFAULT),否则会告警。
5.2 加载模型并在单张图片上做推理
资源附带的test1.jpeg和104_dog.jpg等文件就是拿来验证推理流程的。加载模型做预测的完整脚本如下:
import torch from PIL import Image from torchvision import transforms device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = CatDogCockCNN(num_classes=3) model.load_state_dict(torch.load("best_model.pth", map_location=device)) model.to(device) model.eval() infer_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) image_path = "test1.jpeg" image = Image.open(image_path).convert("RGB") input_tensor = infer_transform(image).unsqueeze(0).to(device) with torch.no_grad(): output = model(input_tensor) _, predicted_idx = torch.max(output, 1) class_names = ["cat", "cock", "dog"] print(f"预测结果: {class_names[predicted_idx.item()]}")推理阶段有几个细节容易踩。map_location=device解决的是训练时用 GPU、推理时用 CPU 的权重映射问题,不加这个参数,在纯 CPU 机器上加载 GPU 训练的权重会报错或卡住。unsqueeze(0)给单张图片补一个 batch 维度,因为模型期望的输入是四维 NCHW。torch.no_grad()在推理阶段强制不计算梯度,能减少内存占用,提升速度。model.eval()必须调用,否则 Dropout 仍然生效,同一张图每次预测的置信度都不一样,这是推理结果不稳定的隐藏原因。
5.3 TensorBoard 可视化与混淆矩阵:验证模型是否真的学会了
训练曲线只能看到整体趋势,想知道模型具体把哪些类混淆了,需要画混淆矩阵。这个工程是猫狗公鸡三分类,混淆矩阵是 3×3 的表格,行是真实类别,列是预测类别,对角线越亮说明分类越准。
我通常直接在验证集上跑一轮,收集预测结果和真实标签:
from sklearn.metrics import confusion_matrix import numpy as np all_preds = [] all_labels = [] model.eval() with torch.no_grad(): for inputs, labels in val_loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) cm = confusion_matrix(all_labels, all_preds) print(cm)如果cm[0][1]的值明显偏高,说明模型经常把猫误判成狗。有一种有效补救:收集分类错误的样本路径,单独建一个 hard examples 目录,在下一轮训练时对这些样本做额外的数据增强或过采样。这个方法比随机增强更有针对性,在小数据集上效果很直接。
TensorBoard 的接入也不复杂,PyTorch 提供了torch.utils.tensorboard.SummaryWriter,只需要在训练循环里定期写入 loss 和准确率。我自己的习惯是每个 epoch 结束时把训练集 loss、验证集准确率、学习率三个标量写进去,训练结束后看曲线就能判断该不该提前终止。
以前我总跳过可视化这一步,认为有训练日志就够了。直到有一次模型验证集准确率看起来不错,但混淆矩阵显示公鸡几乎全被认成狗,才知道日志上的数字会骗人。从那以后我每次训练完,都强制走一遍混淆矩阵加曲线回放,确认每一类都没有塌方才敢把模型拿去用。希望这篇拆解能帮你在做自己的猫狗公鸡分类时,少走几个我走过的弯路。
本文还有配套的精品资源,点击获取