news 2026/10/19 2:24:58

PyTorch手写数字识别实战:从MNIST到CNN算子原理全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch手写数字识别实战:从MNIST到CNN算子原理全解析

1. 项目概述

1.1 核心需求解析

说实话,在AI这个行当里待久了,很多人问过我同一个问题:想入门深度学习,到底该从哪个项目开始?我的答案永远都是——PyTorch手写数字识别。这个项目看起来简单,却像一扇难得的门,把深度学习里最核心的东西全都串在了一起:数据怎么加载、模型怎么搭、算子怎么算、梯度怎么回传、效果怎么评估。

这次内容其实是某个专题系列的第7篇,标题里的“AI系统-7”你也可以理解成“第七个系统实验”或者“第七讲”,都不重要。重要的是,当我们把PyTorch里的数字识别从零到一完整跑起来之后,你会突然发现:原来框架层面那些看似黑盒的API,底层都是一个个算子在支撑。你理解了算子的脾气,才算真正入了深度学习的门。

这篇内容适合谁?两类人。第一类是刚学完Python基础、想进入深度学习但不知道该从哪下手的初学者;第二类是已经能跑通一些现成代码、但对model.train()和model.eval()的区别、对卷积层参数为什么这样设置、对损失函数返回的到底是一个数还是一个向量这些细节模棱两可的进阶初学者。两类人,这篇都能给你一个交代。

我会尽量用实际操练中的大白话讲,不整虚的。每个关键步骤后,我都会把背后那层“为什么”揭开来,让你在动手的同时,脑子里攒下来的不只是代码,而是一套可以迁移到其他模型上的思维框架。

1.2 影响范围与应用场景

你可能会想,数字识别而已,能做多大事?还真别小看。数字识别是OCR(光学字符识别)的最基础形态,虽然现在工业级OCR已经是天翻地覆的做法了,但抽掉所有复杂技术之后,内核依然是从像素到类别的那一次映射。

这个项目的影响范围,往前可以延伸到手写支票金额识别、快递面单数字解析、表单自动录入这些实际业务场景。往后它可以撑起你对图像分类任务的完整认知,图像分类是目标检测、图像分割、人脸识别这些更高阶任务的地基。可以说,数字识别这个项目里养成的每一步习惯,都会用在之后所有模型训练里。

而算子这个概念的影响面更宽。只要用PyTorch写模型,卷积算子、池化算子、全连接算子、激活函数算子、损失函数算子、优化器算子这些东西你全都躲不掉。很多人写代码写得很顺,却不知道自己为什么会把Conv2d的padding设置成1,不知道ReLU在前向传播时做了什么让梯度能流通的事。这篇我会把这些东西一个一个拆给你看。

2. 项目设计与思路拆解

2.1 为什么选MNIST和PyTorch这对组合

先聊数据集。项目里用的数字识别数据集是经典中的经典,业内一般叫它MNIST:6万张训练图片,1万张测试图片,每张都是28×28像素的灰度图,内容是0到9的手写数字。这个数据集有多经典?几乎所有深度学习框架的官方教程,第一个图像相关的例子都是它,就像练字先写“永”字一样。

MNIST好在哪里?第一,它对算力极其友好,一张普通的办公笔记本CPU就能跑,不需要上来就买显卡,入门成本极低。第二,它的任务定义极其清晰:一张图进来,输出10个类别的概率分布,谁最大谁就是预测结果。第三,它已经帮我们把复杂的图像预处理工作简化到了几乎为零,图片大小统一、数值范围统一、标注准确率极高,你可以把全部的精力放在理解模型本身,而不是纠缠数据清洗。

而PyTorch为什么是首选框架?这要说它和另一主流框架在“直觉”上的差异。PyTorch的动态计算图机制允许你在调试时print任意一个中间张量的shape和数值,这对新手来说是救命级别的友好。加上它和Python原生语法的亲和度极高,写模型就像在写普通的Python类。所以,MNIST当作题目、PyTorch当作笔,这对组合是我认为最合理的新手起手式。

2.2 模型方案对比:全连接网络与CNN的取舍

在动手写模型之前,你一定会面临一个选择:用全连接网络(MLP)还是卷积神经网络(CNN)?这俩在这个任务上的差距就像自行车和专业公路车在骑行比赛里的差距——都能到终点,但体验和成绩天差地别。

