尧图精选

神经网络入门:PyTorch实现MNIST手写数字识别全流程解析

🕒 发布时间:2026/9/7 12:30:38 📁 来源:尧图网络
刚开始学神经网络的人最容易卡住的不是数学而是“公式和代码对不上”。你背下了y wx b知道梯度下降是“沿着梯度反方向更新参数”但打开代码编辑器看到model.forward()、loss.backward()、optimizer.step()这些调用时还是会一脸茫然这一步到底改了哪个参数为什么训练时要多一个zero_grad()测试时为什么要加torch.no_grad()本文要解决的就是这个问题。我会用“一句话解释 代码示例 运行逻辑分析”的方式带你从零跑通一个完整的神经网络上手项目基于 PyTorch 的手写数字识别。读完这篇文章你能获得三样东西能看懂神经网络代码而不是只会复制运行能自己动手写一个最小训练流程并解释每一步在做什么遇到训练不收敛、loss 不下降、代码看不懂 shape 报错时有清晰的排查思路。1. 这篇文章真正要解决的问题关于神经网络的教程非常多但存在一个普遍问题讲原理的只讲原理放代码的只放代码两者之间缺少一座桥。结果就是有人看完反向传播公式仍然不知道它在 PyTorch 里对应哪一个 API有人能跑通 MNIST 示例但把代码里的view()删掉就报错却不知道为什么要写这一行有人训练完模型不知道怎么保存、怎么加载、怎么给别人用还有人把训练代码改了一堆参数loss 却一直不降完全没有排查思路。这篇文章的核心判断是入门神经网络的关键不是背熟反向传播的数学推导而是建立“计算图”意识看清数据在模型里是怎样流动的。当你脑子里有了“输入张量 → 网络层 → 输出 → 损失 → 梯度 → 参数更新”这条完整链路再回来看任何开源代码都不会觉得是黑盒。所以你不需要是数学专业背景也不需要先看完一本书再动手。只需要有一点 Python 基础能看懂最简单的for循环和import就可以跟着文章跑起来。2. 神经网络核心概念把公式映射到代码在写代码之前我们先建立几个关键概念的代码对应关系。这里不追求严格的数学定义只求“提到概念时你能想到代码里是什么”。2.1 神经元一个带参数的函数单个神经元做的事情其实就是一次线性变换加一次激活函数输出 激活函数( 权重 × 输入 偏置 )写成数学公式是z w^T * x b a activation(z)对应到 Python 里这一段代码就表示一个神经元import numpy as np def sigmoid(x): return 1.0 / (1.0 np.exp(-x)) def neuron(x, w, b): z np.dot(w, x) b return sigmoid(z) x np.array([0.5, 0.3, 0.8]) # 输入向量 w np.array([0.2, -0.4, 0.1]) # 权重向量 b 0.5 # 偏置 print(neuron(x, w, b))这里的w和b就是神经网络的“参数”。整个训练过程的目标就是不断调整w和b让神经元的输出尽量接近目标值。2.2 层把多个神经元堆在一起单个神经元能力有限所以工程上会把很多神经元放在一起形成“层”。在 PyTorch 中全连接层对应nn.Linearimport torch.nn as nn # 输入维度 784输出维度 128 layer nn.Linear(784, 128)这一层内部其实维护了一个形状为(128, 784)的权重矩阵和一个形状为(128,)的偏置向量。你可以把它理解成 128 个神经元并联在一起。2.3 激活函数给模型加入非线性如果神经网络只有线性变换无论堆多少层最终表达的都还是一个线性函数。隐藏层再多也没意义。激活函数的加入才让网络有能力拟合复杂的非线性关系。常见的激活函数包括激活函数公式主要用途Sigmoid1 / (1 exp(-x))输出范围 0~1适合二分类输出层Tanh(exp(x) - exp(-x)) / (exp(x) exp(-x))输出范围 -1~1适合需要正负输出的场景ReLUmax(0, x)隐藏层最常用计算简单缓解梯度消失在 PyTorch 中nn.ReLU()直接被当成网络的一层来用self.relu nn.ReLU()2.4 损失函数与优化器训练的方向盘损失函数衡量“模型输出”和“真实标签”之间的差距。Pytorch 里常用的分类损失是交叉熵损失criterion nn.CrossEntropyLoss()优化器负责根据损失函数算出的梯度去更新模型参数optimizer optim.Adam(model.parameters(), lr0.001)参数lr是学习率决定每一步更新参数时走多远。学习率太大容易震荡太小收敛很慢。2.5 计算图理解 backward 的关键PyTorch 最核心的设计就是“动态计算图”。当你执行一次前向传播时PyTorch 会自动记录张量之间的运算关系形成一个计算图。之后调用loss.backward()它会沿着这个图反向传播梯度给每个参与运算的参数填上.grad属性。所以标准训练循环长这样optimizer.zero_grad() # 1. 梯度清零 outputs model(inputs) # 2. 前向传播 loss criterion(outputs, labels) # 3. 计算损失 loss.backward() # 4. 反向传播计算梯度 optimizer.step() # 5. 更新参数新手最容易犯的错误是忘记第一步zero_grad()。如果不清理上一次的梯度梯度会累加参数更新方向就乱了。3. 环境准备Python PyTorch 实操环境本文的代码基于 Python 3.9 和 PyTorch 2.x 编写但都是通用 API版本差异不大。如果你没有安装环境可以按下面步骤准备。创建一个干净的虚拟环境conda create -n nn-demo python3.10 -y conda activate nn-demo安装依赖pip install torch torchvision matplotlib如果你的机器有 NVIDIA 显卡建议去 PyTorch 官网用对应命令安装 CUDA 版本。如果暂时没有 GPU直接在 CPU 上跑 MNIST 也完全可以这个数据集很小普通笔记本几分钟内就能完成一次训练。安装完验证一下import torch import torchvision print(PyTorch version:, torch.__version__) print(Torchvision version:, torchvision.__version__) print(CUDA available:, torch.cuda.is_available())如果能正常打印出版本号说明环境已经准备好。即使CUDA available显示False也不影响本文后续实验。4. 从零手写NumPy 实现神经元与参数更新很多人总觉得“模型训练”是个很黑盒的过程。为了消除这种感觉我们先抛开深度学习框架用 NumPy 手写一个最简单的神经元并手动执行梯度下降。这个例子的任务是拟合一条直线已知x和y_true满足y_true 2x我们希望神经元自己学出接近 2 的权重。import numpy as np def forward(x, w, b): return w * x b def loss_fn(y_pred, y_true): return ((y_pred - y_true) ** 2).mean() x np.array([1.0, 2.0, 3.0, 4.0]) y_true np.array([2.0, 4.0, 6.0, 8.0]) w 0.0 b 0.0 lr 0.01 for step in range(200): y_pred forward(x, w, b) loss loss_fn(y_pred, y_true) # 手动求梯度 grad_w 2 * (y_pred - y_true) x / len(x) grad_b 2 * (y_pred - y_true).mean() # 梯度下降更新参数 w w - lr * grad_w b b - lr * grad_b if (step 1) % 50 0: print(fstep {step 1}: loss {loss:.6f}, w {w:.4f}, b {b:.4f})运行后可以看到loss 逐步下降w慢慢接近 2b慢慢接近 0。这段代码体现了神经网络的本质前向传播算出损失反向传播算出梯度梯度告诉我们参数该往哪个方向调调多少由学习率控制。PyTorch 只是把这个过程自动化了核心思想完全一致。5. 核心实操PyTorch 实现 MNIST 手写数字识别现在进入本文最重要的部分用 PyTorch 实现一个完整的图像分类模型。5.1 为什么选 MNISTMNIST 是深度学习界的“Hello World”。它包含 6 万张训练图片和 1 万张测试图片每张都是 28×28 的灰度手写数字标签是 0~9。选它有三个原因数据量小CPU 上也能快速训练预处理简单不需要复杂的增强策略适合完整跑通“数据加载 → 模型定义 → 训练 → 评估 → 保存”这条流程。5.2 数据加载与预处理使用torchvision可以一行代码下载并加载 MNISTfrom torch.utils.data import DataLoader from torchvision import datasets, transforms transform transforms.Compose([ transforms.ToTensor(), # 把 PIL 图像转成 Tensor并归一化到 [0, 1] transforms.Normalize((0.1307,), (0.3081,)) # 标准化 ]) train_dataset datasets.MNIST( root./data, trainTrue, transformtransform, downloadTrue ) test_dataset datasets.MNIST( root./data, trainFalse, transformtransform, downloadTrue ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size128, shuffleFalse)这里解释两个关键点ToTensor()把原本范围在[0, 255]的像素值转换到[0, 1]并且把数据形状变成(通道, 高, 宽)。Normalize((0.1307,), (0.3081,))是 MNIST 数据集的均值和标准差做标准化后能让模型训练更稳定。DataLoader的作用是把数据集按批次打包。train_loader里每个 batch 的形状是(64, 1, 28, 28)分别表示批量大小 64、通道数 1、高 28、宽 28。5.3 定义一个简单 CNN这里选用一个简单的卷积神经网络CNN而不是纯全连接网络。因为卷积层更适合处理图像数据也能让后面讲清 shape 变化。import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) self.relu nn.ReLU() def forward(self, x): x self.pool(self.relu(self.conv1(x))) # 输出 32 x 14 x 14 x self.pool(self.relu(self.conv2(x))) # 输出 64 x 7 x 7 x x.view(x.size(0), -1) # 展平成 64 x (64*7*7) x self.relu(self.fc1(x)) x self.fc2(x) return x各层输出形状变化如下层输入 shape输出 shapeconv1 relu pool(64, 1, 28, 28)(64, 32, 14, 14)conv2 relu pool(64, 32, 14, 14)(64, 64, 7, 7)view 展平(64, 64, 7, 7)(64, 3136)fc1 relu(64, 3136)(64, 128)fc2(64, 128)(64, 10)最后输出 10 个数分别表示输入图片属于数字 0~9 的“分数”。通常取最大分数的下标作为预测类别。5.4 训练循环训练循环是深度学习代码里最固定的部分import torch.optim as optim model SimpleCNN() criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) def train_one_epoch(): model.train() total_loss 0.0 correct 0 total 0 for images, labels in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) correct (predicted labels).sum().item() total labels.size(0) return total_loss / total, correct / total每一次迭代的五个步骤对应我们之前在计算图部分讲过的流程optimizer.zero_grad()清理上一次的梯度outputs model(images)前向传播loss criterion(outputs, labels)计算损失loss.backward()反向传播自动算出各参数梯度optimizer.step()根据梯度更新参数。model.train()是一个容易被忽略但很重要的调用。它会告诉模型当前处于训练模式某些层比如 Dropout、BatchNorm会采用训练阶段的行为。5.5 评估与保存测试时不需要计算梯度所以用torch.no_grad()包裹def evaluate(): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: outputs model(images) _, predicted torch.max(outputs, 1) correct (predicted labels).sum().item() total labels.size(0) return correct / totalmodel.eval()和model.train()正好相反它让模型切换到推理模式。torch.no_grad()的作用是告诉 PyTorch 反向传播阶段“这次不需要记录计算图”既能省内存也能加快推理速度。训练并保存模型epochs 5 for epoch in range(epochs): train_loss, train_acc train_one_epoch() test_acc evaluate() print(fEpoch {epoch 1}/{epochs}, Loss: {train_loss:.4f}, fTrain Acc: {train_acc:.4f}, Test Acc: {test_acc:.4f}) torch.save(model.state_dict(), mnist_cnn.pth) print(模型已保存到 mnist_cnn.pth)在正常训练情况下5 个 epoch 后测试集准确率可以到达 98% 以上。这个表现对于入门项目已经足够。5.6 完整代码汇总为了方便你直接运行下面把前面所有代码合并成一个文件。文件路径可保存为mnist_train.py。import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) self.relu nn.ReLU() def forward(self, x): x self.pool(self.relu(self.conv1(x))) x self.pool(self.relu(self.conv2(x))) x x.view(x.size(0), -1) x self.relu(self.fc1(x)) x self.fc2(x) return x transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST( root./data, trainTrue, transformtransform, downloadTrue ) test_dataset datasets.MNIST( root./data, trainFalse, transformtransform, downloadTrue ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) test_loader DataLoader(test_dataset, batch_size128, shuffleFalse) model SimpleCNN() criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001) def train_one_epoch(): model.train() total_loss 0.0 correct 0 total 0 for images, labels in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) correct (predicted labels).sum().item() total labels.size(0) return total_loss / total, correct / total def evaluate(): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: outputs model(images) _, predicted torch.max(outputs, 1) correct (predicted labels).sum().item() total labels.size(0) return correct / total epochs 5 for epoch in range(epochs): train_loss, train_acc train_one_epoch() test_acc evaluate() print(fEpoch {epoch 1}/{epochs}, Loss: {train_loss:.4f}, fTrain Acc: {train_acc:.4f}, Test Acc: {test_acc:.4f}) torch.save(model.state_dict(), mnist_cnn.pth) print(模型已保存到 mnist_cnn.pth)运行命令python mnist_train.py第一次运行会自动下载数据集下载完成后开始训练。如果你发现打印出的 Test Acc 在 98% 以上说明整个流程已经跑通。加载已保存的模型做推理model SimpleCNN() state_dict torch.load(mnist_cnn.pth) model.load_state_dict(state_dict) model.eval() # 假设 images 是一个形状为 (batch, 1, 28, 28) 的张量 with torch.no_grad(): outputs model(images) predictions torch.argmax(outputs, dim1) print(predictions)注意load_state_dict之后同样需要调用一次model.eval()确保推理时模型处于正确的模式。6. 代码逐段解释新手最容易忽略的4个细节代码跑通之后更重要的是理解几个“不写也能跑但不理解就很容易出 bug”的细节。6.1model.train()和model.eval()的作用很多模型同时包含训练时和推理时行为不一致的层。例如 Dropout 在训练时会随机丢弃部分神经元推理时应该关闭BatchNorm 在训练时会用当前 batch 的统计量推理时用累计的全局统计量。所以训练循环里要明确调用model.train()验证和测试时要调用model.eval()。少写了模型的表现可能和你预期差很多但不会直接报错属于“隐蔽 bug”。6.2torch.no_grad()为什么能省显存默认情况下PyTorch 会自动记录所有张量运算用于后续反向传播。但在测试阶段我们不需要反向传播这些记录纯属浪费内存。用with torch.no_grad()包住测试代码PyTorch 就不会构建计算图占用内存大幅减少。6.3x.view(x.size(0), -1)的作用全连接层要求输入是二维张量(batch, feature)。但是卷积层输出的 shape 是(batch, channels, height, width)。所以需要用view把每个样本的多维特征展平成一维。x.size(0)保留 batch 维度-1表示让 PyTorch 自动推算剩余维度。如果你删除这一行nn.Linear会直接报维度不匹配的错误。6.4 为什么测试准确率可能比训练准确率高很多初学者看到“测试集准确率比训练集高”就很困惑觉得不符合常理。原因通常是训练循环中计算训练准确率时模型还处于训练模式加上当前 batch 的统计噪声单 batch 准确率会波动而测试集是在全部数据上统计的。此外 Dropout 在训练时会随机丢弃神经元也会让训练阶段的准确率显得偏低。遇到这种情况不必惊慌关键是看整体趋势训练 loss 在下降测试准确率在上升或保持稳定都算正常。7. 进阶认知从 MLP 到 CNN 再到 RNN跑通 MNIST 之后你应该把视角拉高一层理解不同类型网络之间的区别。因为实际项目里选对模型结构比调参重要得多。7.1 MLP适合表格数据全连接网络适合输入是“一组特征”的数据比如用户画像、数值型单据数据。每个特征之间没有明显的空间或时序关系。但把图片像素全部拉平输入 MLP参数会暴涨而且会丢失空间结构信息。7.2 CNN适合图像等网格数据CNN 之所以适合图像是因为它有“局部感知”和“权值共享”两个特性。局部感知卷积核只观察图片的一个小区域而不是像 MLP 一样连接所有像素权值共享同一个卷积核在整张图上滑动参数数量大大减少。这就是为什么一个 28×28 的 MNIST 图片用 MLP 做一个隐层要 784×128 参数而 CNN 的卷积核往往只需要几百个参数。7.3 RNN适合序列数据如果你想处理文本、语音、时间序列就需要循环神经网络。RNN 的核心是有一个“隐藏状态”h它会在时间步之间传递信息。标准循环神经网络在时间步t的隐藏状态更新公式是h_t tanh(W_hh * h_{t-1} W_xh * x_t b_h)通俗理解是RNN 像一个人在逐字阅读文本每读到一个新词都会结合上一个位置的“记忆”来更新当前的理解。PyTorch 里使用 RNN 同样非常简洁import torch.nn as nn class SimpleRNN(nn.Module): def __init__(self, input_size, hidden_size): super().__init__() self.rnn nn.RNN(input_size, hidden_size, batch_firstTrue) self.fc nn.Linear(hidden_size, 1) def forward(self, x): out, h_n self.rnn(x) # 取最后一个时间步的输出 out self.fc(out[:, -1, :]) return out7.4 更广阔的网络家族除了 MLP、CNN、RNN实际工程中还会遇到图神经网络GNN适合社交网络、分子结构、推荐系统等图结构数据Transformer基于注意力机制是目前大语言模型的基础结构物理信息神经网络PINN把物理方程嵌入神经网络用于科学计算。这些都属于“做出正确结构选择”的进阶话题。入门阶段先把 MNIST 这类经典数据集的完整训练流程吃透再横向扩展会顺利得多。8. 常见问题与排查思路下面整理了几个新手最容易遇到的问题以及相应的排查方式。问题现象可能原因排查方式解决方案训练 loss 一直不降学习率过大或过小数据未归一化模型结构有误打印每个 batch 的 loss观察变化检查输入数据范围和标签范围调整学习率确认是否使用 ToTensor 和 Normalize从最简单的模型开始验证报错mat1 and mat2 shapes cannot be multiplied全连接层输入维度计算错误在view之后打印x.shape按公式重新计算特征维度或临时用print观察 shape测试准确率特别低忘记调用model.eval()数据预处理不一致检查评估函数是否使用no_grad和eval在评估前调用model.eval()并保证训练和测试使用同一套预处理流程训练时显存或内存不足batch size 太大图片分辨率太高查看报错信息中的 tensor 大小调小batch_size用torch.no_grad()包住测试逻辑保存的模型加载后结果不对加载后没有调用eval()模型结构定义不一致检查加载的state_dict的 key 是否匹配保持模型结构和训练时一致加载后调用model.eval()下载数据集失败网络问题或存储目录不可写查看网络连接检查./data目录权限手动下载 MNIST 放到./data目录下或更换可写目录backward 报错提示梯度为 None某个参数没有参与计算图或模型输出与损失函数不匹配检查自定义层是否在forward中被调用检查损失函数输入维度确保所有参数都参与了前向计算对于多分类任务使用CrossEntropyLoss一个通用排查技巧是把 batch size 调成 2用几行代码打印每一步的张量 shape。绝大多数新手代码问题都会在 shape 面前现出原形。9. 最佳实践与后续学习方向最后这部分给你一些工程上真正有用的建议而不是单纯的教程复述。9.1 从最小可运行模型开始不要一开始就搭建复杂的网络。先用一个最简单的模型把数据加载、训练、评估全流程跑通再逐步加深网络。这样出了问题你能确定问题出在新增的那几层里而不是全局抓瞎。9.2 固定随机种子神经网络有随机性每次训练结果可能不完全一样。为了实验可复现建议在代码开头设置随机种子import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed)这样做的好处是调参时你能确认效果变化来自参数调整而不是随机波动。9.3 记录训练日志实际项目中不要只靠print。建议把每个 epoch 的 train loss、train acc、test acc 记录到 CSV 或日志文件里训练结束后画出曲线。你才能判断模型到底是在正常收敛、过拟合还是欠拟合。9.4 不要盲目调参学习率是最重要的超参数但如果模型不收敛先检查数据预处理和模型结构再调学习率。不要同时改多个超参数否则你永远不知道是哪个改动起了作用。9.5 生产环境注意安全与合规如果你要把训练好的模型放到生产环境需要关注几件事数据来源是否合规是否获得授权模型推理接口要做输入校验避免恶意构造数据造成服务异常不要直接在生产服务器上运行未经验证的训练脚本模型更新前先在小流量灰度验证并保留可回滚的旧版本。很多入门同学会觉得这些是“软件工程的事”但等模型真正上线时这些往往决定项目能否稳定运行。9.6 下一步学什么跑通 MNIST 后你可以按下面的路径继续深入换一个更难的数据集比如 CIFAR-10体会数据规模和图像复杂度对模型的影响在现有 CNN 上加入 Dropout、BatchNorm、数据增强观察过拟合如何缓解学习 Transformer 的基本原理理解注意力机制如何替代循环结构尝试自己用神经网络解决一个真实问题比如房价预测、文本分类、时间序列预测。如果今天只能做一件事我建议你打开编辑器亲手把第 5 章的完整代码逐行敲一遍然后给每一行加上中文注释。这一遍的价值远高于连续看十篇教程。代码只有自己跑过、改错、修好才算真正学会。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →