ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PyTorch实战:用CIFAR10跑通第一个深度学习项目

PyTorch实战:用CIFAR10跑通第一个深度学习项目 经常有人问我深度学习入门后的第一个完整项目怎么选。我的答案一直很固定跑一个 CIFAR10 分类模型。原因其实很直白——MNIST 上手太顺28x28 的灰度数字图居中、干净、无背景跑通了你也没什么体感真想上手 ImageNet光数据准备和训练时间就能劝退大半新手。CIFAR10 夹在中间刚刚好6 万张 32x32 的彩色图像10 个常见类别数据量撑得起一次完整的训练循环训练速度又足够温和普通笔记本也能扛得住。这篇文章我会带你从零开始做三件事搭建 PyTorch 环境、设计并训练一个简单的卷积神经网络、把训练好的模型正确保存下来。跑完这一整套流程你基本就走完了深度学习项目里“数据—模型—训练—存储”的全部闭环之后再换任何数据集、任何模型都是这套框架的延伸。1. 为什么我建议把 CIFAR10 作为第一个完整项目1.1 这个数据集到底长什么样CIFAR10 是由 Hinton 的学生 Alex Krizhevsky 等人整理的小型图像数据集最早是作为“图像分类入门测试集”设计的。它包含 10 个类别飞机、汽车、鸟、猫、鹿、狗、青蛙、马、轮船、卡车。每个类别 6000 张图其中 50000 张训练、10000 张测试图片是 32x32 的 RGB 彩色图。32x32 意味着什么对比一下MNIST 是 28x28 单通道灰度ImageNet 常见输入是 224x224 三通道。32x32 的图对算力非常友好一次前向传播的计算量很小哪怕没有 GPU、纯 CPU 训练跑十几个 epoch 也就在一顿饭的功夫内。这意味着你可以反复调整结构、参数快速验证想法而不是改一次配置等两个小时。1.2 完成这个项目你实际学会了什么我觉得很多人低估了这个项目的“闭环价值”。跑通 CIFAR10 之后你真正掌握的不是某一行代码而是一整套工作流会用 torchvision 加载公开数据集理解 Dataset 和 DataLoader 的分工会写一个自己的 nn.Module 子类搞清楚卷积、池化、全连接层怎么拼知道训练循环里每个环节为什么存在zero_grad、forward、backward、step会区分训练集和验证集的作用而不是只在训练集上刷一个自欺欺人的精度理解模型保存的几种方式以及权重文件为什么不能随便乱存。这套工作流可以无缝迁移到其他分类任务比如你自己拍的一堆猫狗照片、工业零件缺陷图、遥感影像分类。模型结构可能要改数据加载要换接口但整体骨架完全一样。所以我一直认为CIFAR10 不是“玩具项目”它是你整个深度学习实践能力的地基。2. 环境搭建先把 PyTorch 跑起来再谈其他2.1 创建独立的 Python 环境我强烈建议用 Anaconda 管理 Python 环境不要直接在你的系统 Python 里装包。原因很简单深度学习库的依赖非常敏感torch 和 torchvision 的版本需要相互匹配numpy 的版本也会影响运行结果。直接用系统 Python 装过两个月你再装别的项目大概率会撞依赖。创建并激活环境conda create -n cifar10 python3.10 -y conda activate cifar10这里我选择 Python 3.10因为它在稳定性、第三方库兼容性上处于一个很好的平衡点。Python 3.11、3.12 当然也能用但没必要给自己增加不必要的兼容性风险。2.2 安装 PyTorch 关键一步进入环境后安装 torch 和 torchvision顺便装上 matplotlib后面可视化会用到pip install torch torchvision matplotlib如果你用的是 NVIDIA 显卡并且已经装好了 CUDAPyTorch 会自动检测可用的 GPU 版本。如果暂时不打算用 GPUCPU 版本也完全能跑通本项目只是训练会慢一些。装完之后立刻验证环境是否正常import torch import torchvision print(torch.__version__) print(torchvision.__version__) print(torch.cuda.is_available())如果最后一行为 True说明你的 GPU 可用后续训练会自动使用它。如果为 False也没关系代码依然能跑只是慢一些。我第一次做这个项目就是在 CPU 上跑的20 个 epoch 大约等了半小时完全在可接受范围内。提示安装时如果网络状况不佳导致下载很慢可以先配置国内的 pip 镜像源再执行上面的安装命令。镜像源和 PyTorch 官方版本完全一致不影响后续使用。2.3 数据集下载让 torchvision 帮你搞定环境装好后第一次运行项目时会触发数据集下载。你可以先单独执行一次下载避免训练脚本里下载出错时半天排查不明白from torchvision import datasets train_set datasets.CIFAR10(root./data, trainTrue, downloadTrue) test_set datasets.CIFAR10(root./data, trainFalse, downloadTrue)执行后PyTorch 会自动下载cifar-10-python.tar.gz并解压到./data目录。文件大约 170MB耐心等一会就好。如果下载过程网络异常导致中断删掉那个未完成的 tar 文件重新跑一遍即可这是非常常见的坑。3. 数据流水线从原始图片到训练批次3.1 transform 里每一行都有讲究原始 CIFAR10 图片是 32x32 的 RGB 数组但你不能直接喂给模型用必须先做标准化。我习惯的 transform 组合是from torchvision import transforms transform_train transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ]) transform_test transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)), ])很多人不理解为什么要做这些操作我逐个解释RandomCrop(32, padding4)先把图片四周补 4 像素的零再随机裁剪回 32x32。这样每轮训练模型看到的图片位置略有偏移相当于免费增加了训练样本量逼迫模型学习物体的本质特征而不是死记硬背位置。RandomHorizontalFlip()以 50% 概率左右翻转图片。对于自然图像飞机、汽车、猫、狗等左右翻转不会改变类别语义这也是最经典的图像增强手段。ToTensor()把 HWC 格式的 uint8 数组转成 CHW 格式的 float32 张量并把像素值从 0-255 缩放到 0-1。这是 PyTorch 模型要求的输入格式。Normalize(...)把每个通道缩放到均值为 0、方差为 1 的分布。前面那两组数值分别是 CIFAR10 数据集的 RGB 三通道均值和标准差这是 torchvision 官方统计好的固定值直接用就行。关键点在于验证集不能用 RandomCrop 和 RandomFlip。验证集的作用是模拟模型在真实场景中的表现你不可能在真实推断时每张图都随机裁剪。所以 transform_test 只做 ToTensor 和 Normalize。3.2 DataLoader把数据变成批次Dataset 描述了“数据长什么样”DataLoader 负责“按什么顺序、以什么粒度喂给模型”from torch.utils.data import DataLoader batch_size 128 train_loader DataLoader(train_set, batch_sizebatch_size, shuffleTrue, num_workers2) test_loader DataLoader(test_set, batch_sizebatch_size, shuffleFalse, num_workers2)shuffleTrue保证每轮 epoch 数据顺序不同避免模型学到样本顺序相关的错误模式。num_workers2表示用两个子进程预读取数据能显著减少数据加载对 GPU 训练的阻塞。Windows 用户注意如果这里设置 num_workers 0脚本外层必须写在if __name__ __main__:里面否则会报 RuntimeError这点我在第七章会单独说。3.3 用一张图验证数据没问题在正式开始训练前我习惯先画几张图看看数据有没有异常。由于图像已经做过 Normalize显示时需要先乘标准差再加均值import matplotlib.pyplot as plt import numpy as np import torch def imshow(img): img img * 0.2470 0.4914 npimg img.numpy() plt.imshow(np.transpose(npimg, (1, 2, 0))) dataiter iter(train_loader) images, labels next(dataiter) imshow(torchvision.utils.make_grid(images[:4])) plt.show()看到画面里物体清晰可辨、颜色正常就可以放心的进入模型搭建了。4. 模型设计从零写一个适用于 32x32 图像的 CNN4.1 网络结构选择的逻辑标题写的是“从零搭建”所以这一步我不用任何预训练模型而是手写一个简单的 CNN。针对 32x32 这种小尺寸输入网络不需要太深三层卷积加两层全连接已经是很经典的组合。结构如下层名称操作输入尺寸输出尺寸conv1Conv2d(3, 32, 3, padding1) ReLU3x32x3232x32x32pool1MaxPool2d(2)32x32x3232x16x16conv2Conv2d(32, 64, 3, padding1) ReLU32x16x1664x16x16pool2MaxPool2d(2)64x16x1664x8x8conv3Conv2d(64, 128, 3, padding1) ReLU64x8x8128x8x8pool3MaxPool2d(2)128x8x8128x4x4fc1Linear(128x4x4, 256) ReLU Dropout2048256fc2Linear(256, 10)25610为什么卷积层用 padding1因为 3x3 卷积在 padding1 时不会改变特征图尺寸尺寸的变化完全由池化层控制这样每一层的通道数变化很容易推算。整个过程你可以理解为卷积负责“看特征”池化负责“压缩信息”全连接负责“做决策”。4.2 用 PyTorch 实现这个网络import torch.nn as nn import torch.nn.functional as F class SimpleCNN(nn.Module): def __init__(self): super(SimpleCNN, self).__init__() self.conv1 nn.Conv2d(3, 32, 3, padding1) self.conv2 nn.Conv2d(32, 64, 3, padding1) self.conv3 nn.Conv2d(64, 128, 3, padding1) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(128 * 4 * 4, 256) self.fc2 nn.Linear(256, 10) self.dropout nn.Dropout(0.5) def forward(self, x): x self.pool(F.relu(self.conv1(x))) x self.pool(F.relu(self.conv2(x))) x self.pool(F.relu(self.conv3(x))) x x.view(-1, 128 * 4 * 4) x F.relu(self.fc1(x)) x self.dropout(x) x self.fc2(x) return x我来说明 forward 里维度的变化输入 x 是(batch_size, 3, 32, 32)经过 conv1 后通道数变 32、尺寸不变pool 后变成(batch_size, 32, 16, 16)conv3 pool 后变成(batch_size, 128, 4, 4)。x.view(-1, 128*4*4)是把每个样本展平成 2048 维的向量之后接全连接层。Dropout 放在全连接层之前训练时以 0.5 概率随机丢弃一部分神经元能有效缓解过拟合。注意dropout(0.5)只在训练时生效验证时会自动关闭这也是为什么验证必须调用model.eval()。这个网络参数量大约 50 万。作为对比ResNet18 有 1100 万参数。小模型的好处是训练快、调试方便对新手非常友好。4.3 要不要加 BatchNorm你可以给每个卷积层后追加一个nn.BatchNorm2d它的作用是让每一层的输入分布保持稳定加速收敛。加上 BN 后通常可以适当加大学习率训练过程也更稳定。但我建议第一版先不加先把基础流程跑通。等模型精度上不去想优化时加 BN 是你第一个可以尝试的改动加完只需要在 forward 的 ReLU 前插入self.bn1(x)即可。5. 训练循环核心代码逐段拆解5.1 损失函数与优化器的选择多分类问题默认用CrossEntropyLossPyTorch 的这个损失函数在内部已经包含 softmax 操作所以你模型最后一层输出的是原始的 logits不用手动接 softmax。优化器我用 SGD 而不是 Adam这是很多教程不讲但很重要的一个点。Adam 收敛快但得到的模型泛化性往往不如调好参数的 SGD。对于 CIFAR10 这种入门级数据集SGD 加动量和权重衰减是多篇经典论文验证过的稳定组合import torch.optim as optim criterion nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay5e-4)momentum0.9让梯度更新带上惯性可以更平稳地穿过损失函数的平坦区域weight_decay5e-4是 L2 正则化约束权重不要过大是防止过拟合的经典手段。5.2 训练一个 epoch 需要哪几步训练代码的核心骨架如下def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() running_loss 0.0 correct 0 total 0 for images, labels in dataloader: images images.to(device) labels 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 accuracy correct / total return avg_loss, accuracy我特别强调一下optimizer.zero_grad()。PyTorch 的梯度是累积的不清空就会把上一个 batch 的梯度叠加到当前 batch 上导致更新方向完全错乱。这行代码的意思是“每次更新前清空历史梯度”漏掉它你会看到 loss 忽高忽低非常典型的新手错误。5.3 验证集别让模型在训练集上自嗨训练过程中必须周期性地在验证集上评估模型否则你不知道模型是“真会了”还是“背答案”了。验证代码和训练代码有两处关键区别def evaluate(model, dataloader, criterion, device): model.eval() running_loss 0.0 correct 0 total 0 with torch.no_grad(): for images, labels in dataloader: images images.to(device) labels 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() avg_loss running_loss / total accuracy correct / total return avg_loss, accuracy区别一是model.eval()它把 Dropout 和 BatchNorm 切换为推理模式保证输出稳定。区别二是torch.no_grad()它告诉 PyTorch 不需要计算梯度。验证时我们只做前向传播不需要反向传播关掉梯度计算能省内存、提速不少。5.4 把训练和验证串起来我的完整训练循环是这样device torch.device(cuda if torch.cuda.is_available() else cpu) model SimpleCNN().to(device) num_epochs 20 scheduler optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.1) for epoch in range(num_epochs): train_loss, train_acc train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc evaluate(model, test_loader, criterion, device) scheduler.step() print(fEpoch {epoch1:3d} | Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | Val Loss: {val_loss:.4f} | Val Acc: {val_acc:.4f})scheduler.step()这行很容易被忽略。它会在指定的 epoch 间隔降低学习率让模型在训练后期用更小的步长精细收敛。step_size10、gamma0.1 意味着第 10 个 epoch 结束后学习率从 0.01 降到 0.001。这个策略虽然简单但对 SGD 的效果提升非常明显。6. 模型保存与加载这一步最容易被新手忽略6.1 state_dict 是什么PyTorch 模型保存的核心概念是state_dict。它是一个字典里面存的是模型所有可学习参数的张量值比如 conv1 的权重和偏置、fc2 的权重和偏置等。PyTorch 官方推荐只保存它而不是保存整个模型对象# 推荐只保存权重 torch.save(model.state_dict(), cifar10_cnn.pth) # 不推荐保存整个模型对象 # torch.save(model, cifar10_full.pth)为什么不推荐保存完整模型因为完整模型文件里包含了模型类的代码路径和结构定义加载时如果类的定义位置变了或者类名改了就会报错。保存 state_dict 则非常干净它只是一堆张量任何环境下只要你能构建出一个结构完全相同的模型就能把权重原封不动地塞回去。6.2 加载时必须先创建同结构的模型保存权重之后加载代码长这样model SimpleCNN() model.load_state_dict(torch.load(cifar10_cnn.pth)) model.eval()注意这里必须重新实例化一个SimpleCNN()然后再调用load_state_dict。如果模型定义里的层和保存时不一致PyTorch 会报 missing keys 或 unexpected keys 的错误。这是新手最容易碰到的问题之一本质原因就是“模型结构”和“权重文件”没有对上。model.eval()那句也不要省。推理前不切到 eval 模式Dropout 层还在随机丢神经元你每次预测同一个输入会得到不同结果很多人拍脑袋说“模型训练完预测不准”十有八九是这个原因。6.3 checkpoint把训练进度也存下来如果模型要训练几十个 epoch中途断电、显存溢出是常有的事。更好的做法是保存 checkpointcheckpoint { epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), scheduler_state_dict: scheduler.state_dict(), best_val_acc: best_val_acc, } torch.save(checkpoint, checkpoint_last.pt)恢复训练时ckpt torch.load(checkpoint_last.pt, map_locationdevice) model.load_state_dict(ckpt[model_state_dict]) optimizer.load_state_dict(ckpt[optimizer_state_dict]) scheduler.load_state_dict(ckpt[scheduler_state_dict]) start_epoch ckpt[epoch] 1为什么连优化器和调度器的状态也要存因为 SGD 动量的历史累积量、学习率调度器当前的位置都属于训练状态的一部分。只恢复模型权重、重新初始化优化器你会发现学习率回到了初始值动量信息全丢了后续训练需要重新适应。6.4 换设备时的 map_location另一个高频问题是在 GPU 上训练的模型突然要在没有 GPU 的机器上加载。直接torch.load会报错提示 CUDA 不可用。解决办法device torch.device(cuda if torch.cuda.is_available() else cpu) ckpt torch.load(checkpoint.pt, map_locationdevice)map_location参数会把原本存在 GPU 上的张量映射到指定设备上跨设备搬运模型的必备选项。7. 一次完整的训练实战与调优记录7.1 我的实测训练过程固定好上面所有代码后我实际跑了一次完整训练。硬件是普通笔记本的 CPUbatch_size128初始学习率 0.01总共 20 个 epoch。关键指标如下Epoch训练准确率验证准确率备注134.2%35.1%刚开始收敛558.6%56.8%模型开始学出轮廓1070.4%65.3%训练集增速明显高于验证集1577.8%69.5%过拟合迹象显现2084.7%72.3%学习率下降后验证集继续小幅提升从这个表格能看出两个典型规律第一前 10 个 epoch 训练集准确率只有 70% 出头而验证集已经超过 65%说明模型还在正常学习通用特征没有明显过拟合到了第 15 个 epoch 之后训练集准确率逼近 85%但验证集只从 65% 涨到 69.5%差距开始拉大这是典型的过拟合信号。学习率降到 0.001 后验证集勉强又涨了一点最终停在 72.3%。72% 的准确率对 10 类随机猜10%来说已经很可观但对于 3 层小 CNN 来说也基本摸到了天花板。如果还想提升方向无非三条加 BatchNorm 和后几层卷积、把数据增强从 RandomCrop 换成 CutMix 等更强策略、或者换成 ResNet 这类更深的网络。7.2 我在复现过程中踩过的坑第一个坑是 Windows 下 DataLoader 的 num_workers。Windows 和 Linux 不一样多进程数据加载需要主模块能被安全导入所以完整训练脚本必须用if __name__ __main__:包住主流程否则会抛出一个看似莫名其妙的多进程错误。第二个坑是验证集中途忘记model.eval()。有一次我训练时发现验证准确率忽高忽低同一轮 epoch 跑两次结果完全不同。排查半天才发现是验证函数里没有调用 evalDropout 在验证阶段还在随机丢弃神经元导致输出不稳定。这个问题说实话很隐蔽毕竟代码不会报错只会给你一个让人摸不着头脑的验证结果。第三个坑是 Normalize 之后显示图片一片乌黑。最初我直接用plt.imshow(images[0].numpy())显示归一化后的图片画面全是黑的。后来才意识到模型输入是标准化到 0 均值、1 方差的分布像素值大部分在 -2 到 2 之间直接按 0-1 范围显示当然会失真。显示前必须反归一化即乘标准差再加均值。第四个坑更容易忽略如果你保存的是完整模型对象torch.save(model, ...)加载时必须确保SimpleCNN这个类的定义在原文件中存在且路径没有变化。我见过有人把权重文件拷到另一台机器机器上没有模型定义代码结果无论如何都加载不了。7.3 关于训练耗时的一个参考CPU 训练 20 个 epoch 在我的笔记本上大约耗时 28 分钟平均一个 epoch 一分半。如果有 NVIDIA GPU哪怕是入门级的 GTX 1650也能把这个时间压缩到两三分钟。也就是说这个项目的“试错成本”极低你完全有能力在一天内反复调整网络结构、超参数建立对深度学习的直觉认识。8. 项目收尾单图推理验证与我的几点体会训练完成、模型保存后一定要做一次单图推理测试这一步把“文件里保存的权重”变成“真实的预测能力”from PIL import Image classes [airplane, automobile, bird, cat, deer, dog, frog, horse, ship, truck] img Image.open(test_cat.jpg).convert(RGB) img transform_test(img).unsqueeze(0).to(device) model.eval() with torch.no_grad(): outputs model(img) _, predicted torch.max(outputs, 1) print(fPredicted: {classes[predicted.item()]})这短短十几行代码里其实包含了两个关键细节。一是unsqueeze(0)给单张图片补上 batch 维度模型要求输入是四维张量缺少这一维会直接报错二是with torch.no_grad()推理阶段不需要构建计算图既省内存又提速。我个人在跑完整个项目后的体会是深度学习入门最怕的不是数学、不是理论而是“不知道每一步代码在干什么”。CIFAR10 这个项目的美妙之处在于它把深度学习中最重要的几个环节高度浓缩了你不需要昂贵的硬件不需要海量数据也不用担心训练等太久。从环境搭建、数据加载、模型设计、训练调参到模型保存每个环节你都能亲手摸到、亲眼看到、立刻验证结果。跑通之后你会对整个训练流程建立起不可动摇的直觉再去看那些更复杂的模型、更大的数据集至少不会觉得这扇门打不开了。
返回列表