资讯动态

PyTorch入门实战:从零搭建CNN实现CIFAR-10图像分类

发布时间:2026/10/18 5:55:54 来源:尧图企业网站定制
把话先说在前面CNN卷积神经网络是很多人在 PyTorch 入门路上真正开始觉得“有意思”的那一站。在跑通全连接网络之后你会发现图片分类、目标检测、语义分割这些视觉任务基本都是 CNN 的天下而 PyTorch 作为落地方便、调试直观的深度学习框架其实已经把卷积、池化这些操作封装得非常顺手难点更多在于理解张量形状怎么变、参数怎么算、训练怎么调。这篇是“PyTorch 入门”系列的第 11 篇主题就一个从零搭一个能跑通 CIFAR-10 图像分类的 CNN 网络。我会把这套模型的核心组件、PyTorch 代码实现、训练循环配置以及我实际跑训练时踩过的那些坑一次讲清楚。适合刚学完 PyTorch 基础张量操作、想拿真实数据集练手的人也适合那种“模型会写但解释不清为什么”的读者——看完这篇你至少能说清楚每一层到底干了什么。1. CNN 是怎么想问题的从全连接到卷积的思考方式转变1.1 全连接网络处理图片时的两个硬伤很多人第一次接触图像分类习惯直接把 32×32×3 的彩色图片“拉平”成 3072 维向量然后塞进全连接网络。这个做法在 MNIST 这种 28×28 灰度小图上勉强能跑但问题很快就暴露了。第一个问题是邻域结构丢了。一张图片里中间那个像素和它周围一圈像素共同构成纹理、边缘、颜色渐变这些视觉语义但拉平成向量之后像素之间的二维空间关系就被拆散了。网络要自己硬学出“哪些像素互相靠近”这件事不仅慢而且学不干净。第二个问题是参数爆炸。假设输入是 224×224×3第一层我们用 512 个神经元那么光是这一层的权重就有 224×224×3×512 ≈ 7700 万个参数。这么大规模的全连接层且不说训练起来要多少显存光是不让它过拟合就已经够头疼。CNN 的思路是一开始就承认图像是二维结构并且用“卷积”为图像量身定制计算方式每个神经元只看输入的一小块局部区域不同位置共享同一套权重。这就是局部连接和权值共享模型参数一下少了好几个数量级而且对“猫出现在图片左侧还是右侧”这类平移变化也更加鲁棒。1.2 卷积层用一个小窗口扫过整张图卷积层的核心操作可以理解成拿一个小“模板”在图像上滑动每停一个位置就计算一下模板和被覆盖区域的元素乘积之和。模板就是卷积核也叫滤波器它的大小用 kernel_size 表示比如 3×3、5×5。PyTorch 里最常见的写法是nn.Conv2d(in_channels, out_channels, kernel_size, stride1, padding0)。其中 in_channels 是输入特征图的通道数out_channels 是这一层输出几个特征图stride 是每次滑动的步长padding 则是在图像外侧补一圈“0”用来控制输出尺寸。为什么大部分卷积层习惯用 3×3 卷积核加 padding1因为这样可以在不改变空间尺寸的前提下提取局部特征。你可以把公式拿出来算一下输出尺寸 floor((输入尺寸 2×padding - kernel_size) / stride) 1。stride1、padding1、kernel3 时输入 32×32 的输出还是 32×32。用两个 3×3 卷积堆叠等效感受野是 5×5但参数量却比直接用 5×5 卷积少很多——这也是后来很多网络结构喜欢堆小卷积核的原因。out_channels 这个概念也要花点时间理解。它的本质是“这一层用多少个不同的卷积核去扫描输入”每个卷积核会生成一个特征图用来捕捉某种特定的局部模式比如边缘、拐角、纹理。第一层往往从 16、32 这种较小数量起步越往后通道数越大空间尺寸越小这是 CNN 设计里一种常见的手法。1.3 池化层降维的同时保留关键信息池化的作用一句话概括把特征图变小一点但尽量别把关键信息弄丢。最常用的是最大池化nn.MaxPool2d(kernel_size2, stride2)它把每个 2×2 的小区域浓缩成最大值。你可以理解为“这一小块区域里最显著的特征是什么我就留下什么”。为什么要下采样一方面空间尺寸缩小后续卷积的计算量就小了另一方面池化给了网络一点局部平移不变性——目标稍微偏移几个像素最大值可能还落在同一个池化窗口里特征表达就不会剧烈变化。最大池化在边缘、纹理这类强特征上表现更好也更常用平均池化则更多用于全局特征聚合比如把最后一层特征图直接平均成一个向量。这里有个容易忽略的细节池化层是可以不设置 padding 的因为它的作用是降采样。但如果你想精细控制输出尺寸也可以给池化加 padding实际上很少这么做。PyTorch 里 MaxPool2d 的默认 stride 等于 kernel_size也就是窗口和滑动距离一致这样尺寸正好减半非常规整。1.4 激活函数和全连接层让网络学会复杂映射卷积和池化做完网络已经能提取出一批抽象特征但这些特征之间的组合关系要靠激活函数和全连接层来拟合。ReLU 是现在默认的选择公式是 max(0, x)计算极快而且能有效缓解梯度消失和 Sigmoid、Tanh 相比ReLU 的梯度在正区间不会饱和深层网络训练稳定很多。最后一层全连接会把前面展平的特征向量映射到类别数量。比如 CIFAR-10 是 10 分类最后一层输出维度就是 10。PyTorch 里通常在后面接nn.CrossEntropyLoss()完成多分类训练因为这个损失函数内部已经集成了 Softmax 计算你不需要自己在模型里画蛇添足地再加 softmax。2. PyTorch 搭建 CNN 的完整代码与尺寸推演2.1 准备数据CIFAR-10 与预处理动手写模型之前数据要先准备好。我习惯用 CIFAR-10 做入门实验因为它只有 60000 张 32×32 彩色图体量适中在普通的 GPU 上几分钟就能训练一轮。用 torchvision 加载非常省事from torchvision import datasets, transforms from torch.utils.data import DataLoader transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean[0.4914, 0.4822, 0.4465], std[0.2470, 0.2435, 0.2616]), ]) train_dataset datasets.CIFAR10(root./data, trainTrue, downloadTrue, transformtransform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers2, pin_memoryTrue)这两步预处理很关键。ToTensor()会把图片从 HWC 的 PIL 格式转成 CHW 的 PyTorch 张量同时把像素值从 0~255 归一化到 0~1Normalize()再按照数据集的均值和标准差做标准化让每个通道的值大致落在 -1~1 之间网络收敛会稳定很多。你可以简单理解为数据得先“对齐”到一个合适分布梯度才不会被极端像素值带偏。2.2 一个轻量但完整的 CNN 模型下面这个模型是很多入门教程的基准结构两次“卷积 激活 池化”再接两层全连接分类器。它足够简单但已经拥有了 CNN 的全部核心机制import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 16, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), nn.Conv2d(16, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(32 * 8 * 8, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(256, num_classes), ) def forward(self, x): x self.features(x) x self.classifier(x) return x这段代码放在nn.Module的子类里PyTorch 会自动追踪前向传播过程并在反向传播时计算梯度。nn.Sequential只是把多个层按顺序包成一个容器方便阅读和管理。我在开头几层用 padding1 保持空间尺寸再通过池化让 32×32 先变 16×16、再变 8×8最终全连接层输入是 32×8×82048 维这个数字来得很自然。2.3 从输入到输出手把手推演每层张量形状最容易翻车的地方是改图片输入尺寸后全连接层维度对不上。我强烈建议新手把每一层的输出形状写出来而不是靠猜。下面是这个模型在 32×32 输入下的完整尺寸变化层操作输出形状计算说明输入-(3, 32, 32)CIFAR-10 原始尺寸Conv1kernel3, padding1, stride1(16, 32, 32)(322-3)/1132ReLU逐元素(16, 32, 32)尺寸不变Pool1kernel2, stride2(16, 16, 16)空间尺寸减半Conv2kernel3, padding1, stride1(32, 16, 16)(162-3)/1116ReLU逐元素(32, 16, 16)尺寸不变Pool2kernel2, stride2(32, 8, 8)空间尺寸再减半Flatten-(2048,)32×8×82048Linear12048→256(256,)自定义Linear2256→10(10,)对应 10 个类别这个表格值得你对照代码多看几遍。一旦你把“卷积输出尺寸公式”记牢以后用户任何网络结构都能推算出现报错也知道往哪个方向排查。参数数量也可以顺手口算一下对模型复杂度有个直观感受。第一层卷积的参数是 3×16×3×316加上的 16 是偏置共 448 个第二层卷积是 16×32×3×332 共 4640 个第一个全连接是 2048×256256 共 524544 个最后一个全连接是 256×1010 共 2570 个。总参数约 53 万在当代神经网络里已经算是非常轻量了但放到 CPU 上也能在几十秒内完成一个 epoch 的训练很适合入门阶段反复调试。3. 训练循环与关键参数调节3.1 损失函数与优化器CrossEntropy 和 Adam/SGD 的搭配分类任务在没有特殊理由的情况下直接用nn.CrossEntropyLoss()。这个损失函数把模型最后的原始分数logits自动做 Softmax 并计算交叉熵所以标签只需要是整数索引比如图片是“猫”标签就是 3不需要手写 one-hot 编码。优化器我通常让新手先在 Adam 和带动量的 SGD 之间二选一。Adam 收敛快对学习率的敏感度相对低适合快速验证模型能不能跑起来SGD Momentum 泛化更稳适合把精度抠到极致。入门阶段我建议先用 Adamlr 设置在 1e-3 附近import torch.optim as optim criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3)如果你想尝试 SGD可以设置optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay5e-4)。momentum 相当于给梯度更新加了一个“惯性”能让优化过程更平稳而 weight_decay 相当于 L2 正则化对抑制过拟合有一定帮助。这里没有绝对标准答案不同数据集、不同网络对优化器的偏好不同实验才是王道。3.2 训练循环的标准模板train 和验证都要写训练代码其实写多了就会发现它像一个固定模板清零梯度、前向计算、算损失、反向传播、更新参数。麻烦的是一些隐藏细节比如 BatchNorm 和 Dropout 在训练和推理阶段行为不同所以必须用model.train()和model.eval()明确切换状态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验证函数结构类似但要用torch.no_grad()包住前向计算并且不需要反向传播和参数更新torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() running_loss 0.0 correct 0 total 0 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() return running_loss / total, correct / total为什么要单独写验证集因为我们要观察的是模型在没见过的数据上的表现而不是对训练集的死记硬背。训练 loss 和验证 loss 之间的差距是判断过拟合最重要的信号。3.3 epoch、batch size 与学习率怎么搭配写个循环跑多个 epoch把每轮的训练和验证结果打出来基本就有数了device torch.device(cuda if torch.cuda.is_available() else cpu) model SimpleCNN().to(device) num_epochs 10 for epoch in range(1, num_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(fEpoch {epoch:02d} | ftrain_loss{train_loss:.4f} train_acc{train_acc:.4f} | fval_loss{val_loss:.4f} val_acc{val_acc:.4f})epoch 数量取决于训练曲线。如果 3 个 epoch 后验证准确率还在涨就继续训如果验证 loss 开始回升那就已经进入过拟合区间了。batch size 我建议从 64 起步——太小导致梯度震荡太剧烈太大又需要更多显存64 到 128 之间通常是个甜区。学习率则遵循一个经验法则Adam 一般从 1e-3 起步SGD 从 1e-2 起步然后按 10 倍下降搜索。这类程序的时长其实很短CIFAR-10 加上这个轻量模型在普通型号的显卡上 10 个 epoch 可能也就 3~5 分钟。我经常把这个实验当作“深夜十分钟就能跑完”的调试基准。4. 踩坑实录训练 CNN 时最常见的几个问题4.1 全连接层维度对不上报 size mismatch这个报错基本是新手头号痛点。报错信息往往类似“size mismatch for fc.weight: size of input tensor should match”。原因几乎都是改动了输入图片尺寸或者改了卷积/池化的参数导致展平后的特征维度和nn.Linear的预期不一致。排查方法我推荐两步走。第一步先按 2.3 节的方式手动推演尺寸确认最后展平是多少维第二步直接在forward里临时加一行打印代码看真实形状# 临时调试用 print(x.shape) x x.view(x.size(0), -1)如果你在模型里用了view强烈建议用nn.Flatten()替代因为 Flatten 对 batch 维度更友好不会出现把 batch 和通道混在一起的问题。维度报错的另一个通用解法是把first Linear的输入写成一个变量并注释来源比如in_features 32 * 8 * 8下次调参时直接改这里就行。4.2 Loss 不下降甚至变成 NaNLoss 一直不降先别急着加层。最常见的原因是学习率太大梯度一步迈过头损失直接爆掉。判断方法很简单如果 loss 从某个 epoch 开始突然变成 nan或者一开始就是 nan大概率是学习率的问题。把 Adam 的学习率降到 1e-4 试试经常就好了。第二个检查点是数据归一化。如果图片像素没有缩放到合理范围仍然保留在 0~255梯度量级会偏大网络训练非常不稳定。ToTensor()和Normalize()不是可有可无的装饰而是训练稳定性的必需品。还有一个写错模型结构导致 loss 不降的细节在CrossEntropyLoss之前你的最后一层是没有激活函数或者 softmax 的。有些人习惯在网络里自己加了 softmax然后又用 CrossEntropyLoss这时梯度就会来回抵消训练效果极差。CrossEntropyLoss 里自带 softmax你自己不要再加这个坑我几乎每隔一段时间就会听人踩一次。4.3 训练集 acc 很高验证集 acc 上不去这是典型过拟合。CIFAR-10 训练集只有 5 万张图参数稍多的网络很容易把训练集“背下来”。经典解决手段有三件套Dropout、数据增强、正则化。我们的模型已经在全连接层后加了nn.Dropout(0.3)——训练时随机丢掉部分神经元让模型不能过度依赖某几个节点数据增强则是从训练集本身入手比如随机水平翻转、随机裁剪相当于免费扩充了训练样本的多样性train_transform transforms.Compose([ transforms.RandomHorizontalFlip(), transforms.RandomCrop(32, padding4), transforms.ToTensor(), transforms.Normalize(mean[0.4914, 0.4822, 0.4465], std[0.2470, 0.2435, 0.2616]), ])这个增强只加在训练集上验证集依旧只做 ToTensor 和 Normalize。如果加了增强后训练 acc 反而下降那是正常的因为你给模型出的题变难了真正要看的是验证 acc 是否因此上升。另外训练数据和验证数据必须走完全相同的规范化处理。如果训练集用了归一化、验证集忘了模型在验证集上的表现会非常不稳定。很多人调了半天才发现是这两边的 transform 不匹配。4.4 显存不足、训练慢、DataLoader 卡住显存不足OOM最容易解决把 batch size 调小比如从 64 降到 32如果还要再小就考虑降低输入图片尺寸或者精简网络层数。另一个容易被忽略的显存泄漏点是在循环内部反复调用model.to(device)这类操作应该只在模型初始化后进行。训练慢的场景先确认 PyTorch 是否真的用上了 GPUprint(torch.cuda.is_available()) print(device)num_workers可以适当调大一点比如 2 或 4让数据加载更多并行pin_memoryTrue能加快数据从 CPU 到 GPU 的拷贝。如果你用的是 Windows还要记得把训练入口用if __name__ __main__:包起来否则多进程数据加载会反复启动子进程产生令人抓狂的报错。训练慢还有一个隐蔽原因模型里刻意用了过大的全连接层。全连接层的参数占比几乎总是最大的把中间维度从 512 降到 128 或者 256训练速度会有明显改善而精度损失往往很小。写在最后的几点实操体会跑通这个 CNN demo 之后我的建议是立刻做两件事。第一件手动把输入图片换成不同尺寸比如 48×48然后重新手推一遍每一层的形状推到和 PyTorch 报错完全吻合为止。第二件把卷积核数量从 16/32 改成 32/64观察训练时间和验证准确率的变化你就能切身感受到“模型容量”这个概念——它不是一个抽象术语而是实实在在反映在 GPU 占用和 loss 曲线上。还有一个更多人不注意的小技巧每次调完参数把关键超参数连同训练日志记到一个简单的表格里。我见过太多人反复调了几十次参最后却说不清哪一组配置效果最好。训练 CNN 本来就充满了随机性用固定记录控制变量你才能真正判断是数据增强起作用了还是你的 Dropout 值设对了或者只是这次运气好。下一篇如果继续深入可以聊聊可视化卷积核特征图那会帮你更直观地理解每层到底学到了什么也可以试试把模型换成预训练的 ResNet然后在这个基础上做迁移学习。但从现在开始你至少已经是一个“能独立搭一个 CNN 跑通图像分类”的人了剩下的都是在这个基础上的延伸。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