全连接网络的做法是把28×28的图片拉成一维向量,也就是784个数,然后全部塞进一层的神经元里做线性变换。它的缺点是显而易见的:图像是一个二维的、有空间结构的数据,邻近的像素之间往往有强烈的相关性,但拉平这个操作直接把空间结构毁灭了,模型被迫在“没有空间线索”的输入里去学习特征,效果自然受限。

CNN的做法则完全不同。它用卷积操作在二维平面上滑动地提取特征,一开始提取的是边缘、线条这类低级特征,靠后面一层层堆叠提取出弧线、拐角甚至整个数字的部件结构,最后再用一个分类头把这些特征汇聚成10个类别的概率。CNN尊重图像的空间层次结构,这是它能拿到接近99%准确率而全连接网络只能勉强爬到97%左右的核心原因。

所以这个项目最终选定的是卷积神经网络路线。但我想强调一点,这不代表全连接就不该学。恰恰相反,我在后面第4节会带着你用一行代码把CNN切换成MLP做对照实验,那种动手获得的体感,比读十篇文章都管用。

2.3 算子与神经网络的底层关系

在学习任何深度学习框架时,最需要建立的一个认知是:整个神经网络,本质就是一张由算子节点组成的计算图。

从数据流向来看,一个输入张量从前向传播的第一层出发,依次经过卷积算子、激活算子、池化算子、全连接算子,在每一层节点处完成一次数学变换,最终在末尾的损失算子处得到一个标量损失值。然后反向传播启动,自动微分机制沿着这张计算图从后往前计算每个算子对应的梯度,优化器算子再拿着这些梯度和学习率,对着模型参数做一次更新。然后,下一轮循环开始。

所以说到底,深度学习模型的训练就是一个不断“前向算损失、反向算梯度、优化器更新参数”的循环。框架帮你把自动微分和算子的调度做掉了,但你作为使用者,必须知道每个算子在计算图里干了什么,才能真正驾驭模型的训练过程,出现问题时也能判断出错的环节。

我见过太多人在loss出现NaN后不知所措,在我带这个项目的过程中这类情况几乎每个学员都会遇到一次。而一旦你知道了loss实际上是一个算子,有机会变成NaN的地方无非就是输入有异常、损失计算有除零、或者梯度爆炸,排查路线就会变得非常清晰。

3. 核心算子逐个拆解

这一节是这篇内容里含金量比较高的一个部分。我把这个项目里会遇到的算子全列出来,逐个讲清楚:它是干什么的、核心参数怎么设置、代码里怎么用它、实操中容易踩什么坑。

3.1 卷积算子Conv2d

Conv2d,二维卷积算子,是整个CNN模型当之无愧的核心。它做的事可以用一句话概括:用一个可学习的小窗口在图像上滑动,每个位置做一次内积运算,把局部特征响应出来。

你可以把卷积理解成一台带手电筒的扫描仪。手电筒的光圈大小就是kernel_size(卷积核大小),光圈扫过的每一个位置,都把光圈覆盖下的像素和一组可学习的权重做加权求和,得到一个响应值。权重这组数,就是卷积核,它是模型通过训练学出来的。

代码里,一个最常用的卷积层长这样:

