恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
PyTorch图像分类实战:从CNN搭建到CIFAR-10模型训练与推理
首页
资讯中心
/
PyTorch图像分类实战:从CNN搭建到CIFAR-10模型训练与推理
PyTorch图像分类实战:从CNN搭建到CIFAR-10模型训练与推理
发布时间:2026/10/10 7:00:19
图像分类是深度学习入门绕不开的第一个完整落地场景。我见过很多新手朋友从张量操作、反向传播一路学过来但真正打开PyTorch、加载一批图片、把训练循环跑通、最后看到准确率升上去——这中间的距离比想象中要大不少。这篇内容我打算直接用一套完整的代码实战来带大家走一遍图像分类的全流程环境准备、数据集处理、模型搭建、训练评测、模型保存与加载以及最后的单张图片推理预测。无论你是刚学完PyTorch基础、想趁热打铁做个小项目还是工作需要快速验证某个分类想法都可以把这套代码当成一个可以直接改、可以直接用的模板。全程我会尽量把每一步的“为什么”讲清楚而不只是给你一堆能跑的代码。1. 项目整体设计与思路拆解1.1 图像分类任务的核心闭环图像分类这个任务说白了就是给机器看一张图让它告诉你“这张图是什么”。以最常见的CIFAR-10数据集为例里面一共有10个类别飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车每个类别6000张32x32的彩色图片。这个数据集在学术界和工业界都被广泛用作算法验证的基准非常适合作为第一棵“技能树”来修炼。一个完整的图像分类项目在我看来可以拆成四个环节数据准备、模型设计、训练优化、评估落地。这四个环节缺一不可。很多人容易陷入一个误区觉得图像分类就是“把模型跑出来、准确率差不多就行”但实际工程里数据怎么组织、训练过程怎么监控、过拟合怎么处理、模型怎么保存和复用这些细节才真正决定你能否把项目从实验台推到生产环境。拿数据准备来说很多人一开始忽略了一个关键点PyTorch的Dataset和DataLoader这两个组件决定了你的数据是以什么方式、什么速度送入模型的。数据加载和预处理如果不处理好模型训练基本就是“在漏水的水桶里加水”。1.2 为什么选用PyTorch框架选择上我强烈推荐PyTorch。原因有三第一动态计算图非常友好调试的时候可以在训练循环里随意打印中间变量对于理解模型内部发生的事情极其有帮助第二生态成熟不管是torchvision内置的数据集和预训练模型还是第三方的扩展库几乎你能想到的深度学习需求都有人实现过第三社区影响力越来越大很多前沿研究和工业落地都基于PyTorch你踩过的坑大概率别人已经踩过这意味着你有更多经验可以参考。具体到图像分类这个任务PyTorch的优势更加明显。torchvision.datasets帮你封装好了常用数据集的一键下载和格式解析torchvision.transforms提供了一套链式数据增强工具torchvision.models则是把经典网络结构的实现直接摆在你的面前。你不用再从零开始写ResNet的每一个block但这也不代表你可以完全不懂它的内部机制。1.3 方案选型的整体考量我在这套实战里采用的方案是自定义一个简洁的卷积神经网络CNN来完成CIFAR-10分类。为什么不直接用ResNet之类的现成模型因为既然是“轻松上手”的实战我更希望大家先把CNN的基本组件卷积层、池化层、全连接层串联清楚把模型的输入输出维度算明白再去看复杂的网络结构就会轻松很多。等你跑通了这个小模型再去替换成ResNet18甚至更深的网络不过就是改几行代码的事情。训练方面我选用了SGD优化器加交叉熵损失函数。SGD虽然简单配合Momentum之后在CIFAR-10上表现稳健而且超参数的解释性强。至于Adam如果你更喜欢用也没问题但在这类任务上SGDMomentum的经典组合往往能取得更好的最终效果这也是业界很常见的经验。2. 环境准备与数据集的获取2.1 开发环境搭建工欲善其事必先利其器。对于PyTorch项目的开发环境我的建议是直接使用Anaconda来管理。Anaconda能很方便地创建独立的Python虚拟环境避免不同项目之间的包版本冲突。# 创建并激活虚拟环境 conda create -n cifar10 python3.10 conda activate cifar10 # 安装PyTorchCPU版本 pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu如果你有NVIDIA显卡装CUDA版本会更适合实际训练。注意安装前先去NVIDIA官网查一下自己的显卡驱动支持哪个CUDA版本然后选择对应的PyTorch安装命令。如果你一开始不确定先用CPU版本跑通代码逻辑之后有需要再换成GPU版本代码本身不需要改动。判断环境是否装好可以在Python里执行下面这行代码import torch print(torch.__version__) print(torch.cuda.is_available()) # 没有GPU会输出False这里提醒一下CPU版本和GPU版本在代码层面基本无差别唯一的区别在于设备指派。我们用device torch.device(cuda if torch.cuda.is_available() else cpu)统一处理就好这样代码在两种环境都能运行。2.2 下载并解析CIFAR-10数据集使用torchvision内置接口是获取CIFAR-10最便捷的途径。下载好的数据会存在./data目录下面第一次运行会自动下载之后再次运行就直接从本地读取。from torchvision import datasets, transforms # 定义数据预处理流程 transform_train transforms.Compose([ transforms.RandomCrop(32, padding4), # 随机裁剪并填充4像素 transforms.RandomHorizontalFlip(), # 随机水平翻转 transforms.ToTensor(), # 转为Tensor像素值归一化到[0, 1] transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), # 标准化 ]) transform_test transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), ]) train_dataset datasets.CIFAR10(root./data, trainTrue, downloadTrue, transformtransform_train) test_dataset datasets.CIFAR10(root./data, trainFalse, downloadTrue, transformtransform_test)这套预处理流程是CIFAR-10的标准操作但我希望大家不要只是复制粘贴而是知道每一步的意义RandomCrop(32, padding4)先把原图向外扩4个像素再随机裁剪成32x32。这样做相当于人为制造了更多的训练样本因为同一张图每次训练时可能被裁剪出不同的区域有效扩大了数据量。RandomHorizontalFlip()以50%的概率把图片水平翻转。对于CIFAR-10里的绝大多数类别水平翻转并不会改变语义类别猫翻过来还是猫这种手段能进一步增强模型的泛化能力。ToTensor()把PIL图片转成形状为(3, 32, 32)的Tensor同时把像素值从0到255的整数压缩到0到1的浮点数。Normalize()对三个颜色通道做标准化。括号里的四个数字分别是RGB三个通道的均值和标准差这些数值是CIFAR-10整个数据集的统计值。标准化之后的像素分布大致服从均值为0、方差为1的正态分布可以加速模型的收敛这是深度学习中非常基础也非常重要的一步。注意测试集不需要随机裁剪和翻转只需要把图片转成Tensor并做标准化即可。训练集和测试集的预处理要保持一致的前提是模型在训练时见过某种分布的数据测试时也应该面对同样的分布否则评估结果会失真。2.3 DataLoader的封装与Batch训练的意义数据集准备好之后下一步就是封装成DataLoader方便训练时按批次读取数据。from torch.utils.data import DataLoader BATCH_SIZE 64 train_loader DataLoader(train_dataset, batch_sizeBATCH_SIZE, shuffleTrue, num_workers2) test_loader DataLoader(test_dataset, batch_sizeBATCH_SIZE, shuffleFalse, num_workers2)DataLoader做的事情简而言之就是“按批次喂数据”。shuffleTrue在训练时很重要它会在每个epoch开始前把数据打乱避免模型学到样本之间的顺序信息。shuffleFalse用于测试集这样在评估时我们可以按顺序对比预测结果和真实标签。关于num_workers这是控制数据加载的并行子进程数量。Windows用户需要注意如果num_workers设置大于0可能会遇到多进程相关的报错或卡顿一个常见解决方式是把num_workers设为0或者将代码放到if __name__ __main__:块里面运行。CPU版本训练时num_workers对训练速度的影响不如GPU环境明显所以设置成2基本够用。Batch Size的选择是训练中很关键的超参数极大影响训练速度和显存占用。BATCH_SIZE 64在CIFAR-10上是一个比较均衡的选择如果显存不够可以降到32如果GPU性能富余也可以调到128但要注意学习率可能需要相应调整。Batch Size和学习率存在联动关系通常Batch越大学习率可以适当调高——但这里不展开先按常规设置走。3. 神经网络模型搭建与维度推演3.1 CNN核心组件原理解析搭建模型之前先花几分钟梳理卷积神经网络中最核心的三个操作卷积、池化、全连接。这三者分工明确卷积层用一组可学习的滤波器Filter在图像上滑动提取局部特征。底层卷积提取边缘、颜色等低层特征高层卷积能组合出眼睛、轮子等更抽象的语义特征。池化层对特征图进行下采样保留区域内的主要信息。最常用的最大池化Max Pooling取区域内最大值既能压缩特征图尺寸减少后续计算量又能在一定程度上带来平移不变性。全连接层把卷积层输出的二维特征图展平成一维向量再映射到最终的类别得分上。它相当于一个“分类器”负责把模型“看到”的特征综合起来判断属于哪个类别。类比一下卷积层像是一群拿着不同“滤镜”的观察员在图片上到处扫视总结出各种局部特征池化层像是一个“信息过滤网”扔掉多余细节只保留最重要的信息全连接层则是最终的“决策委员会”根据所有观察员的汇报投票得出结果。3.2 定义轻量CNN模型下面是一个适合CIFAR-10的轻量级CNN结构信息量足够但参数规模不算大很适合入门理解。import torch.nn as nn import torch.nn.functional as F class SimpleCNN(nn.Module): def __init__(self, num_classes10): super(SimpleCNN, self).__init__() self.conv1 nn.Conv2d(3, 32, kernel_size3, padding1) # 输入3通道输出32通道 self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(64 * 8 * 8, 256) self.fc2 nn.Linear(256, num_classes) self.dropout nn.Dropout(0.3) def forward(self, x): # x形状: (batch_size, 3, 32, 32) x self.pool(F.relu(self.conv1(x))) # 输出: (batch, 32, 16, 16) x self.pool(F.relu(self.conv2(x))) # 输出: (batch, 64, 8, 8) x x.view(x.size(0), -1) # 展平: (batch, 64*8*8) x self.dropout(F.relu(self.fc1(x))) # (batch, 256) x self.fc2(x) # (batch, 10) return x3.3 输入输出尺寸推演细节这个模型里有一个初学者最容易迷糊的地方全连接层的输入维度64 * 8 * 8是怎么来的我来完整推演一遍。输入图像是(3, 32, 32)即3个通道、宽高都是32像素。经过第一个卷积层conv1(3 - 32, kernel_size3, padding1)后因为padding1不会改变空间尺寸输出是(batch, 32, 32, 32)也就是32个通道、宽高仍然是32。紧接着的池化层MaxPool2d(2, 2)把宽高各缩小一半所以输出变成(batch, 32, 16, 16)。经过第二个卷积层conv2(32 - 64)通道数变为64空间尺寸不变输出是(batch, 64, 16, 16)。再经过一次池化宽高从16变为8输出变为(batch, 64, 8, 8)。把这三维展平成一维就是64 * 8 * 8 4096。所以fc1的输入特征数是4096输出256fc2再把256映射到10对应10个类别。如果你以后换了一个输入尺寸不同的数据集千万别直接套用这个全连接维度而是先自己算清楚。一个更好的做法是用nn.AdaptiveAvgPool2d或者把展平操作写在forward里动态计算上面代码用的就是动态展平这已经比硬编码要灵活。但无论怎么写你都要对模型内部的数据流动有清晰的认识这是排查维度报错的基础。3.4 Dropout的作用我在fc1之后加了nn.Dropout(0.3)这又是很多人忽视的一个细节。Dropout在训练时随机“丢弃”一定比例的神经元这里概率是30%让网络不能过度依赖某一个神经元从而强制模型学到更鲁棒的特征。它的效果相当于在训练很多个不同的子网络并在测试时取平均是缓解过拟合非常有效的利器。这里有一个重要提醒nn.Dropout只在训练模式生效。PyTorch中模型默认是训练模式调用model.eval()后会切换为评估模式此时Dropout自动被禁用。如果你忘记调用model.eval()就去做验证Dropout依然在随机丢弃神经元导致测试结果不稳定这属于很隐蔽又常见的坑。4. 训练流程完整拆解4.1 损失函数与优化器选择训练本质上是“最小化损失函数”的过程。图像分类最常用的损失函数是交叉熵损失CrossEntropyLoss。它可以直观理解为模型给正确类别分配的概率越高损失越低如果模型把高置信度给了错误类别损失就会剧增。PyTorch中nn.CrossEntropyLoss封装了两个步骤先对模型输出的原始分数Logits做Softmax归一化成概率分布再计算预测和真实标签之间的交叉熵。所以使用它时forward的最后输出不要手动加Softmax否则效果会出问题。优化器选择了带动量的SGDimport torch.optim as optim model SimpleCNN() device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9)两个超参数说明一下lr0.01学习率控制每次参数更新的步长。学习率太大会导致loss震荡不收敛太小会让收敛速度慢到让人失去耐心。momentum0.9动量让梯度更新“带上惯性”可以越过局部极小值同时加快收敛。这是一个被广泛验证的稳健配置。如果之后想尝试更快的收敛可以考虑余弦退火学习率调度器torch.optim.lr_scheduler.CosineAnnealingLR但不建议第一步就把步子迈太大先能稳定跑通最重要。4.2 训练循环的标准写法训练一个epoch的标准流程大致是这样的遍历训练数据loader中的每一个batch取出图片和标签前向传播得到预测结果计算损失反向传播求梯度再用优化器更新权重最后清空旧的梯度。def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() running_loss 0.0 correct 0 total 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) # 清空上一轮计算的梯度 optimizer.zero_grad() # 前向传播 损失计算 outputs model(images) loss criterion(outputs, labels) # 反向传播 参数更新 loss.backward() optimizer.step() # 统计本batch的loss和准确率 running_loss loss.item() * images.size(0) _, predicted outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() epoch_loss running_loss / total epoch_acc correct / total return epoch_loss, epoch_acc这中间有一个新手最容易犯的错误忘记写optimizer.zero_grad()。如果不把上一batch的梯度清空梯度就会不断累加参数更新方向会变得非常诡异loss的表现也会时好时坏。optimizer.step()则是在此基础上真正执行参数更新。评估函数和训练函数差不太多但有几个关键点不一样需要model.eval()切换评估模式让Dropout等训练专用机制失效。整个评估过程不需要计算梯度用with torch.no_grad():包裹能省下大量显存和计算时间。def evaluate(model, test_loader, device): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() return correct / total4.3 训练主循环与可视化日志完整的训练主循环如下我加入了一些日志输出这是监控模型状态最简单直接的方式。num_epochs 50 for epoch in range(1, num_epochs 1): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) test_acc evaluate(model, test_loader, device) print(fEpoch [{epoch}/{num_epochs}] fTrain Loss: {train_loss:.4f} fTrain Acc: {train_acc:.4f} fTest Acc: {test_acc:.4f})输出大概会长这样Epoch [1/50] Train Loss: 1.5789 Train Acc: 0.4160 Test Acc: 0.4342 Epoch [10/50] Train Loss: 0.9521 Train Acc: 0.6512 Test Acc: 0.6701 Epoch [30/50] Train Loss: 0.5679 Train Acc: 0.7968 Test Acc: 0.7714 Epoch [50/50] Train Loss: 0.4562 Train Acc: 0.8352 Test Acc: 0.7993随着训练进行train loss应该稳步下降train acc和test acc逐渐升高。如果发现train acc一路飙升但test acc停滞甚至下降这就是典型的过拟合信号说明模型把训练数据“背”下来了而不是学会了通用的特征。应对过拟合的方法包括增加数据增强强度、提升Dropout概率、使用权重衰减weight_decay参数、或者简化模型结构。5. 模型保存、加载与单张图片推理预测5.1 模型保存的两种正确姿势训练完成后模型参数都保存在当前进程的model.state_dict()里但进程一结束参数就没了。要把训练成果持久化最常用的方式是保存state_dict这是推荐的做法因为它只存参数占用空间小加载灵活。# 保存 torch.save(model.state_dict(), simple_cnn_cifar10.pth) # 加载方式1先实例化模型再load_state_dict model SimpleCNN() model.load_state_dict(torch.load(simple_cnn_cifar10.pth, map_locationdevice)) model.to(device)另一个方式是保存整个模型对象torch.save(model, simple_cnn_cifar10_full.pth)我不太推荐第二种方式因为保存整model会把类定义的代码路径也绑定在一起如果之后你改了模型类的文件路径或者类名再加载往往会报错。需要注意的是load_state_dict必须传入一个OrderedDict参数而不是模型对象。如果你在加载时遇到Missing key(s)或者Unexpected key(s)的报错大概率是保存时用的是torch.save(model, ...)而加载时却用了load_state_dict两者格式对不上。5.2 单张图片的预处理与推理训练和评估都搞定之后很多人会想拿一张自己的图片去让模型预测一下类别感觉。这时候需要特别注意单张图片的预处理必须和训练时的测试集预处理完全一致否则模型看到的分布和训练时不匹配预测结果会出现明显偏差。举个例子假设我手头有一张名为cat.jpg的图片我要用训练好的模型来预测它属于哪一类from PIL import Image import torchvision.transforms as transforms def predict_image(image_path, model, device): # 注意这里的transform必须和测试集一致 transform transforms.Compose([ transforms.Resize((32, 32)), # 保证输入尺寸符合模型要求 transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), ]) image Image.open(image_path).convert(RGB) image transform(image).unsqueeze(0) # 增加batch维度形状从(3,32,32)变为(1,3,32,32) image image.to(device) model.eval() with torch.no_grad(): outputs model(image) _, predicted outputs.max(1) classes [airplane, automobile, bird, cat, deer, dog, frog, horse, ship, truck] return classes[predicted.item()]这里有一个非常关键、却常被忽视的点unsqueeze(0)这行代码。模型在训练时接收的是一个batch的数据形状是(batch_size, 3, 32, 32)所以即使你只有一张图也要手动增加一个batch维度变成(1, 3, 32, 32)。另外如果实际项目的图片不是正方形直接用Resize((32, 32))可能会拉伸变形。更稳的做法是用transforms.CenterCrop(32)先裁剪成正方形再Resize或者组合使用transforms.Resize(32)和transforms.CenterCrop(32)。但CIFAR-10本身就是32x32的小图所以这里直接Resize问题不大。5.3 GPU与CPU环境的加载兼容在GPU上训练好的模型如果要放到没有GPU的机器上做推理需要这样处理model SimpleCNN() model.load_state_dict(torch.load(simple_cnn_cifar10.pth, map_locationtorch.device(cpu)))如果加载时不做map_location指定PyTorch默认按保存时的设备去加载在纯CPU环境可能会报找不到CUDA设备的错误。反过来CPU环境保存的模型想在GPU上加载通常不会有问题但加上map_location总是一个好习惯。6. 训练过程中的问题排查与避坑心得6.1 维度不匹配问题这是Deep Learning新手最常遇到的毛病。假如你在跑模型时看到类似这样的报错RuntimeError: mat1 and mat2 shapes cannot be multiplied (64x2048 and 4096x256)这说的就是全连接层的输入维度和模型定义的维度对不上。2048这个数字说明展平之后特征数不是4096。解决办法仔细检查输入图像尺寸、卷积核和padding设置、池化层数量重新推演一遍维度把全连接层的in_features改过来。为了避免这类问题我给大家一个偷懒但有效的技巧在模型定义之后用一张形状相符的假数据做一次前向测试。# 用随机数据验证维度 dummy_input torch.randn(1, 3, 32, 32) model SimpleCNN() output model(dummy_input) print(output.shape) # 期望输出 torch.Size([1, 10])只要这一步能通过模型内部的前向传播就是通顺的剩下的问题无非是训练逻辑层面的。6.2 损失不下降的原因排查如果你发现训练过程中loss一直高居不下不降反升或者干脆是nan可以从下面几个方向依次排查学习率是否过大把lr尝试调小到0.001或者0.0001看看loss是否开始下降。数据规范化是否正确Normalize的均值方差数值如果填错输入分布完全偏离正常范围模型很难学习。标签和数据是否对得上有些人自定义数据集时标签索引错位模型接收的监督信号本身就是错的。打印几个样本的images.shape和labels验证一下。是否存在梯度爆炸在loss.backward()之后查看一下梯度的范数是否异常也可以用torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)做梯度裁剪但CIFAR-10这个简单场景基本用不上。6.3 过拟合的主要应对策略我在前面提过过拟合的外在表现是train acc远高于test acc。应对策略按推荐顺序排列增加数据增强强度比如RandomRotation、ColorJitter、RandomAffine等让训练数据更多样化。增大Dropout概率从0.3调到0.5试一下尤其当模型参数量较大的时候。加入权重衰减optim.SGD(..., weight_decay5e-4)这相当于对较大的参数做惩罚是简单有效的正则手段。使用更小的模型如果数据量不大参数量过大的模型更容易过拟合。6.4 训练速度太慢的优化方案这里给出三个提高训练效率的实用建议启用CUDA只要设备支持model model.to(cuda)后训练速度会提升几十倍不止这是质的飞跃。合理设置num_workers数据加载是多进程的如果CPU核心数够多把num_workers设为4或8都能有效减少GPU等待数据的时间。检查是否用了pin_memoryTrue在DataLoader中设置pin_memoryTrue当使用GPU时数据传输效率会有明显提升。train_loader DataLoader(..., batch_size64, shuffleTrue, num_workers2, pin_memoryTrue)7. 进阶方向与经验总结当你跑通上面这套全流程后我觉得至少你已经掌握了图像分类项目的核心骨架数据怎么吃进来、模型长什么样、训练怎么循环、结果怎么评估、模型怎么复用。接下来想进阶有几个方向可以试试。第一个方向是替换更强的网络结构。把SimpleCNN换成torchvision.models.resnet18(pretrainedFalse)输入尺寸改成224x224或者直接用CIFAR-10原始尺寸加上适当的全局池化。你会发现强大的网络结构带来的提升是立竿见影的但对显存和训练时间的要求也会同步上升。第二个方向是引入迁移学习。加载在ImageNet上预训练好的ResNet18或MobileNetV2权重替换最后的全连接层然后只训练全连接层或微调部分层。在数据量小的场景下迁移学习的泛化能力明显优于从零训练。第三个方向是尝试训练过程的可视化监控。用TensorBoard记录每个batch的loss和验证集准确率曲线观察训练过程中的动态变化这对调试和分析模型行为非常有帮助。最后我再耐心多说一句深度学习不是玄学每一步操作都有它的道理出了问题也有清晰的排查路径。希望你用这套代码跑通一次之后能把它当成自己的工具箱根据自己的任务去改数据接口、改模型结构、改训练策略。代码跑通只是起点真正理解每一行的意义才是你在这条路上走得更远的底气。