要说课程设计里头被点名最多的深度学习任务,图像分类绝对排第一。这次想聊的猫的类别识别,就是一个非常典型的基于PyTorch的CNN分类项目:输入一张猫的照片,输出它的品种标签。别看它听起来不算复杂,从数据整理、网络搭建、训练调参到最后的结果评估,每一步都足够被老师细细追问,所以把整条链路吃透对课程设计和毕业设计都很有帮助。这篇文章适合刚学完Python基础、准备做深度学习入门项目的同学,也适合那些已经把代码跑通、但想知道每个步骤背后原因的人。我会按实际做项目的顺序来聊,把容易踩的坑都拉出来晒一晒。
1. 项目整体设计与思路拆解
1.1 为什么这个任务非CNN不可
图像分类在深度学习里属于最基础也最典型的问题。CNN之所以在这类任务里占据绝对主力,是因为它把特征提取和分类器合成了一个网络,自动从像素里学规律。换成传统方案,比如先提取颜色直方图、边缘方向直方图,再丢给支持向量机,遇到猫品种这种差异很细的任务会很吃力。
原因很直观:不同品种的猫,毛色可能相近但脸形不同,花纹可能类似但耳朵形状有差异,手动设计的特征很难覆盖这些维度。CNN通过一层层卷积堆叠,低层感受野小,学会的是边缘、颜色块;高层感受野变大,学会的是耳朵、眼睛、毛纹组合这些抽象语义。这个过程可以理解成先在照片上画很多小格子,逐层拼出整只猫的完整轮廓,再对应到品种标签。
选PyTorch而不是其他框架,主要是它对初学者太友好了。动态计算图让调试变得很直观,运行时报错基本能定位到具体一行,社区资料也丰富。加上torchvision里自带常用数据集、预训练模型和图像变换工具,课程设计这个场景下能省掉大量底层代码。
1.2 类别设定和数据来源怎么安排
做类别识别之前先要定类别,这个决策直接决定后面所有工作量。我建议课程设计里不要贪多,五到六类最合适。类别太少体现不出分类能力,太多则容易混淆,训练时间变长,报告也不好解释。
常用的组合可以是英短、美短、布偶、暹罗、波斯和中华田园猫。这六类在外形轮廓、毛色、脸型上差异比较明显,用一个轻量级CNN也能跑到85%以上,但又不会简单到让项目失去技术含量。
数据来源方面,可以使用公开的宠物品种数据集,也可以自己建一个图片文件夹。自己收集时有几个硬性要求:每个类别尽量超过100张,测试集必须单独划分,训练集里出现过的图绝不能混进验证集和测试集,否则最终准确率会有严重虚高。图片格式最好统一成jpg或png,命名不要带中文和空格,避免后续路径读取时莫名其妙报错。
1.3 整体流程设计
这个项目的完整链路是:图片采集与整理 → 数据预处理与增强 → 自定义Dataset和DataLoader → CNN模型搭建 → 训练与验证 → 评估与可视化 → 撰写课程设计报告。
建议先把流程图用文字版画在草稿本上,再动手写代码。做课程设计时老师看的不只是一个准确率数字,更关注你是否把每个环节都说清楚。比如为什么训练集要做随机裁剪而验证集不做、为什么最后要用AdaptiveAvgPool而不是直接Flatten,这些细节才是答辩时的加分项。整体流程里,数据工程和模型训练各占一半精力,很多人把大量时间花在调网络结构上,结果输入数据一塌糊涂,模型再好也出不来效果。
2. 环境准备与数据工程
2.1 依赖安装与GPU检查
环境这一步看似简单,却能绊住不少人。先安装基础依赖,命令如下:
pip install torch torchvision matplotlib scikit-learn如果本机有NVIDIA独立显卡,建议去PyTorch官网按CUDA版本安装对应的GPU版本,训练速度比CPU快非常多。装完后用下面这段确认环境是否可用:
import torch print(torch.__version__) print(torch.cuda.is_available())输出里torch.cuda.is_available()如果是False,那就用CPU跑。代码里统一通过device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')做设备分配,这样换一台机器也不用动逻辑。CPU训练也不是不行,只是epoch数量要适当减少,不然一次实验等半小时会让人怀疑人生。
2.2 数据集目录与自定义Dataset
数据集的目录结构建议这样组织:
data/ train/ BritishShorthair/ 001.jpg 002.jpg Ragdoll/ ... val/ BritishShorthair/ ...目录名就是类别名。torchvision自带的ImageFolder可以直接按这个结构读取,但我更推荐自己写一个Dataset类,因为在答辩时老师问起来,你能把每一步都讲清楚。
下面是一个可以直接用的自定义Dataset实现:
import os from PIL import Image from torch.utils.data import Dataset class CatDataset(Dataset): def __init__(self, root, transform=None): self.classes = sorted(os.listdir(root)) self.class_to_idx = {cls: i for i, cls in enumerate(self.classes)} self.samples = [] self.transform = transform for cls in self.classes: cls_path = os.path.join(root, cls) for fname in os.listdir(cls_path): if fname.lower().endswith(('.jpg', '.jpeg', '.png')): self.samples.append( (os.path.join(cls_path, fname), self.class_to_idx[cls]) ) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] img = Image.open(path).convert('RGB') if self.transform: img = self.transform(img) return img, label这里有几个关键细节。convert('RGB')是必须的,有些图片是灰度图或者PNG带透明通道,如果不统一成三通道,训练时会出现通道数对不上模型输入的错误。sorted(os.listdir(root))也很重要,它能保证类别顺序是稳定的,类别索引不会因为文件系统顺序变化而乱掉。
2.3 预处理与数据增强方案
图像预处理直接决定训练稳定性。我的常规配置是:
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(p=0.5), 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]) ]) val_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]) ])每项增强都有它的意义。Resize(256)加RandomResizedCrop(224)让网络每次见到不同尺度、不同位置的猫,提高对拍摄远近和主体位置变化的鲁棒性。水平翻转和颜色抖动模拟日常拍照时角度、光线的变化。Normalize用的是ImageNet的均值和标准差,这么做能让输入像素范围稳定在一个合适区间,帮助模型更快收敛。
重点提醒一下:验证集一定不能加随机增强,只能用Resize加CenterCrop。如果验证集也做随机翻转和随机裁剪,每个epoch评估结果都会抖动,没法稳定判断模型到底是变好了还是变差了。
3. 模型搭建与训练核心实现
3.1 自己搭建一个轻量CNN
课程设计阶段,我建议从零写一个简单但结构完整的CNN,而不是一上来就套ResNet。自己搭网络能把卷积、池化、全连接这些概念真正串起来。下面这个结构是我反复用过的一个轻量模型,在猫品种分类上效果稳定:
import torch.nn as nn class SimpleCatCNN(nn.Module): def __init__(self, num_classes=6): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 16, kernel_size=3, padding=1), nn.BatchNorm2d(16), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 112x112 nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 56x56 nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 28x28 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 14x14 ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(128, 256), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))设计思路很朴素:通道数从16开始逐层翻倍到128,控制参数量;每次池化让特征图尺寸减半,最后用AdaptiveAvgPool2d把任意尺寸的特征图压成1x1,再接全连接层。BatchNorm和Dropout是防止过拟合的两道保险,尤其是数据量不大时,Dropout能让全连接层不依赖某些特定神经元。3x3卷积加padding=1始终保持特征图分辨率,降采样完全交给MaxPooling,这样信息丢失可控,也方便理解。
3.2 训练流程核心代码
训练部分拆成两个函数:一个负责一个epoch的训练,一个负责验证。
import torch from torch import nn from torch.utils.data import DataLoader def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total = 0.0, 0, 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() total_loss += loss.item() * images.size(0) pred = outputs.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total = 0.0, 0, 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) total_loss += loss.item() * images.size(0) pred = outputs.argmax(dim=1) correct += (pred == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total主训练循环如下:
from torch import optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = SimpleCatCNN(num_classes=6).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=2) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=2) epochs = 30 for epoch in range(1, epochs + 1): train_loss, train_acc = train_one_epoch( model, train_loader, criterion, optimizer, device) val_loss, val_acc = evaluate( model, val_loader, criterion, device) print(f'Epoch {epoch:02d}: ' f'train_acc={train_acc:.4f}, val_acc={val_acc:.4f}')这里有几个初学者最容易犯的错。
model.train()和model.eval()必须成对出现。它们会影响Dropout和BatchNorm的行为:训练时要开启Dropout随机丢弃,BatchNorm按当前batch统计均值方差;验证时要把这些关闭,否则每次预测结果都带随机性。
optimizer.zero_grad()必须放在loss.backward()之前。每轮梯度是从零开始累加的,如果不清零,梯度会跨batch累积,参数更新方向会完全乱掉。
验证过程用torch.no_grad()包起来,让PyTorch不构建计算图,既能省显存,也能让速度更快。很多同学验证时忘了写这个,显存直接被撑爆。
3.3 迁移学习对比与报告加分项
如果你的课程设计想做得更有层次,可以再加一个迁移学习方案做对比。用预训练的ResNet18然后在数据集上微调是性价比很高的选择:
from torchvision import models resnet = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) resnet.fc = nn.Linear(resnet.fc.in_features, 6)预训练模型已经在ImageNet大规模数据集上见过大量图像,底层卷积核学会了通用视觉特征,我们只需要替换最后一层全连接并微调。在数据量不足的情况下,这个方案通常比自己搭的CNN准确率更高。
但我不建议直接跳过自定义网络。先在报告里展示自己从零设计的SimpleCatCNN结果,再把ResNet18微调结果作为对比实验,分析两者差异和原因,这个思路会让报告厚实很多,答辩时也有内容可聊。
4. 调参实战与问题排查
4.1 训练损失不下降时先查这四个地方
训练一开始loss就不动,或者直接发散,绝大多数是下面四个原因。
第一,数据没有归一化。漏掉Normalize时输入像素值在0到255之间,数值范围太大,梯度会不稳定。检查一下训练数据是不是已经归一化到近似标准正态分布。
第二,标签错位。手动写Dataset时类别索引和图片内容不对应,打印几个(image, label)样本确认一眼。
第三,学习率不合适。Adam的初始学习率在1e-3附近通常没问题,但loss如果是在一个值上下震荡,就把它降到3e-4或1e-4。学习率太大,损失会在高处以很小步幅反复横跳。
第四,模型输出维度不等于类别数。全连接层最后一层必须输出类别数量,类别数写错在代码里很隐蔽,因为模型仍然能前向运行,直到计算loss时维度对不上才会报错。
另外一个小经验:loss在下滑但准确率迟迟不动,可能是前期预测都集中在某一个类别上,可以先把weight_decay加上,同时看看类别数量分布是否严重不均衡。
4.2 验证集准确率上不去且训练准确率过高
训练准确率越拉越高,验证准确率却停在低位,这就是标准的过拟合表现。小数据集最怕这个。
应对思路按优先级排列:数据增强是最有效的手段,翻转、裁剪、颜色抖动都在扩大有效训练样本空间;然后看模型结构,把Dropout比例调高到0.5,或者去掉一部分卷积层降低模型容量;还可以加weight_decay,也就是L2正则化,让网络权重不要变得过大。
更实用的做法是早停。训练过程中记录验证集最优准确率,连续几个epoch没有刷新就停止训练,同时保存最优模型:
best_acc = 0 patience = 5 no_improve = 0 # 在epoch循环内 if val_acc > best_acc: best_acc = val_acc no_improve = 0 torch.save(model.state_dict(), 'best_model.pt') else: no_improve += 1 if no_improve >= patience: print('early stop at epoch', epoch) break这里还必须强调一个概念:验证集和测试集要分开。调参只能用验证集观察效果,测试集只在最终评估时用一次。如果反复用测试集去指导调参,测试集本身也会被污染,最终报告的准确率会失真。
4.3 显存不足、训练特别慢怎么办
显存不足的报错通常是CUDA out of memory,最常见的原因是batch_size太大。如果你设了64,改成32、16甚至8就能解决。另一个方法是减小输入图片尺寸,比如把224改成160,显存占用会下降一大截。
训练速度慢的话,优先检查DataLoader的参数。num_workers=0会变成单进程加载数据,模型经常要等数据;设成2或4能把显存中的数据预取做到并行。pin_memory=True在GPU训练时也能减少数据传输时间。
如果只能靠CPU训练,建议把epoch数量降到10到15,数据量也适当缩小,或者直接用迁移学习加冻结底层参数的方式,只训练最后一层,速度会快很多。
5. 效果评估与课程设计汇报要点
5.1 用准确率曲线和混淆矩阵展示结果
课程设计报告里只写一句“准确率90%”太单薄。把训练过程中train和val的损失曲线画出来,把测试集上的混淆矩阵画出来,信息量完全不同。
绘图代码大致如下:
import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay # all_labels和all_preds是在测试集上收集的列表 cm = confusion_matrix(all_labels, all_preds) disp = ConfusionMatrixDisplay(cm, display_labels=classes) disp.plot(cmap='Blues') plt.xticks(rotation=45) plt.show()混淆矩阵能直观看出哪些类别之间容易混淆,比如布偶和波斯经常被搞混,说明它们的外形特征在网络眼里比较接近。这个分析可以直接写进报告结论。
准确率曲线用matplotlib画就行,把每个epoch的train_acc和val_acc记录到列表,训练结束后统一绘图。曲线能看出模型是否收敛、是否存在过拟合,比单一数字有说服力得多。
5.2 可视化模型注意力:让报告更有亮点
如果时间充裕,可以把模型的可视化做出来,这是答辩现场最能吸引眼球的部分。最简单的一种是用torchvision.utils.make_grid把一批训练图像拼接展示,可以直观看到数据增强后的样本形态。
再进一步,可以用Grad-CAM的方式生成热力图,显示模型在预测某张猫图片时主要关注哪些区域。比如模型判断一只猫是虎斑,热力图应当集中在猫身上的条纹区域,而不是背景里的沙发。Grad-CAM实现起来有些代码量,但如果写不出来,只展示第一层卷积输出的特征图也可以,同样能说明模型学到了一些边缘纹理。
5.3 课程设计报告怎么写、答辩怎么答
写报告时不要大段贴代码,重点放在思路和分析上。项目背景部分简要说明猫品种分类的应用价值,技术方案部分讲清楚为什么选CNN、数据怎么预处理、网络结构为什么这样设计,实验结果部分放曲线、混淆矩阵和对比实验。
答辩时大概率会被问到下面几个问题。
- 为什么用交叉熵损失?因为它适合多分类场景,配合softmax输出每个类别的概率分布,对错误类别的惩罚更自然。
- 为什么加BatchNorm?它能让每一层输入的分布更稳定,缓解训练过程中内部协变量偏移问题,加速收敛,还带一点正则化效果。
- 为什么不用更深更大的网络?小数据集用大网络容易过拟合,训练慢,收益有限。这是要在实验分析里主动写明的点。
我个人做这个项目时最深的体会是:一开始满脑子想的是把准确率刷上99%,后来发现真正让我学到东西的反而是那些失败过程——忘了切model.eval()导致验证结果忽高忽低,数据没归一化让第一批实验全乱套,显存炸掉之后被迫去理解DataLoader和计算图的机制。如果你现在也在做类似的分类项目,完全不用怕踩坑,每个坑踩过去之后,你对PyTorch和CNN的理解都会扎实一截。后续想往深了走,可以把猫的品种分类换成鸟类、植物叶片这些更细粒度的识别,或者尝试把模型导出后部署到移动端做实时推理,玩法其实很多。