import torch.nn as nn conv1 = nn.Conv2d( in_channels=1, # 输入通道数,灰度图是1,RGB图是3 out_channels=32, # 输出通道数,也可以理解成本层用了多少个卷积核 kernel_size=3, # 卷积核尺寸,3x3 stride=1, # 滑动步长 padding=1 # 边缘填充,保持尺寸不变 )

这几个参数我逐个说。in_channels对应输入张量的通道数,MNIST图片是单通道灰度图,所以第一层是1。out_channels是这一层输出的通道数,可以理解成用多少个不同的卷积核去分别提取图像不同维度的特征,这是一个需要你自己定的超参数,本项目中32是个常见且合理的值。kernel_size是卷积核的尺寸,3×3是VGG之后的主流选择,因为它比大核参数少、堆叠起来感受野又足够。stride是卷积核每次滑动的步长,默认1,也就是相邻两次卷积覆盖的区域大部分重叠。padding是边缘填充数,设置为1可以让输入输出尺寸保持一致,这里有一个公式需要记住:

输出尺寸 = (输入尺寸 - 卷积核尺寸 + 2 × padding) / stride + 1

以28×28输入、3×3卷积核、stride=1、padding=1为例:

输出尺寸 = (28 - 3 + 2 × 1) / 1 + 1 = 28

尺寸不变,这就是很流行的“same”模式。如果你把padding设成0,输出尺寸就变成26×26,特征图会逐层缩小。后面我第4节中写模型时,每一层卷积之后我都会手动验证一下输出尺寸,这是一个很好的习惯。

关于Conv2d,实操中新手最容易忽略的一点是:Conv2d默认不带偏置的讨论。它其实有bias参数,而且默认是True。在大多数情况下保留bias没什么问题。但在某些特定结构里(比如卷积后面紧跟BatchNorm),建议把bias设成False,因为BatchNorm自带平移参数,bias就多余了,还会浪费一点算力。

3.2 池化算子MaxPool2d

池化算子是干嘛的呢?它的本质是下采样。最常见的MaxPool2d做的事情是在一个窗口内取最大值,把窗口内的信息浓缩成一个数。

为什么需要池化?两个原因。第一,经过卷积后特征图的分辨率可能还很大,如果不压缩,后续卷积层的计算量会爆炸式增长,对于显存较小的设备尤其不利。第二,池化带来了局部平移不变性,意思就是数字稍微偏了一点、抖了一点,窗口内取最大值这个操作对微小的位移并不敏感,这在一定程度上增强了模型的鲁棒性。

代码使用很简单:

pool = nn.MaxPool2d(kernel_size=2, stride=2)

这行的效果是把特征图的宽高各缩小一半。28×28的图,过一次池化变成14×14,再过一次变7×7。之所以把stride也设成2,是为了让窗口之间不重叠,每个窗口各管一方。你用kernel_size=2、stride=2,输出尺寸就是输入的一半:

输出尺寸 = (输入尺寸 - 卷积核尺寸) / stride + 1 = (28 - 2) / 2 + 1 = 14

这里有一个很容易被忽略的坑:MaxPool2d在反向传播时,只会把梯度回传给池化窗口里那个最大值所在的位置,其他位置梯度为0。所以它不引入任何需要学习的参数,不会增加模型的参数量,这是它的一个很大优势。

3.3 激活函数算子ReLU

如果没有ReLU,我们这个模型无论堆多少层卷积,本质上都还是一堆线性变换的复合,而线性变换的复合仍然是线性变换,意味着模型的表达能力会大打折扣,对复杂的图像特征根本拟合不了。激活算子就是给神经网络引入非线性的关键角色。

ReLU的定义几乎简单到不能再简单:输入大于0时原样输出,输入小于等于0时输出0。用一个生活化的类比理解:它像一道只允许正数通过的门,所有负数在门前被拦下归零。

activation = nn.ReLU()

在代码里,ReLU通常紧跟卷积层之后:

x = self.conv1(x) x = self.relu(x) # 等价于 x = torch.relu(x)

为什么CNN标配ReLU而不是更早年代的Sigmoid?关键在于梯度消失问题。Sigmoid在输入绝对值比较大的地方,导数趋近于0,这意味着反向传播时梯度层层相乘后会迅速变得极小,深层网络的参数几乎得不到有效更新。而ReLU在正数区域的导数恒为1,梯度可以顺畅地流过这些层。这就是ReLU对深层网络极其友好的根本原因。

实操中ReLU有个小坑叫dying ReLU。如果某些神经元长期只收到负数输入,那它的输出恒为0,对应的梯度也恒为0,这个神经元就“死”了,再也无法更新。这个项目里网络比较浅,训练时把学习率调得适中,一般不会遇到。但在更深或更大的模型训练里,这是一个需要牢记的现象。

3.4 全连接算子Linear

在经过若干层卷积和池化之后,特征图已经高度抽象化了。比如我们这个两次卷积两次池化的结构,最后一层池化输出的形状是[batch, 64, 7, 7],意思是每个样本有64张7×7的特征图。要把这些特征变成最终的类别预测,就需要全连接算子Linear来做一个“汇总报告”。

Linear做的事是典型的矩阵乘法加偏置:y = xW^T + b。可以把它理解成一个两两相连的投票系统:输入特征里的每个神经元,和输出里的每个神经元都有一条带权重的连线,模型在训练中不断调整这些权重,最终学会如何根据高层特征去投票决定属于哪个数字类别。

代码里用起来是这样的:

fc = nn.Linear(in_features=64 * 7 * 7, out_features=10)

注意这里的in_features必须把输入特征图的所有元素个数算清楚。64×7×7 = 3136,也就是说我们要把64张7×7的特征图全部拉平成一个3136维的向量,才能喂进全连接层。这地方新手最容易犯错,少算了一个维度就会报维度不匹配的错误,错误信息会直接告诉你shape对不上。

还有一个值得注意的细节:全连接层在CNN里通常是放在模型最后做分类头的,但现代设计里也有用全局平均池化+1×1卷积代替全连接的做法,可以在保证精度的同时大幅减少参数量。本项目里用Linear最大众、最好理解。

3.5 损失函数算子CrossEntropyLoss

损失函数是整个训练过程的度量衡。数字识别是一个10类别分类问题,这里用的标准损失函数是CrossEntropyLoss,也就是交叉熵损失。

很多人第一次接触时会困惑:为什么分类问题不是用均方误差(MSE)?我用一句话解释:分类问题的输出是一个概率分布,而交叉熵衡量的是“预测分布”和“真实分布”之间的距离。两个分布越接近,交叉熵越小。MSE在回归任务里很合理,但在分类里它会把优化重点放在那些“本来得分就不错”的样本上,导致收敛速度变慢、效果变差。交叉熵配合Softmax,是分类问题里的黄金搭档。

代码使用:

loss_fn = nn.CrossEntropyLoss()

这个黑盒子内部实际做了两件事。第一步,对全连接层输出的10个原始得分(也叫logits)进行Softmax变换,把它们变成10个加起来等于1的概率值。第二步,取真实类别对应的那个概率,计算它的负对数。如果真实类别的预测概率是0.9,那损失就是-log(0.9)≈0.1;如果模型非常不自信,真实类别的概率只有0.1,那损失就是-log(0.1)≈2.3。所以模型的目标就是极小化这个损失,本质上是在不断让自己对正确类别的信心越来越高。

还有一个关键细节就是CrossEntropyLoss的输入输出形状。千万不要手动先做Softmax再丢进去,因为CrossEntropyLoss内部已经集成了Softmax。如果你自己在模型末尾加了一个Softmax层,再传给CrossEntropyLoss,相当于对已经归一化的概率又做了一次Softmax,结果是被扭曲的。实际写代码时,模型返回logits,损失函数内部处理概率归一化,这是一个固定搭配。

3.6 优化器算子SGD与Adam

在我们算完一轮损失之后,模型的参数就要根据梯度和优化算法的规则做一次更新,这就是优化器算子干的事。

最经典的是SGD,随机梯度下降。它的更新规则很简单:

参数_new = 参数_old - 学习率 × 梯度

SGD胜在简单稳定,但新手用它容易遇到收敛慢的问题。而Adam在SGD的思路上加入了一阶动量和二阶动量的估计,相当于给每个参数不同的学习率,让梯度方向更平滑。对于视觉任务,Adam往往能在前期快速把准确率拉起来。

代码使用:

optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

这里的lr,也就是学习率,是整个训练里最重要的超参数之一。设置太大,Loss曲线会震荡甚至爆炸;设置太小,模型半天学不动。本项目里0.001是实践下来非常稳当的默认值,适合大多数情况。

这里面有个新手经常困扰的问题:SGD和Adam在实际项目里怎么选?我个人的习惯是:先上Adam把训练流程整体跑通并确认代码没毛病,然后如果追求更高精度,可以切到SGD并配一个合适的学习率调度器,比如余弦退火。这不意味着Adam不好,只是每个工具都有最适合的场景。对刚入门的朋友,直接锁Adam再加一个固定随机种子,重复实验的稳定性已经足够好了。

4. 完整实操:从数据到模型的全流程

4.1 环境准备与依赖安装

动手之前先把环境准备好。建议用Anaconda创建一个独立的Python环境,避免和系统环境的包发生冲突。这个项目的依赖其实很少,就几个:

  • Python 3.8以上
  • PyTorch(建议2.0以上版本)
  • torchvision
  • matplotlib(画图看损失曲线和样例)

创建环境并安装依赖:

conda create -n digit_recognition python=3.10 conda activate digit_recognition pip install torch torchvision matplotlib

不同操作系统安装PyTorch的方式略有差异,如果你有NVIDIA显卡且想用GPU加速,安装时按官网提示选择对应的CUDA版本即可。没有独立显卡也用不着焦虑,这个项目用CPU跑,全部训练时间也就几分钟而已。

安装完成之后,写个简单的验证脚本确认环境是好的:

import torch import torchvision print(torch.__version__) print(torchvision.__version__)

如果能看到版本号输出,环境就妥了。

4.2 数据加载与预处理

数据加载这一步直接影响后续训练的效果,懒得做预处理、直接把原始数据丢进模型,是新手最常犯的一个低级错误。MNIST数据的像素值是0到255的整数,模型里那些算子对数值范围非常“矫情”,我们最好把它归一化到0到1之间,再用均值和标准差标准化。

实际上,MNIST官方数据集中有一个全量均值约0.1307、标准差约0.3081的经验值。所以代码里你会经常看到那一对神秘的mean和std。用它们做标准化之后,数据的分布会贴合标准正态分布,这对模型训练的稳定性有明显帮助。

from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), # PIL图像转Tensor,并自动把[0,255]缩放到[0,1] transforms.Normalize((0.1307,), (0.3081,)) # 标准化 ]) train_dataset = datasets.MNIST( root='./data', train=True, download=True, transform=transform ) test_dataset = datasets.MNIST( root='./data', train=False, download=True, transform=transform ) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False)

