如果你搜过“Python 图像识别”这个关键词,大概率已经被卷积神经网络(CNN)、深度学习、模型训练这些名词砸得晕头转向。我当初入坑的时候也一样,看了三天理论,合上电脑连一个完整的训练流程都跑不起来。今天这篇就是要把这件事彻底讲透:我会用猫狗图片分类这个最经典的实战项目,从 CNN 原理、环境搭建、数据准备到模型训练、调参排查,完整走一遍。文章里不会只有飘在空中的概念,每一段都有代码、有命令、有可以落地的操作步骤,你就当一位朋友在旁边手把手带你做项目。
这篇内容适合两类人:一类是学过 Python 基础、想切入深度学习和图像识别方向的新手;另一类是已经跑通过一些 demo、但对 CNN 为什么有效、训练中遇到问题怎么排查还比较模糊的人。读完你应该能做到三件事:能独立搭出一个可用的 CNN 分类模型,能理解卷积、池化、全连接这些层到底在干什么,以及模型不收敛、过拟合、显存溢出这些常见问题出现时知道从哪下手查。
1. 为什么图像识别首选 CNN:核心思路与方案选型
1.1 CNN 究竟解决了什么问题
传统方法做图像分类,最头疼的是“特征工程”。你得手动告诉程序:猫的耳朵是三角形的、狗鼻子是湿的、毛发的纹理有什么差异……这些规则不仅写起来要命,换一个数据集基本就废了。因为猫狗之间甚至猫和猫之间的差异,根本不可能靠几条人工规则描述清楚。
CNN 的思路完全不同:它不让你定义特征,而是从数据里自己“学”特征。网络的前几层学到的是边缘、颜色块这样的低级特征,中间的层会把它们组合成纹理、形状,后面的层甚至能学到“眼睛”“耳朵”这种语义级别的特征。这就是“端到端学习”——输入原始像素,输出分类结果,中间所有特征表示都是网络自己搞定的。
这是 CNN 能成为图像识别主流方案的根本原因。从 2012 年 AlexNet 在 ImageNet 上把错误率大幅压低开始,VGG、GoogLeNet、ResNet 一路把这个思路发扬光大。到今天,医疗影像诊断、工业质检、自动驾驶感知、无人机航拍识别,核心清一色都是 CNN 或者它的衍生变体。所以学图像识别,CNN 是绕不开的第一课。
1.2 实战方案选型:框架、数据集与硬件
实战项目第一件事是选工具链,我直接说结论和理由。
框架层面,我用 PyTorch。相比 TensorFlow,PyTorch 的调试体验好得多,模型定义就是 Python 代码本身,出错了你可以顺着堆栈一路查下去,而且学术界大量预训练模型和论文代码都是 PyTorch 版本,后续你想迁移到目标检测、图像分割方向,生态衔接最顺滑。
数据集层面,我选了 Kaggle 的猫狗数据集(Dogs vs Cats)。这个数据集有 25000 张猫狗图片,足够支撑一个入门项目的训练和验证。更重要的是它足够“真实”——图片里有各种姿势、光照、遮挡,跟工业现场拿到的原始数据很像,训练出来的模型不是那种在小数据集上自嗨的玩具。你要是还没注册 Kaggle,也可以用 torchvision 里的 CIFAR-10 作为替代,但猫狗数据集的感知难度明显更高,练出来的经验更实用。
硬件层面,有一块 NVIDIA 显卡(哪怕 4GB 显存)就能跑。没有显卡也能跑,只是慢一些。我实测下来,核心的部分用 CPU 训练大概需要数小时,而 GPU 只需要几分钟。这个差异长期存在,所以有条件还是建议把 CUDA 环境配上。下面第 2 节我会把环境配置步骤也一起列出来。
1.3 项目整体流程:从图像到分类结果的完整链路
整个实战项目的执行顺序,我建议严格按下面这条链路走:
- 环境准备:安装 Python、PyTorch、CUDA(可选)。
- 数据获取与组织:下载猫狗数据集,按训练集/验证集分目录存放。
- 数据预处理:缩放到统一尺寸、转张量、归一化,同时做数据增强。
- 模型搭建:用 PyTorch 定义 CNN 网络结构。
- 训练循环:前向传播、计算损失、反向传播、更新权重。
- 评估与可视化:看准确率和损失曲线,分析错误的样本。
- 预测部署:写一个加载模型、输入图片、输出类别的完整脚本。
这 7 步做完,你就拥有一个完整可用的图像识别系统了。下面每一节都会对应这条链路中的一个或多个环节,跟着走就行。
2. 环境配置与数据准备:动手前的关键细节
2.1 Python 环境与依赖安装
先说环境。我用的是 Python 3.10,PyTorch 2.x 版本(目前 2.1/2.2 都稳定可用)。装环境我一般建议用虚拟环境,别把包直接往系统 Python 里塞,不然隔几个月你就会遇到“A 项目要 PyTorch 1.8,B 项目要 2.2”这种互相打架的局面。
创建虚拟环境并安装依赖的命令如下:
# 创建虚拟环境,名字叫 cnn_practice python -m venv cnn_practice # 激活环境(Windows) cnn_practice\Scripts\activate # 激活环境(macOS / Linux) source cnn_practice/bin/activate # 安装 PyTorch CPU 版本 pip install torch torchvision torchaudio # 如有 NVIDIA GPU,先到 pytorch.org 官网复制对应 CUDA 版本的安装命令 # 例如 CUDA 11.8: pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118注意:如果你有 GPU,安装前一定先去 pytorch.org 查当前最新的 CUDA 对应关系。PyTorch 版本更新很快,直接网上搜旧教程常会装到不匹配的版本,导致 CUDA 不可用。
安装完成后,建议先运行下面这段代码确认环境是好的:
import torch print(torch.__version__) print("CUDA 可用:", torch.cuda.is_available()) print("GPU 名称:", torch.cuda.get_device_name(0) if torch.cuda.is_available() else "CPU")如果你看到 CUDA 可用输出为 True,后面训练速度会非常可观。
2.2 数据集下载与目录组织:一次踩坑后的标准做法
Kaggle 的猫狗数据集需要注册登录才能下载。如果你已经有 Kaggle 账号,最简单的方式是直接用官方命令行工具:
pip install kaggle # 把 kaggle.json 凭证文件放到 ~/.kaggle/ 目录 kaggle competitions download -c dogs-vs-cats下载下来是 train.zip 和 test1.zip。test1.zip 里的图片不带标签(Kaggle 比赛原题就是预测这些),我们训练只用 train.zip。解压后你会发现里面有 25000 张图片,命名格式是 cat.0.jpg、dog.0.jpg 这种。
这里我要重点提醒一下目录组织的问题。我第一做这个项目的时候图省事,所有图片放在同一个目录,训练代码里自己写逻辑按文件名前缀去读。结果后面对接数据加载器、做数据增强、划分验证集的时候,代码越写越绕,bug 越调越多。后来我学乖了,老老实实按 PyTorch 标准习惯组织目录:
data/ ├── train/ │ ├── cats/ │ │ ├── cat.0.jpg │ │ ├── cat.1.jpg │ │ └── ... │ └── dogs/ │ ├── dog.0.jpg │ └── ... └── val/ ├── cats/ └── dogs/为什么非要这样组织?因为 PyTorch 的 torchvision.datasets.ImageFolder 直接支持这种目录结构,它会自动把子文件夹名称作为类别标签,从零开始帮你把数据加载、标签映射、顺序打乱全搞定。用标准结构,后面所有环节都能少写几十行代码,也更不容易出 bug。
下面这个脚本把原始 train 文件夹里的图片按 9:1 比例随机分成训练集和验证集:
import os import shutil import random random.seed(42) source_dir = 'train' # 下载解压后的原始目录 base_dir = 'data' train_dir = os.path.join(base_dir, 'train') val_dir = os.path.join(base_dir, 'val') # 创建目录结构 for split in ['train', 'val']: for cls in ['cats', 'dogs']: os.makedirs(os.path.join(base_dir, split, cls), exist_ok=True) for cls in ['cats', 'dogs']: filenames = [f for f in os.listdir(source_dir) if f.startswith(cls + '.')] random.shuffle(filenames) val_count = int(len(filenames) * 0.1) for f in filenames[:-val_count]: shutil.copy(os.path.join(source_dir, f), os.path.join(train_dir, cls, f)) for f in filenames[-val_count:]: shutil.copy(os.path.join(source_dir, f), os.path.join(val_dir, cls, f)) print(f"{cls}: 训练集 {len(filenames) - val_count} 张,验证集 {val_count} 张")注意:划分验证集之前一定要先打乱顺序。原始数据是按 cat.0, cat.1, cat.2 ... dog.0, dog.1 ... 的顺序排列的,不打乱的话验证集会全是某一段连续图片,与训练集分布不一致,评估结果会失真。
2.3 数据预处理与数据增强:小数据集救星
原始图片尺寸大小不一,而 CNN 的输入尺寸是固定的,所以第一步是 resize。常用尺寸是 224x224 或者 128x128。大尺寸保留更多细节,但训练更慢、吃显存;小尺寸训练快但可能丢掉细节。我建议初学阶段用 128x128,训练速度和效果比较平衡。
除了 resize,还必须做三步操作:转成张量(PIL 图像默认是 HWC 格式,取值范围 0-255,要转成 CHW 且归一化到 0-1 的浮点张量)、标准化(减均值除方差)、以及数据增强。
数据增强是本文第一个重要技巧。简单说就是:训练时随机对图片做一些旋转、翻转、裁剪、颜色抖动,人为制造更多样的训练样本。这样做能大幅缓解过拟合,尤其适合 25000 张这种“不算特别大”的数据集。
所以这里有个很关键的点:训练集和验证集的预处理必须不一样。训练集要做增强,验证集只能用 resize + 归一化,不能用随机翻转这些。
from torchvision import transforms # 训练集:含数据增强 train_transforms = transforms.Compose([ transforms.Resize((144, 144)), # 先放大一点 transforms.RandomResizedCrop(128), # 随机裁剪回 128x128 transforms.RandomHorizontalFlip(), # 随机水平翻转 transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) # 验证集:只做基本缩放和归一化 val_transforms = transforms.Compose([ transforms.Resize((128, 128)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])RandomResizedCrop 会随机裁剪图像中的一部分再缩放到目标尺寸,这招对提升模型泛化能力特别有效,相当于让模型学会从不同角度、不同构图里识别主体。
3. CNN 核心原理与模型结构逐层拆解
3.1 卷积层:拿着放大镜在图片上找特征
卷积层是 CNN 的灵魂。你想象一下:有一张 128x128 的猫图,输入是 3 个通道(RGB)。卷积层做的事情是拿一堆小窗口(称为卷积核或滤波器,常见 3x3 大小)在图片上从左到右、从上到下滑动,每滑到一个位置,就把窗口里的像素值和卷积核里的权重做加权求和,得到一个输出值。滑遍整张图,就得到一张新的特征图。
这个操作的意义在哪?你可以把每个卷积核理解成“特征探测器”。某个卷积核的权重刚好长成横向边缘的形状,它就对横向边缘响应很大;另一个卷积核可能对圆形的响应很大。网络训练的过程中,这些卷积核的权重会被不断调整,让它们自己进化成最能区分当前数据类别的探测器。
关键参数有三个:
- 卷积核大小(kernel_size):常见 3x3、5x5。小的卷积核感受野更细,堆叠多层能获得大感受野,所以现代网络清一色用 3x3。
- 步长(stride):卷积核每次滑动的距离。默认 1,即逐个像素移动。步长大于 1 可以原地缩小特征图尺寸。
- 填充(padding):在图像边缘补一圈 0,防止边缘像素被“冷落”,也可以控制输出尺寸。padding=1 时,3x3 卷积输出尺寸保持不变。
输出特征图的尺寸公式是:输出尺寸 = (输入尺寸 - 卷积核大小 + 2 * padding) / stride + 1。这个公式你在调试网络结构时报错时会经常用到,建议记下来。
3.2 池化层:压缩信息,保留核心特征
池化层做的事情简单粗暴:对特征图做降采样,最常用的是最大池化(Max Pooling),即在 2x2 的窗口里只保留最大的那个值,然后窗口滑动,输出尺寸直接减半。128x128 的特征图经过一次 2x2 最大池化就变成 64x64。
为什么这样做不会丢失关键信息?因为图像识别关心的往往是“这个区域里有没有某个特征”,而不是“特征精确落在哪个像素位置”。取最大值相当于说:只要这个局部区域里有我们要找的特征,就把它保留下来。同时,降采样让后面的层看到的感受野更大,计算量也大幅减少。
生活化类比:看一张合照,你想确认里面有几个人。你不会逐个像素数,而是大致扫一眼,关注每个人所在区域的“人形特征”是否存在。最大池化就是这个逻辑。
3.3 全连接层与输出层:把特征变成分类决策
经过若干轮卷积和池化之后,特征图被展平成一维向量,送入全连接层(Fully Connected Layer,即传统的神经网络层)。到这里,网络已经完成了“特征提取”阶段,全连接层负责把这些特征组合起来,做出最终的分类判断。
最后一个全连接层的输出神经元数量等于类别数。猫狗二分类就是 2 个神经元。为了让输出变成一个概率分布,会接一个 Softmax 激活函数,把两个实数映射成 0 到 1 之间、加起来等于 1 的数值。比如输出 [0.87, 0.13],就代表模型认为这张图有 87% 的概率是猫。
3.4 经典 CNN 架构演进:从 LeNet 到 ResNet 的启发
理解了上面三个层,你就已经掌握 CNN 的骨架了。历史上经典的 CNN 本质上都是这些层的堆叠,只是堆法不同。
- LeNet-5(1998):2 层卷积 + 3 层全连接,用于手写数字识别,CNN 的开山之作。
- AlexNet(2012):更深更大,引入了 ReLU 激活和 Dropout,一举拿下 ImageNet 冠军。
- VGG(2014):把卷积核统一成 3x3,通过堆更多层数提升效果,结构非常规整,适合用来学习理解 CNN。
- ResNet(2015):加入“残差连接”,让信息可以跳过某些层直接向后传递,解决了深层网络难以训练的问题。
我们的实战模型不需要那么深,我会用 VGG 风格的思路,搭一个 3 层卷积 + 3 层全连接的网络。既保留足够的表达能力,又不会让新手在训练时间上等得太痛苦。
4. 模型搭建与训练实操:基于 PyTorch 的完整流程
4.1 搭建 CNN 模型:第一版代码全解析
现在开始写模型。我用 torch.nn.Module 定义网络,这是 PyTorch 的标准做法。模型结构如下:
- 第 1 层:卷积 3→32 通道,3x3 卷积核,ReLU,最大池化
- 第 2 层:卷积 32→64 通道,3x3 卷积核,ReLU,最大池化
- 第 3 层:卷积 64→128 通道,3x3 卷积核,ReLU,最大池化
- 展平后接全连接:128*16*16 → 256 → 2
输入是 128x128 的图片,经过三次“卷积+池化”,尺寸变化是 128→64→32→16。所以展平后的特征维度是 128(通道数)* 16 * 16 = 32768。这个数值你可以按前面说的尺寸公式推算,也可以打印模型看到。
import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=2): super().__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 * 16 * 16, 256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x model = SimpleCNN() print(model)这里提两个细节。第一,inplace=True 表示原地修改输入张量,节省显存,是 PyTorch 的常见写法。第二,Dropout(0.5) 在训练时随机让一半神经元失活,强制网络学到更鲁棒的特征,是经典的防过拟合手段,但要注意推理时它会自动关闭,不需要手动处理。
4.2 损失函数与优化器:训练的核心驱动器
模型搭好了,接下来要回答两个问题:怎么衡量模型预测得好不好?怎么根据预测结果调整模型参数?
衡量预测质量用损失函数。多分类任务的标准选择是交叉熵损失(CrossEntropyLoss)。它的思想是:模型对正确类别的预测概率越高,损失越小。如果模型把一张猫图以 90% 概率判为猫,损失很小;如果只给 30% 概率,损失就很大。优化目标就是最小化这个损失。
调整参数用优化器。我选 Adam,它是深度学习领域目前最省心的优化器,会自动调整每个参数的学习率,新手不需要像用 SGD 那样手动调动量、调学习率策略。学习率我推荐从 0.001 起步,这是经过大量实践验证的稳妥默认值。
import torch.optim as optim criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001)如果 GPU 可用,把模型搬到 GPU 上:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) print(f"训练设备:{device}")4.3 数据加载器与训练循环:让模型真正学起来
数据加载用 DataLoader,它负责把图片按批次(batch)喂给模型。batch_size 决定了一次前向传播处理多少张图,太大显存不够,太小训练不稳定。32 是一个比较均衡的起点。
from torch.utils.data import DataLoader from torchvision import datasets train_dataset = datasets.ImageFolder('data/train', transform=train_transforms) val_dataset = datasets.ImageFolder('data/val', transform=val_transforms) 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) print(f"训练集样本数:{len(train_dataset)},验证集样本数:{len(val_dataset)}") print(f"类别映射:{train_dataset.class_to_idx}")shuffle=True 在训练时很重要,它让每个 batch 里的图片顺序随机化,避免模型学到样本顺序的假规律。验证集不需要打乱。
接下来是核心的训练循环。每轮 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() epoch_loss = running_loss / total epoch_acc = correct / total return epoch_loss, epoch_acc 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() epoch_loss = running_loss / total epoch_acc = correct / total return epoch_loss, epoch_acc说明几个新手最容易忽略的点。optimizer.zero_grad() 必须在 backward 之前调用,因为 PyTorch 的梯度是累加的,不清空的话每一轮的梯度会叠加在一起,参数更新就乱了。model.train() 和 model.eval() 也很关键,它们控制 Dropout 和 BatchNorm 在不同模式下的行为,忘了切换会导致训练结果莫名其妙。
4.4 完整训练脚本与执行结果
把上面的内容拼到一起,就是完整的训练脚本。我固定随机种子保证结果可复现:
import random import numpy as np def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(42)然后是训练主循环:
epochs = 15 best_val_acc = 0.0 for epoch in range(epochs): 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+1}/{epochs} | " f"Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | " f"Val Loss: {val_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') print(f" 保存最佳模型,验证准确率 {val_acc:.4f}")我实际跑下来的典型输出大致是这样的:
Epoch 1/15 | Train Loss: 0.6652 | Train Acc: 0.5881 | Val Loss: 0.5643 | Val Acc: 0.7193 Epoch 3/15 | Train Loss: 0.4683 | Train Acc: 0.7780 | Val Loss: 0.4231 | Val Acc: 0.8120 Epoch 8/15 | Train Loss: 0.3305 | Train Acc: 0.8592 | Val Loss: 0.3127 | Val Acc: 0.8741 Epoch 15/15 | Train Loss: 0.2424 | Train Acc: 0.8988 | Val Loss: 0.2428 | Val Acc: 0.902715 轮训练结束,验证集准确率大约 90% 左右。对一个从零训练的浅层 CNN 来说,这个结果完全合格。如果你想继续提升,后面可以做两件事:一是用 ImageNet 预训练的 ResNet 做迁移学习,二是把输入分辨率提高到 224x224。两种方法都能轻松把准确率推到 95% 以上,但作为理解 CNN 原理的实战,自己从头训练一轮的价值是不可替代的。
4.5 训练过程中值得观察的现象
训练时你会发现几个有意思的规律,提前说给你,省得自己琢磨。
第一,前几轮 loss 下降很慢但准确率飙升,这是因为网络一开始在学全局的明暗分布、粗略轮廓这种简单的模式。第二,训练准确率和验证准确率之间的差距会逐渐拉开,如果前者高后者低太多,就是过拟合信号,后面第 6 节会专门讲。第三,loss 曲线如果出现震荡,可能是学习率偏大,可以降到 0.0003 试试。
5. 模型评估与可视化:别只盯着准确率一个指标
5.1 训练曲线绘制:判断模型是否健康的仪表盘
训练结束后,建议把 loss 和准确率的变化画出来。这一步不复杂,但对判断模型状态非常有帮助。把每一轮的 train_loss、val_loss、train_acc、val_acc 存到列表里,然后用 matplotlib 绘图。
import matplotlib.pyplot as plt # 假设 history 里存了每轮的指标 plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) plt.plot(train_losses, label='Train Loss') plt.plot(val_losses, label='Val Loss') plt.xlabel('Epoch') plt.ylabel('Loss') plt.legend() plt.title('Loss Curve') plt.subplot(1, 2, 2) plt.plot(train_accs, label='Train Acc') plt.plot(val_accs, label='Val Acc') plt.xlabel('Epoch') plt.ylabel('Accuracy') plt.legend() plt.title('Accuracy Curve') plt.tight_layout() plt.show()看曲线的时候重点关注:val_loss 是否在前几轮持续下降(正常),到后期是否反弹上升(过拟合信号),训练和验证曲线是否贴合紧密(贴合越好泛化越好)。如果 val_loss 从某个 epoch 开始持续上升而 val_acc 停滞甚至下降,就说明模型开始记住了训练集中的噪声,应该提前停止训练或使用早停策略。
5.2 混淆矩阵与错误样本分析:找出模型真正的弱点
准确率 90% 听起来不错,但光看一个数字是不够的。我强烈建议你做一个混淆矩阵,看看模型到底把哪些样本搞错了。这里以猫为 positive 类,模型的结果有四种:真正例(猫判对)、真负例(狗判对)、假正例(狗被误判为猫)、假负例(猫被误判为狗)。
用 sklearn 可以直接计算:
from sklearn.metrics import confusion_matrix, classification_report all_preds = [] all_labels = [] model.eval() with torch.no_grad(): for images, labels in val_loader: images = images.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) all_preds.extend(predicted.cpu().numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) print("混淆矩阵:") print(cm) print(classification_report(all_labels, all_preds, target_names=['cats', 'dogs']))我跑出来的混淆矩阵大致是这样:
[[1052 148] [ 132 1068]]也就是说,2500 张验证图里,有 148 张猫被当成狗,132 张狗被当成猫。误判比例差不多,说明模型没有明显的类别偏好,这是比较健康的状态。如果你发现某一类误判特别多,大概率是训练数据里这一类图片不够多样,或者类别本身在视觉上更容易混淆。
把错误样本可视化出来看看往往是洞察力的来源。你可以把预测错误的图片保存并写上图注,比如“猫被预测为狗,置信度 0.82”。你会发现被认错的往往是毛发很短、脸型尖、耳朵不明显的猫,以及看起来像猫的狗。这很正常,某类样本本身的类间差异就小于类内差异,换人类来判可能也会犹豫。
5.3 用训练好的模型做单张图片预测
模型训练好之后,最终要能对新图片做出预测。下面这段代码把加载模型和单张预测封装成一个可复用的推理脚本:
from PIL import Image def predict_image(image_path, model, device, class_names): model.eval() image = Image.open(image_path).convert('RGB') transform = transforms.Compose([ transforms.Resize((128, 128)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) tensor = transform(image).unsqueeze(0).to(device) with torch.no_grad(): output = model(tensor) probability = torch.softmax(output, dim=1) _, predicted = torch.max(output, 1) class_idx = predicted.item() confidence = probability[0][class_idx].item() return class_names[class_idx], confidence class_names = ['cats', 'dogs'] result, confidence = predict_image('test_photo.jpg', model, device, class_names) print(f"预测结果:{result},置信度:{confidence:.4f}")注意两个细节:预测时也要做和验证集相同的预处理(resize + normalize),并且要加 unsqueeze(0) 把单张图片扩展成 batch 维度,因为网络输入要求四维张量 (batch, channels, height, width)。softmax 可以帮你看到模型对每个类别的置信度,这个值对调试很有用——如果模型对一张图只有 0.55 的置信度,说明它自己也很不确定,这种样本值得你去看看有什么特殊性。
6. 常见问题与排查技巧实录
6.1 模型完全不收敛:loss 不降或准确率原地不动
这是新手遇到的第一个拦路虎。模型 loss 一直在 0.7 左右徘徊(二分类随机猜测约等于 0.693),准确率在 50% 上下,说明模型根本没在学。
优先排查顺序:
- 检查标签是否对了。打印几个 batch 的 labels,对照图片看是否存在标签错乱。我自己就遇到过 ImageFolder 的类别顺序和想象中相反的情况。
- 检查学习率。学习率太大(比如 0.1)会导致 loss 剧烈震荡不下降,太小(比如 0.00001)则收敛慢得几乎看不出变化。0.001 是 Adam 的稳妥起点。
- 检查数据预处理。一个经典错误是忘了归一化,导致输入数值范围过大,梯度计算不稳定。
- 确认模型在训练模式下调用了 model.train()。如果忘了,Dropout 不生效,但这通常不会导致完全不收敛,排除优先级靠后。
如果以上都没问题,可以在一个 batch 上做快速试验:喂 4 张图,训练 50 轮,看 loss 能不能降下去。能降到很低,说明代码逻辑对,问题出在数据或超参数上;不能降,问题在模型或代码本身。
6.2 过拟合严重:训练准确率很高,验证准确率上不去
训练集准确率 95%,验证集只有 75%,这是过拟合的典型表现。模型把训练集的特征记死了,没有学到可迁移的通用模式。
应对策略按优先级排序:
- 加强数据增强。随机旋转(Rotation)、随机裁剪(RandomResizedCrop)、颜色抖动(ColorJitter)能大幅扩充样本多样性。这是我试过最有效的招数。
- 增大 Dropout。把 Dropout 从 0.5 提到 0.7,代价是训练收敛变慢,但泛化能力会变好。
- 降低模型复杂度。减少卷积层的通道数,比如 64 降到 32。
- 早停。观察 val_loss,一旦连续几个 epoch 不降反升,就停止训练,用之前保存的最佳模型。
6.3 显存不足:CUDA out of memory
训练到一半报 CUDA out of memory 是家常便饭。最直接的解决办法是把 batch_size 从 32 降到 16 或 8。如果还不行,把输入尺寸从 128 降到 96 或 64。这两个参数直接决定模型前向传播时的显存占用。
另外一个容易被忽略的点:在验证集上评估时,一定要包在 torch.no_grad() 里面。如果不包,每一次验证都会额外计算并保存梯度图,显存占用会翻倍甚至更多。我见过有人把训练和验证写在一起结果撑爆显存,加个 no_grad 就好了。
6.4 常见问题速查表
| 现象 | 可能原因 | 推荐解法 |
|---|---|---|
| Loss 不降,准确率约 50% | 学习率不当 / 标签错误 | 打印标签核对,调整学习率到 0.001 |
| 训练准确率高,验证准确率低 | 过拟合 | 数据增强、增大 Dropout、早停 |
| CUDA out of memory | batch_size 过大 | 减小 batch_size 或输入尺寸 |
| 验证集准确率震荡剧烈 | 学习率偏大 | 降到 0.0003 或使用学习率衰减 |
| 模型预测全部是同一个类别 | 数据集类别严重不均衡 | 检查数据分布,考虑类别加权损失 |
| 加载预训练权重时报尺寸不匹配 | 最后一层类别数不一致 | 只加载除最后一层外的权重 |
6.5 可复现性设置:实验结果对不上的根源
还有一个很容易被忽略的坑:可复现性。你会发现写了 set_seed(42) 之后,每次跑的 loss 曲线好像还是不一样。这是因为 PyTorch 的某些操作(尤其是 GPU 上的卷积)本身就不是完全确定的。想要最大程度复现,需要额外设置:
torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = Falsebenchmark=False 会禁用 cuDNN 的自动算法搜索,虽然可能让训练慢一点,但能提高确定性。另外 DataLoader 的 num_workers 大于 0 时,多进程会引入随机性,可以临时把 num_workers 设为 0 排查。
7. 进阶方向与个人实战心得
7.1 从猫狗分类走向真实项目
跑通猫狗分类之后,你就有了迁移到真实项目的基础能力。图像识别在工业界的落地方向非常多:质检领域用 CNN 检测产品表面缺陷(划痕、脏污、缺料);医疗领域用 CNN 对病理切片或 X 光片做辅助诊断;农业领域用无人机拍摄的农田图像识别病虫害;安防领域做人脸检测与属性识别。我在实际项目里发现,通往这些方向最短的路径是“迁移学习”:加载一个在大规模数据集上预训练好的 ResNet 或 EfficientNet 模型,替换最后的分类层,用自己的数据微调。这样做的好处是小数据集也能取得相当好的效果,而且收敛速度极快。
迁移学习的代码改动很小,核心是把模型的最后一层换掉:
import torchvision.models as models # 加载预训练权重 model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) # 替换最后一层全连接,输出 2 类 model.fc = nn.Linear(model.fc.in_features, 2)冻结前面的卷积层(设置 requires_grad=False),只训练最后一层,几分钟就能看到效果;解锁全部层用更小的学习率微调,还能进一步提升。这个路线在 Kaggle 比赛中被反复验证过:用好预训练模型 + 数据增强,几乎碾压一切从零训练的方案。
7.2 模型部署:从训练脚本到实际可用
训练好的模型最终要服务真实场景。简单的做法是导出成 TorchScript 或 ONNX 格式,放到推理服务里:
# 导出 ONNX model.eval() dummy_input = torch.randn(1, 3, 128, 128).to(device) torch.onnx.export(model, dummy_input, "cat_dog_model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}})ONNX 格式可以转成 TensorRT 在 NVIDIA GPU 上加速推理,也可以放进 OpenCV 里用 DNN 模块加载,方便集成到 C++ 程序。对于移动端或嵌入式设备,可以用 PyTorch Mobile 或量化技术把模型压缩到很小的体积。这个过程单写一篇文章都不够,你只需要知道:训练只是第一步,部署才是真正让模型产生价值的关键环节。
7.3 最后分享几点个人体会
写到这,我想说几句踩坑之后的真实感受。第一,不要小看这个猫狗分类项目,我见过太多人上来就想复现 YOLO、算法岗真题,结果连最基本的训练循环都没跑明白,最后啥也没学会。把 CNN 全流程彻底吃透,后面学目标检测、图像分割都会快很多。第二,训练过程中遇到问题先打印数据流,我绝大多数 bug 都是靠把 inputs 和 labels 打印出来、看 shape 和范围,就找到根源了。不要盲猜。第三,多看一眼 loss 曲线,它会坦诚地告诉你模型到底学得怎么样,比一堆抽象的指标直观得多。
这个项目你做完之后,我强烈建议你继续做两个方向的扩展:换一个更复杂的数据集(比如 CIFAR-100 或食物分类)重跑一遍流程,以及用预训练模型做迁移学习对比效果差异。两次跑下来,你对 CNN 的理解会出现一次真正的质变。