简介:面向有一定深度学习基础、希望上手PyTorch与CNN图像分类的初学者,这份猫狗公鸡三分类实战资源,完整覆盖图像数据预处理、CNN网络搭建、损失函数与优化器选择、训练验证、模型保存加载以及结果可视化等关键步骤,帮助读者建立从理论到实践的完整认知。压缩包共1390个文件,大小约554.92MB,其中以1362张jpg图片为主体,另有11个py训练脚本、onnx模型文件、xml配置文件及txt说明文档;数据集已划分训练集与验证集,并附有测试图片与预测示例,图片与代码一一对应,便于按步骤理解并直接运行复现。已有1378人学习下载,项目内容紧凑,既适合课堂教学演示,也适合个人动手实践;通过亲手实现完整流程,读者可快速掌握PyTorch中数据加载、模型构建、训练调参和模型持久化等核心操作,同时学会用TensorBoard等工具监控训练过程,为课程设计、竞赛准备或后续更复杂的深度学习项目打下扎实基础。
1. 用PyTorch做猫狗公鸡图片分类:先把“能跑通”放在“模型新”前面
很多第一次接触图片分类的人,会把注意力全放在模型结构上,等真正动手才发现,最花时间的是数据读取、尺寸统一和训练循环里的一堆小坑。猫狗公鸡三类图片分类是一个特别合适的最小闭环:类别数量少,难度可控,又足够让你把 PyTorch 的数据管道、卷积特征提取、迁移学习、训练评估完整走一遍。这篇文章不追求刷竞赛精度,而是用 PyTorch 搭一个你自己能复现、能改、能保存、能拿到单张图上做预测的分类网络。适合刚入门卷积神经网络的人,也适合从 TensorFlow 转过来想快速上手 PyTorch 的工程师,跟着做到最后,你会拿到一套可以直接套到其他图片分类任务上的骨架代码。
2. 用 PyTorch 准备猫狗公鸡图片分类数据:ImageFolder 与 DataLoader
2.1 把文件夹结构当成 PyTorch 图片分类网络的第一行代码
在 PyTorch 里做有监督图片分类,最常见的数据组织方式就是按类别建文件夹,每个文件夹里放对应类别的图片。torchvision.datasets.ImageFolder会自动按照文件夹名生成类别标签,不需要自己写 CSV 或解析 JSON。对猫狗公鸡这个任务来说,目录结构一般长这样:
data/ ├── train/ │ ├── cat/ │ ├── dog/ │ └── rooster/ └── val/ ├── cat/ ├── dog/ └── rooster/如果你手头的图片还散落在各个地方,先建目录再拷贝文件,可以用一条命令完成。在 Linux 或 macOS 下,我一般这样建:
mkdir -p data/train/{cat,dog,rooster} data/val/{cat,dog,rooster}这条命令使用了 bash 的花括号展开,分别创建train和val下的猫、狗、公鸡三个子目录。训练集和验证集分开,是为了防止模型“背答案”:如果所有图片都放在一起随机划分,同一次拍摄的连拍帧可能同时出现在训练和验证里,评估结果会虚高。实际使用时,你可以把 80% 的图片放入train,20% 放入val。
2.2 Resize、Tensor 和 Normalize:用 torchvision 的 transform 统一输入
图片不能直接喂给卷积层,PyTorch 期望的输入是[B, C, H, W]的张量,数值范围也要尽量落在 0 附近。torchvision.transforms提供了现成的组合工具。下面这段代码定义了一个基础预处理流程:
from torchvision import datasets, transforms train_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]) ]) 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]) ]) train_dataset = datasets.ImageFolder(root='data/train', transform=train_transform) val_dataset = datasets.ImageFolder(root='data/val', transform=val_transform) print(train_dataset.classes) # ['cat', 'dog', 'rooster'] print(train_dataset.class_to_idx) # {'cat': 0, 'dog': 1, 'rooster': 2}这里有几个容易忽略的点。Resize((224, 224))直接把所有图片强制拉伸成正方形,如果你的原图宽高比差异很大,会引入轻微形变,但在这个任务里影响不大。更稳妥的做法是Resize(256)后接CenterCrop(224),保留更多原始信息。ToTensor()会把HWC的PIL Image转成CHW的张量,同时把像素值从 0 到 255 缩放到 0 到 1。Normalize使用 ImageNet 的均值和标准差,这个参数不是随便写的,后面如果加载 ResNet 预训练权重,必须保持同样的数值范围,否则网络看到的分布和训练时不一致。
ImageFolder会按文件夹名排序,所以顺序是cat、dog、rooster,对应的索引是 0、1、2。打印class_to_idx能帮你确认标签映射,混淆矩阵画错常常就是这里出了偏差。
2.3 数据增强:用 RandomResizedCrop 和 RandomHorizontalFlip 扩大猫狗公鸡样本量
如果你的猫狗公鸡图片总共只有几百张,不做增强的话,训练集很容易过拟合。常见的做法是在训练集 transform 中加入随机变换,而验证集只做固定缩放和中心裁剪。下面是一组效果稳定、不容易把图片破坏到无法识别的增强策略:
train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])RandomResizedCrop会随机裁剪一部分区域再缩放到 224 像素,scale=(0.8, 1.0)表示保留原图 80% 到 100% 的面积,这样模拟了目标在画面中大小不一的情况。RandomHorizontalFlip对猫和狗这种姿态不完全对称的物体也基本安全,公鸡的鸡冠和身体左右翻转后依然是公鸡。ColorJitter用来扰动亮度、对比度和饱和度,可以帮助模型关注形状而不是颜色。以公鸡为例,如果训练照片全是红冠黄羽,验证图片里出现一只白色公鸡,模型很可能误判成别的类别,颜色扰动就是缓解这个问题的办法。
需要说明的是,这套增强只加在训练集上。验证集的作用是近似评估真实场景,所以验证集 transform 必须保持确定性,不能用RandomHorizontalFlip,否则每次评估的输入都不同,指标会来回跳。
2.4 DataLoader 的 batch_size、num_workers 和 pin_memory
ImageFolder返回的是样本集合,真正训练时还需要由DataLoader把它们打包成 batch,并且用多进程并行读取图片,避免 GPU 等 CPU 预处理。下面是训练集和验证集的 DataLoader 配置。
from torch.utils.data import DataLoader 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)参数的含义和选择边界值得展开。batch_size是每次迭代进入网络的图片张数,它同时影响显存占用和梯度稳定性。32 是一个常见的起点;如果你的 GPU 显存只有 4 GB,降到 16 更安全。shuffle=True只在训练集使用,目的是让每个 epoch 的样本顺序不同,避免模型学到样本顺序带来的虚假规律。验证集不需要打乱。num_workers是读取图片的子进程数,Windows 上建议设置为 0 或 2,Linux 上可以设置到 CPU 核心数的一半。设置过高不会明显加速,反而可能因为进程切换导致内存激增。pin_memory=True在 CUDA 训练时可以让数据传输更快,但 CPU 环境下设置无意义,也不会报错。
2.4.1 用一个 batch 验证数据管道的形状
正式训练前,可以取一个 batch 看一眼张量形状,这能提前发现尺寸不一致的问题:
images, labels = next(iter(train_loader)) print(images.shape) # torch.Size([32, 3, 224, 224]) print(labels.shape) # torch.Size([32])images的四维分别是 batch、通道、高、宽,labels是长度为 batch 的一维整数张量。如果这里输出的第二维不是 3,说明你的图片可能不是 RGB 三通道,比如 PNG 带透明度通道时会有 4 通道输入。解决办法是在读取时统一转成 RGB,最直接的方式是在ImageFolder的加载路径上做转换,不过标准做法是在 transform 前用 PIL 的convert('RGB')包装一层,或者先保证所有图片都是 JPEG 格式。
3. 选择 PyTorch 图片分类网络主体:自定义 CNN 还是 ResNet18 迁移
3.1 从零搭一个适合小猫小狗小公鸡的 CNN
自己写一个轻量卷积神经网络,最大的好处是结构可控,每一个卷积核、池化层和全连接层都能被解释清楚。下面是一个三阶段的卷积分类器,它包含卷积、ReLU 激活、最大池化和 Dropout,足够处理 224×224 的输入图片。
import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=3): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 28 * 28, 256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))参数设计是有逻辑的。第一个卷积层输入 3 通道(RGB),输出 32 个特征图,kernel_size=3是 3×3 卷积,padding=1保证输出尺寸不变,所以这一层输出的张量是[32, 224, 224]。每经过一次MaxPool2d(2),高和宽都减半,三次池化后从 224 变成 28。最后一个卷积输出的 128 个通道,每个通道 28×28,展平后是128 * 28 * 28 = 100352个数,所以第一个线性层的输入是 100352。Dropout(0.5)在训练时随机丢弃一半神经元,用来抑制过拟合。
这个模型有一个值得注意的弱点:它没有 BatchNorm。深层网络训练时,每层输入的分布会逐渐偏移,加入nn.BatchNorm2d通常能让收敛更稳定。你可以在每次卷积之后补一层nn.BatchNorm2d,代价是模型参数和显存稍微增加。
3.2 用 torchvision 迁移 ResNet18:猫狗公鸡图片分类的捷径
如果训练数据不够多,从头训练上面的 CNN 容易欠拟合或过拟合。更稳妥的做法是加载在 ImageNet 上预训练过的 ResNet18,把最后一层全连接换成三分类输出。PyTorch 官方接口的写法如下:
import torchvision.models as models import torch.nn as nn model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) model.fc = nn.Linear(model.fc.in_features, 3)ResNet18_Weights.DEFAULT会在首次调用时自动下载预训练权重,所以第一次运行需要网络连接。model.fc原本是一个输出 1000 类的线性层,model.fc.in_features拿到它上一层的特征数量,对于 ResNet18 是 512,替换后的输出维度就是 3。这里不需要手动改卷积层,因为 ImageNet 预训练权重已经让网络学会了边缘、纹理、形状等通用特征,猫和公鸡的毛发、鸡冠纹理都能复用这些低级特征。
如果你想进一步控制训练速度,可以把前面的卷积层全部冻结,只训练最后的替换层,代码是:
for param in model.parameters(): param.requires_grad = False model.fc = nn.Linear(model.fc.in_features, 3)冻结之后,反向传播不会再更新骨干网络的参数,只有fc层有梯度,训练速度会快很多,而且几乎不占用额外显存。缺点是特征提取部分完全固定,如果目标图片风格和 ImageNet 差异大,效果上限较低。折中的方案是冻结前几层,放开最后几个残差块,比如把model.layer4和model.fc参与训练。
3.3 自定义 CNN、冻结 ResNet18、微调 ResNet18 怎么选
同样的数据、同样的训练轮数,三种方案的效果和成本差别很大。下面是我常用的选型参考:
| 方案 | 参数量 | 训练速度 | 小数据表现 | 可解释性 |
|---|---|---|---|---|
| 自定义三层 CNN | 约 2500 万 | 快 | 一般,容易过拟合 | 高 |
| 冻结 ResNet18 | 约 1100 万(只有 fc 参与训练) | 最快 | 稳定,足够用 | 中 |
| 微调 ResNet18 | 约 1100 万(全部参与训练) | 较慢 | 最好,但要调学习率 | 低 |
如果你的目的是学习卷积网络原理,从自定义 CNN 开始是最直观的,每一层输出的特征图都可以打印出来观察。如果你的目标是在猫狗公鸡数据上得到尽量高的准确率,又想短时间跑通,建议直接用冻结 ResNet18 或者微调 ResNet18。参数量的概念也很重要:自定义 CNN 虽然层数浅,但全连接层因为输入特征维度大,参数反而可能超过 ResNet18;ResNet18 依靠残差连接和全局平均池化,用更少的参数拿到更好的表征。
3.4 模型的输出是 logits,不是概率
PyTorch 的CrossEntropyLoss内部已经包含了 Softmax 操作,所以模型的最后一层通常直接输出三个实数,称为 logits。训练阶段不要在网络末尾加 Softmax,否则损失函数计算会出现数值不稳定,训练也会变慢。只在推理阶段,如果你想把输出解释成概率,才需要额外做 Softmax:
import torch.nn.functional as F logits = model(images) probabilities = F.softmax(logits, dim=1)dim=1表示在类别维度上做归一化。三个类别的概率之和等于 1,最大概率对应的索引就是预测类别。例如输出为[0.1, 0.7, 0.2],则预测类别是索引 1,对应前面class_to_idx里的dog。
4. 训练配置与训练循环:PyTorch 图片分类网络的超参数和损失函数
4.1 选 CrossEntropyLoss 和 Adam 的理由
多分类任务的标准损失函数是交叉熵。PyTorch 的nn.CrossEntropyLoss会先对 logits 做 Softmax,再计算负对数似然。和手动分离 Softmax + NLLLoss 相比,合在一起的版本数值上更稳定,因为它内部做了 log-sum-exp 变换,避免指数运算溢出。优化器我通常先选 Adam,它对学习率的敏感度比 SGD 低,适合快速验证一个模型能不能收敛。
import torch.optim as optim import torch.nn as nn criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1)这里把学习率设为1e-4而不是默认的1e-3,是因为在微调预训练模型时,过大的学习率容易把已经学好的特征破坏掉。StepLR表示每 10 个 epoch 把学习率乘以 0.1。你可以根据训练集大小调整,如果只有几百张图片,20 个 epoch 可能就已经收敛,此时step_size=10只触发一次降学习率,效果也足够。
4.2 猫狗公鸡分类的基准超参数表
下面这组参数是我在类似规模数据集上使用的起点配置,以 batch 为单位打印损失,以 epoch 为单位记录准确率。不需要追求一次性调到完美,先跑通再微调。
| 超参数 | 推荐值 | 调整方向 |
|---|---|---|
| batch_size | 32 | 显存不足则降为 16 |
| epochs | 30 | 验证集准确率连续 5 轮不升则提前停 |
| learning rate | 1e-4 | 迁移学习用 1e-4,自定义 CNN 可试 3e-4 |
| weight decay | 1e-4 | 过拟合明显时提高到 5e-4 |
| optimizer | Adam | 想要更好精度可换成 SGD+Momentum |
| scheduler | StepLR step=10 gamma=0.1 | 换成 CosineAnnealingLR 也可 |
weight_decay是 L2 正则化,它会对大的权重做惩罚,防止模型把训练集中个别噪声记住。如果训练损失一直降而验证损失开始反弹,优先提高weight_decay,而不是降低模型复杂度。
4.3 一个能直接改的训练循环模板
训练循环是整个网络的核心,为了保证通用性,我把训练一个 epoch 和验证一个 epoch 封装成两个函数。先看训练函数:
def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in 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() avg_loss = running_loss / total avg_acc = correct / total return avg_loss, avg_acc这段代码的逻辑顺序是:把 batch 数据放到 GPU 或 CPU 上,先用optimizer.zero_grad()清空上一次迭代的梯度,然后前向传播得到预设输出,计算损失,再调用loss.backward()反向传播求出梯度,最后optimizer.step()更新权重。累加损失时乘上images.size(0)是为了用样本数加权,避免最后一个 batch 大小不同导致平均损失不准。correct / total算的是准确率。
验证函数和训练函数的结构类似,但有两个关键差异:model.eval()会把 Dropout 和 BatchNorm 切换到评估模式,torch.no_grad()关闭梯度计算,既省显存又避免误更新参数。
def evaluate(model, loader, criterion, device): model.eval() running_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) loss = criterion(outputs, labels) running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return running_loss / total, correct / total调用的时候,在外部套一层 epoch 循环即可:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) for epoch in range(1, 31): train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc = evaluate(model, val_loader, criterion, device) scheduler.step() print(f'Epoch {epoch:02d} | train_loss: {train_loss:.4f} | train_acc: {train_acc:.4f} | ' f'val_loss: {val_loss:.4f} | val_acc: {val_acc:.4f}')输出里同时打印训练和验证指标,是判断过拟合最快的方法。如果train_acc接近 1 而val_acc低很多,说明模型把训练图片的噪声记住了,此时需要增强数据或提高 weight decay。
4.4 保存和恢复 checkpoint:把最优权重留下来
训练 30 个 epoch 后,最后一次模型不一定是最好的,因为可能在某个中间 epoch 验证准确率最高。常见做法是保存一个包含模型权重、优化器状态、当前 epoch 和最优准确率的字典,每轮验证后比较并覆盖保存。
best_acc = 0.0 for epoch in range(1, 31): train_loss, train_acc = train_one_epoch(...) val_loss, val_acc = evaluate(...) if val_acc > best_acc: best_acc = val_acc torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'best_acc': best_acc, }, 'best_model.pth') print(f'Saved best model at epoch {epoch}')恢复模型时,要注意先加载旧的优化器状态,再继续训练,才能让学习率和动量连续。如果只做推理,只需要model_state_dict:
checkpoint = torch.load('best_model.pth') model.load_state_dict(checkpoint['model_state_dict'])如果是 CPU 上保存、GPU 上加载,或者反过来,要设置map_location=torch.device('cuda')或map_location='cpu',否则会报设备不匹配的错误。
5. 评估猫狗公鸡分类结果:混淆矩阵、单图预测和三个翻车细节
5.1 画出三类的混淆矩阵
准确率只能告诉你整体对了几张,但看不出模型是把公鸡错认成猫还是把猫错认成狗。用一个 3×3 的混淆矩阵可以快速定位类别之间的混淆。下面的代码在验证集上收集预测结果,然后用 NumPy 统计:
import numpy as np import torch all_preds = [] all_labels = [] model.eval() with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) cm = np.zeros((3, 3), dtype=int) for true_label, pred_label in zip(all_labels, all_preds): cm[true_label][pred_label] += 1 print(cm)cm[i][j]表示真实类别 i 被预测成类别 j 的次数。对角线越大越好,非对角线越大说明两个类别越容易混淆。如果你的验证集只有少量图片,这个矩阵可能比较稀疏,但依然能看出趋势。
5.2 用训练好的 PyTorch 模型预测单张图片
模型训练完成后,最常见的需求是输入一张新图片输出类别。关键点和训练时一样:单张图片也要走完全相同的预处理流程,并且要补上 batch 维度,因为 PyTorch 模型始终期望四维输入。
from PIL import Image img = Image.open('test_rooster.jpg').convert('RGB') x = val_transform(img).unsqueeze(0).to(device) model.eval() with torch.no_grad(): logits = model(x) prob = torch.softmax(logits, dim=1) pred_idx = torch.argmax(prob, dim=1).item() classes = val_dataset.classes print(f'Predicted: {classes[pred_idx]}, confidence: {prob[0][pred_idx].item():.4f}')unsqueeze(0)会把形状[3, 224, 224]变成[1, 3, 224, 224],这里的 1 是 batch 维度。argmax拿到概率最大的索引,再通过classes列表还原成名字。需要注意的是,如果你在训练 transform 里用了RandomHorizontalFlip,这里千万别复制训练 transform,否则单张图每次预测结果会随机变化,给排查带来很大迷惑性。
5.3 三个让训练白费的小坑
第一个坑是图像通道数不统一。部分图片如果包含透明通道,读入后通道数是 4,卷积层会因为输入通道不匹配直接报错。我一般会在数据整理阶段用脚本把所有图片转成 RGB 的 JPEG。第二个坑是学习率设太大,迁移学习时尤其明显,模型损失在第三四个 epoch 开始震荡。遇到这种情况,先把学习率降到1e-5重新跑。第三个坑是只保存了最后一个 epoch 的权重,错失验证集上更好的中间结果,解决办法就是第 4.4 节的“保存最优模型”策略。
如果想把训练好的模型部署到不依赖训练代码的环境,还可以在验证结束后直接导出 TorchScript。torch.jit.trace是最快的转换方式,只需要给一个示例输入:
scripted_model = torch.jit.trace(model, torch.rand(1, 3, 224, 224).to(device)) scripted_model.save('cat_dog_rooster.pt')之后用torch.jit.load就能加载,这个文件不再依赖原来的SimpleCNN类定义,也没了 Python 环境耦合,放到服务端或嵌入式设备上都能跑。对猫狗公鸡分类这种规模的任务,整个流程到这里已经是一个可以交付的闭环了。
本文还有配套的精品资源,点击获取