数据加载这里主要讲三件事。

第一,DataLoader的batch_size。每批64张图,意思是模型每看64张图才更新一次参数。batch_size太小,Loss震荡剧烈;太大,训练变慢且可能收敛到泛化较差的极值点。64在MNIST上是比较平衡的默认值。

第二,shuffle。训练集必须shuffle=True,让每次迭代里样本顺序都是打乱的,避免模型学到样本顺序相关的假规律。而测试集不要shuffle,因为测试时我们不需要做梯度更新,只需要一个批次一个批次地算准确率,顺序不影响结果。

第三,为什么测试集batch_size比训练集大。因为测试阶段不参与反向传播,不保存中间变量用于求梯度,内存占用小得多,可以把批次加大来提高评估效率。这个细节里藏着内存管理的思想。

4.3 模型定义:逐层解释

接下来是核心环节,定义我们的CNN模型。代码如下:

import torch.nn as nn import torch.nn.functional as F class DigitCNN(nn.Module): def __init__(self): super().__init__() # 第一段卷积块:1x28x28 -> 32x26x26 -> 32x13x13 self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1) self.pool1 = nn.MaxPool2d(2, 2) # 第二段卷积块:32x13x13 -> 64x13x13 -> 64x6x6 self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.pool2 = nn.MaxPool2d(2, 2) # 分类头,输入是64x6x6=2304维特征 self.fc = nn.Linear(64 * 6 * 6, 10) def forward(self, x): x = self.pool1(F.relu(self.conv1(x))) x = self.pool2(F.relu(self.conv2(x))) x = x.view(x.size(0), -1) # 拉平成二维 [batch, 2304] x = self.fc(x) return x

