简介:利用PyTorch搭建猫狗公鸡图像分类网络,是深度学习初学者熟悉卷积神经网络工程化流程的典型实战项目。资源完整覆盖图像分类项目的完整链路:数据预处理包含像素归一化、尺寸调整、随机翻转与旋转等增强手段;模型构建通过卷积层提取特征、池化层压缩尺寸、全连接层完成三分类输出,并配合激活函数增强非线性;训练采用交叉熵损失与优化器迭代更新权重,在验证集上评估准确率。模型保存、加载与可视化方法也已涵盖,包括权重导出重新载入、训练曲线和混淆矩阵的解读。包内共1390个文件、压缩包约555MB,主体为1362张猫狗公鸡训练图片,另有11个Python工程脚本、XML标注、TXT说明、可直接调用的ONNX模型等文件,代码与数据完整配套,便于动手复现。目前已有1378人浏览学习,适合希望从零落地PyTorch图像分类项目并积累深度学习实践经验的开发者参考。
1. 用PyTorch搭猫狗公鸡图片分类网络:这不是玩具,是入门深度学习的一条完整链路
很多初学者第一次接触PyTorch图片分类网络,都是从MNIST手写数字开始的。那玩意训练五分钟准确率就99%,容易给人造成一种错觉:分类网络很简单。等真正丢给你一个「猫狗公鸡三分类」任务时,你才会发现完整链路远不止「调个模型跑一下」这么简单——数据怎么组织、归一化参数怎么定、模型选自制的还是预训练的、训练时看哪个指标、模型保存成什么格式,每一步都能让你翻车。本文就用这个三分类任务,把从零搭图片分类网络的完整流程拆开讲,包含可以直接照抄的代码和参数,以及我踩过的坑。适合刚装好PyTorch、想跑通第一个正经项目的从业者。
2. 先搞定PyTorch环境和三类图片数据:用Anaconda配置与ImageFolder整理数据集
2.1 用Anaconda配置PyTorch环境:CPU版和GPU版的命令差异与验证方式
标题里用的是PyTorch,动手第一步就是装环境。我见过太多人在这一步浪费半天:要么是直接在base环境里pip install,把系统Python搞乱;要么是装完发现torch.cuda.is_available()返回False,白白用CPU跑了一下午。常见做法是用Anaconda先建一个独立环境,再安装对应版本的PyTorch。
CPU版安装最简单,适合只想先跑通代码的人:
conda create -n catdog python=3.9 conda activate catdog pip install torch torchvision torchaudioGPU版要麻烦一点,核心是版本匹配。PyTorch的CUDA编译版本和显卡驱动必须对上,否则装完能用但是慢得离谱,或者直接报错。
conda create -n catdog python=3.9 conda activate catdog # 以CUDA 11.8为例,具体版本号去PyTorch官网生成命令再粘贴 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118装完之后一定做两件事验证,不要装完就直接开训练:
python -c "import torch; print(torch.__version__); print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0))"如果torch.cuda.is_available()输出True,说明GPU可用;输出False的话,检查驱动和CUDA版本是否匹配。我一般习惯先看nvidia-smi确认驱动支持的CUDA版本,再去PyTorch官网选对应版本,这比盲目装新版靠谱得多。
注意:PyTorch版本和Python版本有对应关系,Python 3.9一般兼容torch 1.13到2.x。别用Python 3.12装老版本torch,很容易碰到
No matching distribution。
2.2 把猫狗公鸡图片整理成ImageFolder可读的结构:目录规范与按类划分
装完环境,下一步是数据。PyTorch的torchvision.datasets.ImageFolder是处理按类别分目录的图片数据最省事的工具,但它的目录结构有硬性要求:
data/ ├── train/ │ ├── cat/ # 所有猫图 │ ├── dog/ # 所有狗图 │ └── rooster/ # 所有公鸡图 └── val/ ├── cat/ ├── dog/ └── rooster/如果你手上是一堆散图不带目录,我习惯写个小脚本自动划分。假设原始图片放在origin/下,分别有cat/、dog/、rooster/三个子目录,按8:2划分训练集和验证集:
import os import random import shutil random.seed(42) origin_root = 'origin' target_root = 'data' classes = ['cat', 'dog', 'rooster'] train_ratio = 0.8 for cls in classes: cls_dir = os.path.join(origin_root, cls) images = [f for f in os.listdir(cls_dir) if f.lower().endswith(('.jpg', '.jpeg', '.png'))] random.shuffle(images) split_idx = int(len(images) * train_ratio) for phase in ['train', 'val']: target_dir = os.path.join(target_root, phase, cls) os.makedirs(target_dir, exist_ok=True) for img in images[:split_idx]: shutil.copy(os.path.join(cls_dir, img), os.path.join(target_root, 'train', cls, img)) for img in images[split_idx:]: shutil.copy(os.path.join(cls_dir, img), os.path.join(target_root, 'val', cls, img))这段代码做了三件事:固定随机种子保证可复现、按8:2比例切分、复制而不是移动原图。我建议用复制不要用移动,万一数据划分有误还能重来。图片扩展名只认了jpg/jpeg/png,如果数据集里有.bmp或.webp格式,记得加进去,否则图片会被静默忽略。
ImageFolder加载时一般配合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]) ]) train_dataset = datasets.ImageFolder('data/train', transform=train_transform) val_dataset = datasets.ImageFolder('data/val', transform=train_transform) print(train_dataset.classes) # 输出 ['cat', 'dog', 'rooster'] print(train_dataset.class_to_idx) # 输出 {'cat': 0, 'dog': 1, 'rooster': 2}ImageFolder会自动扫描子目录名作为类别标签,所以目录名必须和类别一致。class_to_idx的映射顺序按目录名排序,这也决定了模型输出的三维向量里第0维是猫、第1维是狗、第2维是公鸡。
2.3 归一化参数怎么定:mean和std该不该用ImageNet默认值
不少人在这一步偷懒,只写ToTensor()不做Normalize,训练也能跑,但收敛速度和最终精度都吃亏。Normalize的作用是把像素值从[0,1]区间缩放到均值为0、方差为1的分布,让模型训练更稳定。
对于自制的小型CNN,mean和std可以自己算,比如用所有训练图片的通道均值。但对于迁移学习(用预训练模型),必须沿用预训练时的归一化参数,也就是mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]。原因很简单:预训练权重是在这个归一化分布下学出来的,如果变了,输入分布对不上,预训练的效果就废了一半。
注意:验证集和测试集不能做随机增强,只需要Resize、ToTensor、Normalize这三步。数据增强只加在训练集上,否则验证集的结果会虚高。
3. 搭建分类网络的两条路线:自制CNN打地基,ResNet18微调出效果
3.1 自制三层CNN:先让模型在CPU上跑通一次完整训练
自己搭一个简单的卷积网络,好处是结构完全透明,每一层维度自己算得清清楚楚,出了问题好排查;坏处是准确率天花板低。我先给出一个最精简的三层CNN结构,适合先跑通流程:
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.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(128, num_classes) ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x输入是3x224x224的图片,三次卷积加池化后特征图变成128x28x28,然后AdaptiveAvgPool2d((1,1))把特征压缩成128x1x1,拉平后接全连接层输出3个值。这里有个设计细节值得注意:没有用nn.MaxPool2d把尺寸算到最后再拉平,而是用AdaptiveAvgPool2d做全局平均池化。这样不管输入尺寸怎么变,全连接层的输入维度始终是128,省去了手算特征图尺寸的麻烦。
BatchNorm2d放在卷积和激活函数之间,作用是稳定训练,防止梯度消失或爆炸。很多新手问为什么加了这个准确率就上来,因为BN把每层输出拉回标准分布,让梯度更新更顺畅。自制CNN在几百张图片的小数据集上很容易过拟合,训练集准确率98%,验证集卡在75%,这是正常现象,后面讲模型选型时会解释原因。
3.2 用ResNet18预训练权重做迁移学习:为什么三分类任务不建议从零训练
如果你的数据集每个类别只有几百张图,自制CNN的上限很低。我一般会直接换torchvision里的预训练模型做迁移学习。ResNet18大小适中(约44MB),在CPU上也能跑,是三分类任务性价比很高的选择。
import torchvision.models as models model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) # 冻结所有层,只训练分类头 for param in model.parameters(): param.requires_grad = False # 替换最后的全连接层,输出3类 num_features = model.fc.in_features model.fc = nn.Linear(num_features, 3) # 只有fc层的参数需要更新 trainable_params = [p for p in model.parameters() if p.requires_grad] print(f'可训练参数量: {sum(p.numel() for p in trainable_params)}')关键在最后一行:冻结全部层之后,可训练参数只有全连接层的512*3+3=1539个。这意味着训练极快,而且因为前面所有卷积层都用的是ImageNet上学到的通用特征提取能力,小数据集也不容易过拟合。打印可训练参数量是个好习惯,能确认冻结是否生效,我见过有人写了param.requires_grad = False但忘了替换fc层,结果最后一层还是1000维输出,训练时报维度不匹配的错。
这里解释一下为什么迁移学习在小数据集上碾压自制网络。ImageNet预训练模型的前几层卷积已经学到了边缘、纹理、形状等通用特征,这些特征对猫狗公鸡同样适用。自制CNN需要从零学这些特征,数据量不够就只能死记硬背训练集。所以我的建议是:自制CNN用来理解原理、排查流程问题,追求最终效果直接用ResNet18迁移学习。
3.3 只改最后一层:替换fc层时注意in_features取值
替换fc层是迁移学习最常见的操作,但有个细节坑。model.fc.in_features这个写法是动态读取原全连接层的输入维度,ResNet18是512,ResNet50是2048。如果手写死2048或者512,换模型时就容易错。
# 推荐写法:动态读取 num_features = model.fc.in_features model.fc = nn.Linear(num_features, 3) # 不推荐写法:写死维度 # model.fc = nn.Linear(512, 3) # 换ResNet50就直接报错另外,如果不想冻结全部层,只冻结一部分(比如冻结前四层、微调后几层),需要手动控制每个参数的requires_grad,这属于进阶玩法。初次跑通项目建议全冻结,只训练fc层,效果已经很好了。
4. 训练循环和评估:损失下降不是唯一标准,按类准确率才是
4.1 模型、损失函数、优化器三件套:三分类为什么用CrossEntropyLoss而不是BCE
搭好模型之后,训练三件套里第一个就是损失函数。猫狗公鸡是三分类,PyTorch里对应nn.CrossEntropyLoss。新手最容易搞混的是它在内部已经帮我们做了Softmax操作,所以模型的输出层不需要额外加Softmax,直接输出原始logits就行。
import torch import torch.nn as nn import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.fc.parameters(), lr=1e-3)这里有个容易被忽略的点:optimizer只传了model.fc.parameters()。因为前面的层冻结了,没必要给它们单独算梯度。如果用model.parameters()也没错,只是白白多算了大量不需要更新的梯度,速度会慢一些。
CrossEntropyLoss和BCELoss的区别必须说清楚:BCELoss用于二分类或多标签分类,输出要过Sigmoid;CrossEntropyLoss用于多分类,内部自带Softmax。标签是类别索引(0、1、2),不是one-hot编码。如果你用了one-hot标签,得在损失函数上选对版本,否则直接报维度错误。
优化器我习惯用Adam起步,lr=1e-3是个比较稳的初始值。调参时如果发现验证集不收敛或震荡,优先把学习率降到1e-4再试,不要动网络结构。
4.2 一个完整的训练循环:train/test交替、模型保存与Early Stopping
训练循环的写法要兼顾可复现和可中断恢复。这里给出一套完整代码:
import copy num_epochs = 30 best_val_acc = 0.0 best_model_wts = copy.deepcopy(model.state_dict()) for epoch in range(num_epochs): model.train() train_loss, train_correct, train_total = 0.0, 0, 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() train_loss += loss.item() * inputs.size(0) _, preds = torch.max(outputs, 1) train_correct += torch.sum(preds == labels.data) train_total += labels.size(0) train_acc = train_correct.item() / train_total train_loss_avg = train_loss / train_total # 验证阶段 model.eval() val_correct, val_total = 0, 0 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) val_correct += torch.sum(preds == labels.data) val_total += labels.size(0) val_acc = val_correct.item() / val_total print(f'Epoch {epoch+1}/{num_epochs}, Train Loss: {train_loss_avg:.4f}, ' f'Train Acc: {train_acc:.4f}, Val Acc: {val_acc:.4f}') if val_acc > best_val_acc: best_val_acc = val_acc best_model_wts = copy.deepcopy(model.state_dict()) torch.save(model.state_dict(), 'best_model.pth')几个关键点:
model.train()和model.eval()必须交替调用。train()模式下BatchNorm会更新均值和方差,eval()模式下用累计均值。新手最常犯的错是在验证阶段忘了切到eval(),导致验证结果忽高忽低。torch.no_grad()在验证阶段强制不构建计算图,省内存也提速。验证不需要反向传播,不写这个只是慢,不会错。- 保存最佳模型用
copy.deepcopy(model.state_dict()),而且只保存state_dict不保存整个模型对象。这个习惯可以避免后续加载时PyTorch版本不匹配的问题,后面避坑章节细讲。 - 每个epoch打印训练loss、训练准确率、验证准确率三项,不要只打印loss。loss降了不代表分类对了,尤其类别不平衡时loss和准确率可能背离。
4.3 用混淆矩阵定位模型在公鸡类上的翻车点
只看整体准确率会掩盖大问题。比如猫类准确率95%、狗类90%、公鸡类只有60%,整体准确率约82%看着还行,但公鸡类完全不可用。我习惯每次训练完都出一张混淆矩阵:
from sklearn.metrics import confusion_matrix import numpy as np model.eval() all_preds, all_labels = [], [] 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) # 输出格式:行为真实类别,列为预测类别 # [[猫被识别为猫 猫被识别为狗 猫被识别为公鸡] # [狗被识别为猫 狗被识别为狗 狗被识别为公鸡] # [公鸡被识别为猫 公鸡被识别为狗 公鸡被识别为公鸡]]如果看到公鸡类大量被预测成狗,先别急着调模型,回去看数据集:公鸡图片是不是背景里有栅栏、草地,和狗类图片背景相似?这种情况加数据比调参有用得多。混淆矩阵是定位分类瓶颈最直接的工具,比看loss曲线有用十倍。
注意:
torch.max(outputs, 1)返回的是(最大值, 索引),我们要的是索引。写_, preds = torch.max(outputs, 1)时前面的_就是抛弃最大值,只留类别索引。
5. 猫狗公鸡分类的五个常见坑:从数据集翻车到训练假装收敛
5.1 训练损失下降到0但验证集一塌糊涂
现象:训练集准确率98%以上,损失降到0.05以下,验证集准确率只有70%上下。
原因:两种常见可能。第一种是数据划分时没有随机打乱,训练集和验证集分布不一致,比如训练集全是白猫,验证集全是黑猫;第二种是模型过拟合,参数量远大于数据量。
解决:先检查数据划分。我用random.seed(42)手动打乱,确保每个类别在两个集合里比例接近。如果是过拟合,加数据增强(随机水平翻转、颜色抖动、随机旋转)比加Dropout管用,或者直接换迁移学习模型。
5.2 验证集acc震荡不收敛:学习率过大的经典表现
现象:验证集准确率在60%到75%之间反复横跳,不稳步上升。
原因:学习率太大,参数在最优解附近来回弹跳,越过极小值。
解决:把学习率从1e-3降到1e-4,如果还震荡就降到1e-5。我习惯在优化器里加一个学习率调度器:torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='max', patience=3),验证集准确率连续3个epoch不涨就自动降学习率,比手调省心。
5.3 猫狗公鸡图片尺寸差异大导致输入变形失真
现象:公鸡类准确率上不去,肉眼观察模型预测结果,很多长条形的公鸡图片被错分。
原因:公鸡图片长宽比偏大,直接Resize((224,224))会严重拉伸变形;猫狗图片长宽比接近1:1,影响较小。模型学到的特征被失真输入污染。
解决:改Resize策略,先按比例缩放短边到256,再做中心裁剪到224:
transform = 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]) ])这样公鸡图片会裁掉两侧留白,保留主体,而不是整体压扁。
5.4 把模型保存成state_dict还是整个模型
现象:torch.save(model, 'model.pth')保存成功,换一台机器加载时报AttributeError: Can't get attribute 'SimpleCNN'。
原因:torch.save(model, ...)会把整个对象序列化,包括模型类的定义路径。换环境后类定义路径不同,反序列化失败。
解决:只保存参数,不保存结构。训练完用torch.save(model.state_dict(), 'best_model.pth'),加载时先重建模型结构(用相同的模型代码或从torchvision重新实例化),再load_state_dict。这也是为什么前面训练循环里用copy.deepcopy保存权重而不是直接保存模型对象。
5.5 公鸡类样本过少导致分类严重偏斜
现象:猫和狗类各2000张,公鸡只有300张,模型把所有公鸡都预测成狗。
原因:类别不平衡。模型学到的最优策略是把不确定的样本都分到样本量大的类别,因为这样整体loss最小。
解决:先用WeightedRandomSampler给样本少的类别加权:
from torch.utils.data import WeightedRandomSampler labels = [sample[1] for sample in train_dataset.samples] class_counts = [train_dataset.samples.count((s[0], 0)), train_dataset.samples.count((s[0], 1)), train_dataset.samples.count((s[0], 2))] weights = [1.0 / class_counts[label] for label in labels] sampler = WeightedRandomSampler(weights, num_samples=len(labels), replacement=True) train_loader = DataLoader(train_dataset, batch_size=32, sampler=sampler)这样每个batch里公鸡样本的出现概率被强行提高。另一个辅助手段是给公鸡类别单独加数据增强,比如随机旋转15度、颜色抖动。
6. 把训练好的模型落地:单张图片推理与PyTorch转ONNX的验证步骤
6.1 写一个predict.py:读图、预处理、推理、输出三类概率
模型训练好了,最终要能用。我习惯写一个独立的推理脚本,加载测试图片并输出三个类别的概率。这个脚本必须自己完成读图和预处理,不能直接拿训练时的DataLoader来凑,因为真实使用场景就是一张一张来的:
import torch from PIL import Image from torchvision import transforms model = models.resnet18(weights=None) num_features = model.fc.in_features model.fc = nn.Linear(num_features, 3) model.load_state_dict(torch.load('best_model.pth', map_location='cpu')) model.eval() infer_transform = 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]) ]) img = Image.open('test.jpg').convert('RGB') input_tensor = infer_transform(img).unsqueeze(0) # 加batch维度 with torch.no_grad(): output = model(input_tensor) probs = torch.softmax(output, dim=1).squeeze(0).numpy() class_names = ['cat', 'dog', 'rooster'] for name, prob in zip(class_names, probs): print(f'{name}: {prob:.4f}') pred_idx = probs.argmax() print(f'预测结果: {class_names[pred_idx]}')注意Image.open之后要转RGB,否则遇到PNG带透明通道的图片,通道数变为4,ToTensor之后变成4x224x224,和模型输入的3x224x224不匹配,直接报错。unsqueeze(0)是把单张图变成batch_size=1的张量,这一步写顺手了但忘了就会报维度不匹配。
6.2 PyTorch转ONNX:输入尺寸固定后模型才能跨框架部署
如果模型要部署到服务端或用TensorRT、ONNXRuntime加速,最稳的路子是导出ONNX。导出前必须固定输入尺寸,因为ONNX是静态图,动态尺寸会大大增加使用复杂度。
dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, 'catdog.onnx', input_names=['input'], output_names=['output'], opset_version=13, dynamic_axes=None )导出后用ONNXRuntime验证一遍,百分百确认模型没有蒸坏:
import onnxruntime as ort import numpy as np ort_session = ort.InferenceSession('catdog.onnx') input_elem = input_tensor.numpy() ort_outputs = ort_session.run(['output'], {'input': input_elem}) print('ONNX输出:', np.argmax(ort_outputs[0]))这里有个坑:做验证时必须用同一张图比较PyTorch输出和ONNX输出,数值会有极微小差异(float32精度),但argmax结果必须完全一致。如果不一致,先看opset_version是否太低,ONNX导出版本低于11时,部分算子的数值精度不同会导致结果漂移。
我从第一次做分类项目起就养成一个习惯:模型训练的最后一个epoch,跑一遍完整推理验证,确认脚本从「读图」到「输出类别」全链路无误,而不是只检查验证集准确率。验证集准确率是统计学指标,推理单张图是实际体验。这两个数字差很远的情况我见得太多——数据预处理代码写错、类别名映射错位、加载模型时结构对不上,都可能导致指标好看但实际不可用。希望这篇笔记能帮你少踩几个类似的坑。
本文还有配套的精品资源,点击获取