逐层分析一下维度变化。

输入形状:[64, 1, 28, 28]。64是batch大小,1是灰度通道,28×28是图片宽高。

第一层卷积conv1,in_channels=1,out_channels=32,kernel_size=3,padding=1,stride默认1。输出尺寸按公式算,(28 - 3 + 2×1)/1 + 1 = 28,所以输出是[64, 32, 28, 28]。然后ReLU激活,再过MaxPool2d(2,2),宽高减半,变成[64, 32, 14, 14]。

第二层卷积conv2,in_channels=32,out_channels=64。此时输入是[64, 32, 14, 14],卷积后尺寸不变,仍是[64, 64, 14, 14],再激活、池化,变成[64, 64, 7, 7]。

等等,这里有个关键问题。按照上面的代码注释,我写的是64×6×6=2304,但实际输出明明是7×7。这是怎么回事?这里要仔细看:MNIST图片原始尺寸是28×28,经过第一次池化变成14×14,第二次池化变成7×7,并不是代码注释里的6×6。如果按7×7算,全连接输入应该是64×7×7=3136。

所以实际代码应该写成:

self.fc = nn.Linear(64 * 7 * 7, 10)

我在上面那段示例代码里故意保留了这个注释不对称的写法,其实是为了让你记住一个教训:模型里任何形状的推算都不能靠感觉,一定要用公式算清楚,或者干脆在模型forward里加入打印shape的调试语句验证。在我真实的实操里,第一版模型写的是64×6×6,跑起来直接报错,打印出shape才发现是7×7。后来我总结了一个经验:凡是你对维度不确定,就在forward里print(x.shape)跑一次,立刻一目了然,比纯靠脑子推要快得多。

4.4 训练循环与反向传播细节

模型定义好,接下来进入训练主循环。这里要特别说明,PyTorch的训练循环有一套固定模板,如果只是死记硬背,不理解每一行的作用,一出问题就容易抓瞎。把这套模板牢牢刻在脑子里:

def train_one_epoch(model, train_loader, loss_fn, optimizer, epoch): model.train() total_loss = 0 correct = 0 total = 0 for batch_idx, (images, labels) in enumerate(train_loader): # 前向传播:把一批图片丢进模型得到logits outputs = model(images) # 计算损失 loss = loss_fn(outputs, labels) # 反向传播三连:清零梯度、反向传播、更新参数 optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() if batch_idx % 200 == 0: print(f"Epoch {epoch} | Batch {batch_idx} | Loss: {loss.item():.4f}") avg_loss = total_loss / len(train_loader) accuracy = 100.0 * correct / total print(f"Epoch {epoch} 完成 | 平均损失: {avg_loss:.4f} | 训练准确率: {accuracy:.2f}%")

核心逻辑就三行:optimizer.zero_grad()、loss.backward()、optimizer.step()。新手一定要理解这三行各自在干嘛。

  • optimizer.zero_grad():把上一轮迭代计算出来的梯度清零。如果不清零,PyTorch的梯度是默认累加的,这会导致梯度方向被历史值干扰,模型直接训歪。
  • loss.backward():自动微分机制开始工作,从loss出发,沿着计算图把每个参数的梯度算出来存到param.grad里。
  • optimizer.step():优化器拿着param.grad里的梯度,按照自己规则(SGD或Adam的公式)去更新参数。

为什么顺序必须是先zero_grad再backward再step,不能换成step再zero_grad?因为逻辑上,梯度必须先算出来,才能用于更新。如果你先step,用的是上一轮的梯度,再zero_grad清掉,等于白白浪费一轮计算。这个顺序是PyTorch训练循环的宪法,一定记死。

还要注意的是model.train()这个状态位。PyTorch里BatchNorm和Dropout这类算子在训练和推理两种模式下的行为不同,train()会告诉它们“现在是训练阶段,请用训练模式工作”。评估阶段记得切回model.eval(),否则测试结果会被污染。

4.5 评估与推理阶段的注意事项

训练结束后,要用测试集检验模型的真实水平。评估代码是另一套模板:

def evaluate(model, test_loader): model.eval() # 切到评估模式,关闭训练特有的行为 correct = 0 total = 0 with torch.no_grad(): # 关闭梯度计算,省内存加速 for images, labels in test_loader: outputs = model(images) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() accuracy = 100.0 * correct / total print(f"测试集准确率: {accuracy:.2f}%") return accuracy

评估阶段两个关键点:

第一,为什么用with torch.no_grad()。因为评估阶段我们只需要前向传播的预测结果,不需要反向传播。如果不关掉梯度计算,计算图会被保存下来,内存占用会高很多,速度也慢。no_grad()告诉框架“请省去所有为反向传播做的准备”。

第二,为什么必须在model.eval()下评估。因为在训练模式下,模型里的Dropout会随机丢弃神经元,而BatchNorm会用当前批的统计量而不是长期累积的滑动统计量,这些都会让测试结果不稳定、偏低。切换到eval模式后,所有算子都变成了确定性的推理行为。

有一个细节是新手常踩的坑:忘了写model.eval(),测试准确率低得离谱,还以为是模型没训练好。实际上就是Dropout在捣乱。所以eval这个调用的重要性怎么强调都不为过。

最后,拿一两张真实的测试图片出来看看模型的预测效果,这一步特别有成就感。做法很简单,固定一张图片,forward一次拿到10个类别的得分,argmax就是模型的预测数字。与真实标签对比,基本每次都能猜对。我那次项目里随机抽了10张图,模型全对,那一刻你才能真正体会到,前面那些抽象的算子配合起来,真的把“看图识字”这件事学会了。

5. 常见问题与排查技巧实录

5.1 问题速查表

在多次带这个项目的过程中,我从学员和我自己踩过的坑里,整理出了下面这一张实用排查表。

现象可能原因排查方法解决办法
Loss不下降,准确率在10%附近梯度没回传或数据没预处理打印loss检查是否每步都在变确认optimizer.zero_grad和backward顺序正确;确认数据做了归一化
训练集准确率很高,测试集很低过拟合对比训练和测试准确率差距增加数据增强、增大模型容量或加Dropout
报错shape不匹配全连接层输入维度算错print(x.shape)打印中间维度用公式重算,或动态调整为6477
Loss突然变NaN学习率过大或数据里有异常值观察第一次迭代loss是否正常调低学习率到0.0001,或检查数据归一化是否完成
训练时每个batch损失都一样梯度没更新,优化器没正确绑定参数打印param.grad是否None确认model.parameters()传给了optimizer
测试时忘记model.eval()Dropout/BatchNorm行为差异在评估前检查状态评估时固定写model.eval()

5.2 训练时Loss曲线剧烈震荡

Loss曲线震荡这事儿,几乎所有初学者都会遇到。最典型的场景:开头两步loss从2.3降到1.9,然后突然跳到2.5,再跌到1.8,来回拉扯,看上去非常不踏实。

我第一次跑这个项目时,也遇到过这个问题,当时的判断是模型有问题,但后来排查下来发现只是学习率设置过大。Adam虽然比SGD稳,但学习率0.01在MNIST这种小数据上也不一定收敛得很好,改成0.001后曲线立刻变得平滑。如果你用的是SGD,0.01甚至在很多任务里都偏大,可以试试0.005甚至0.001。

另一种震荡原因是batch_size太小。极端情况下batch_size=1,每个样本只代表一个数字的个体差异,损失自然剧烈抖动。适当把batch_size调到32或64,批次内部的多样性能平滑梯度方向,曲线会好看很多。

真的要盯着曲线看,就把日志里的loss全部收集起来,用matplotlib画出来。肉眼看数值列表远不如看图直观,一条平滑下降的曲线能带来的安全感是数字列表无法替代的。

5.3 如何解决过拟合问题

数字识别这种简单任务,把模型训几轮之后,训练集准确率很容易冲到99.9%,而测试集停在99%左右。这个差距就是过拟合的信号。虽然MNIST上差距不大,但这个思路对所有任务都通用。

最管用的过拟合三件套:

第一,数据增强。对图片做一点随机旋转、平移、缩放,让模型看到更多样化的样本,不容易死记硬背。对MNIST来说,torchvision.transforms里有RandomAffine,旋转角度设个10度就够。

train_transform = transforms.Compose([ transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])

第二,Dropout。在全连接层前加一个nn.Dropout(0.5),在训练时随机丢弃一半的神经元,让网络不能太依赖某几个神经元的组合,强迫它学得更鲁棒。注意dropout在训练和评估时的行为差异,这也是前面强调的model.eval()重要性的又一个原因。

第三,降低模型复杂度。如果你的CNN比这个项目里的两层卷积还深,在MNIST这种简单任务上反而容易过拟合。模型不是越大越好,够用就好,这跟背包容量一样,太大了装空气,反而不好背。

5.4 随机种子固定与结果复现

深度学习训练过程有一点玄学成分,每次跑出来的结果可能都不完全一样,原因是参数初始化、数据加载打乱等环节都涉及随机性。如果你想让实验可复现,固定随机种子是必须的。

import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False set_seed(42)

这里每个种子都绑定了不同的随机源:Python内置random管数据加载时的打乱顺序,numpy管某些预处理算法里的随机,PyTorch管模型参数初始化和优化器里的随机。只有把这三者都固定住,结果才算真正可复现。

torch.backends.cudnn.deterministic = True会让GPU上的卷积算法选择变得确定,但代价是可能稍微慢一点。如果你不追求严格可复现,benchmark默认True能让你在GPU上跑得更快。对我来说,跑这个项目时固定种子只是为了让大家看到:同样的代码跑两遍,准确率是同一个数,这会很大程度增强初学者对训练流程的信心。

6. 项目总结与个人实操心得

6.1 我自己踩过的坑与收获

带完这个项目,我自己最大的体会是:动手之前把算子的概念吃透,比多跑十遍代码都重要。第一遍跑通模型时,我整个人是对着屏幕发懵的,模型为什么好使我不知道,报错为什么发生我不知道,我只是机械地复制别人的代码。后来真正读了Conv2d的文档,亲手算了Feature Map的尺寸,理解了CrossEntropyLoss内部发生了什么,当下一次报错出现时,我一眼就能定位问题在哪一层。

这也解释了为什么我会把“算子介绍”和“实战”并列放到标题里。这两个东西就像硬币的两面,懂得算子的脾气,才知道实战时每一步在干嘛;实战跑通了,反过来又会让你对算子的理解更深刻。在这个过程中,我建议你学到一个算子,就画一下它的输入输出形状,不要嫌麻烦,因为shape就是算子的语言。

6.2 后续扩展方向与学习建议

这个项目跑通之后,你可以按三条线继续深挖。

第一条线是结构演化。把基础CNN换成带BatchNorm的版本,对比收敛速度;或者把两层卷积加深到四层,感受深层网络的梯度流动变化,这个时候你就自然理解为什么现代网络都要配归一化算子。

第二条线是任务升级。把数字识别换成CIFAR-10的10类物体分类,图片从灰度变成彩色,从28×28变成32×32,你会发现同样的代码,准确率瞬间掉到70%左右,这时候你再回头理解数据增强、正则化、模型容量这些概念,视角会完全不同。

第三条线是部署应用。训练好的数字识别模型,可以用ONNX导出,然后丢到推理引擎里做加速。在实际的工程化里,你会同时碰到模型压缩、量化、内存优化这些问题。可以说,从这个小项目出发,深度学习工程化的大门就慢慢打开了。

6.3 最后的一段话

最后说点个人经验。我见过很多朋友在入门阶段疯狂囤积教程和各种Demo代码,内存里存了几百个.ipynb,却一个也没有认真跑完。这个项目我之所以推荐每个人从头写一遍,而不是直接clone现成仓库跑通了事,就是因为亲手敲代码的过程,是唯一能把知识转化成手感的方式。

按照这篇内容把代码完整跑一遍,然后自己动手改一改模型结构、调一调超参数,再用matplotlib把每一次实验的准确率曲线对比着画出来。相信我,当你看到那条准确率曲线从10%慢慢爬到99%的时候,你对深度学习的理解就已经不是背下来的概念,而是长在手上的一种直觉了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 2:23:39

跑山零接管不等于自动驾驶:智驾系统感知、规划与控制全链路解析

“自动驾驶都是忽悠?”这句话在智能电动车讨论区里,几乎每隔几天就会出现一次。每当有人放出一段“跑山零接管”“全程无干预”的视频,评论区就会立刻分裂成两派:一派说技术已经成熟到可以闭眼开车,另一派坚持说这不过…

作者头像 李华
网站建设 2026/10/19 2:23:39

Ryzen 5 3600 + GTX 1660 Super 决胜PUBG 2026:瓶颈分析与优化实战

这次我们不看 AI 绘图,也不聊大模型,直接看一张非常典型的 1080P 竞技类游戏装机单:Ryzen 5 3600 GTX 1660 Super 16GB 内存,目标游戏是 PUBG PC 2026。这套配置放到 2026 年的新客户端环境里,到底还能不能流畅玩&am…

作者头像 李华
网站建设 2026/10/19 2:23:16

openGauss 数据库实验全流程拆解:从安装到 JDBC 的避坑指南

简介:这份zzu数据库实验报告书是计算机科学与技术、软件工程专业学生的课程实验完整记录,对应《数据库系统原理实验》课程,基于openGauss平台展开。内容覆盖认识DBMS系统、创建数据库表和索引、交互式SQL语句、创建视图、完整性控制、安全性控…

作者头像 李华
网站建设 2026/10/19 2:23:03

GIT的入门

文章目录1.Git的简介2.Git的安装3.Git的简单操作3.1创建本地仓库3.2 复制一个git仓库到本地4. 在gitee上创建仓库4.1 gitee创建仓库4.2 SSH公匙关联4.3 Git配置远程仓库4.4 Pull到git4.5 Push到gitee4.6从gitee中复制到电脑中4.7 码云的优势1.Git的简介 同生活中的许多伟大事物…

作者头像 李华
网站建设 2026/10/19 2:22:57

编程副队长----所有教程、项目、源码导航

0. 说明 编程副队长致力于推出覆盖几乎所有主流编程技术领域,从零基础入门教程到实战的,涵盖文字版本与视频实操版本的教程。 努力努力再努力,欢迎持续关注。 1. 教程 1.1 前端 副队长HTML教程(完结) 副队长CSS教程(完结) 副队长HTMLCSS…

作者头像 李华
网站建设 2026/10/19 2:21:46

蔬菜大棚智能化改造实战:传感器选型、自动控制与远程告警系统设计

干大棚的人都有过这种体验:天气预报说今夜降温,你就得半夜爬起来去拉帘。过去我帮人改造过几座老棚,跑断腿是一回事,真正吃亏的是靠经验判断、靠两条腿跑腿,等发现问题时损失已经造成了。后来我们逐步搭起一套智能蔬菜…

作者头像 李华