PyTorch深度学习入门:多层感知机实现手写数字识别实战
这个系列一路写下来前十二篇把 Python 基础、数据分析、特征工程和经典机器学习算法都过了一遍如果顺着目录读到这里说明你已经不是刚拿起 Python 的新手了。第十三篇是个关键转折点我们正式踏入深度学习这道门槛用 PyTorch 搭建并训练第一个神经网络。这篇内容会从神经网络最底层的计算逻辑讲起再落到一套完整可运行的图像分类代码最后是训练过程中我踩过的一堆坑。无论你是刚学完逻辑回归、决策树还是已经在 sklearn 里跑过几个模型都能在这里找到从机器学习平滑切换到深度学习的着力点。很多人学深度学习一开始就栽在“框架操作太重”上模型、损失函数、优化器、训练循环一环套一环不知道哪一步出了问题。我的建议是先建立一张完整的“数据流向图”从数据输入到模型输出从损失计算到梯度更新每一步在代码里对应哪个对象、哪个方法全部理清楚了再动手写。这篇文章会用最朴素的多层感知机做例子把这条链路完整走一遍。1. 深度学习与经典机器学习的本质分水岭1.1 为什么之前那一套方法论还不够用前几篇里我们用随机森林、支持向量机、XGBoost 解决了不少问题流程基本都是固定的先做特征工程再标准化/归一化然后训练模型最后调参。这套流程在结构化表格数据上非常稳因为它依赖的是人类对业务的理解来手工构造特征。比如做用户流失预测你得先琢磨“最近一次登录时间是几天前”“累计消费金额处于什么档位”这类特征再喂给模型。但问题来了图像、语音、文本这类数据没法靠手工设计特征。一张 224×224 的图片原始像素就有五万多个你不可能凭空设计出“这只猫的耳朵边缘梯度特征”。面对这类非结构化数据经典机器学习方法就变得非常吃力。深度学习的核心特点就是“端到端学习”原始数据直接进去特征的提取和组合由模型自己完成不再需要人来设计特征模型在训练过程中自动从大量数据里学到了什么样的特征最有效。这种能力的本质差异在于模型结构的“深度”。一个多层的神经网络可以逐级抽象最底层识别边缘和颜色块中间层识别纹理和局部形状高层才能识别出“眼睛”“耳朵”这类语义概念。这种层级化的特征表达和人类视觉皮层的工作方式有某种程度上的呼应也是深度学习在感知类任务上碾压传统方法的根本原因。1.2 深度学习的适用边界与选型判断虽然深度学习很强但不能因此把前面学过的经典算法全部丢掉。先说清楚什么时候应该继续用经典机器学习。训练数据量在几千条以下深度学习网络参数多容易过拟合泛化能力反而不如加了正则的树模型或线性模型。纯结构化表格数据特征经过合理工程后梯度提升树通常仍然是最优选择训练快、可解释性也有保障。有严格低延迟或弱硬件约束的环境深度模型要跑 GPU部署成本高。需要理解模型的决策依据比如信贷审批树模型的规则路径容易解释神经网络的解释就要复杂得多。反过来遇到图像识别、语音转写、自然语言理解、大规模推荐排序这类场景深度学习的优势就非常明显了。另外还有一个折中场景如果数据量中等比如几万到几十万条传统模型已经到瓶颈可以考虑用深度学习模型替代特征工程甚至直接尝试 Transformer 类的结构化数据模型。我个人的项目经验是在动手之前先把这个判断做完甚至可以先用逻辑回归或随机森林跑一版基线再去和深度模型对比。很多团队上来就直接上深度学习最后又调不动回头发现其实是数据量不支持白白浪费工程时间。2. 神经网络背后的直觉与数学2.1 从逻辑回归到神经元的映射关系逻辑回归的公式是 y σ(w·x b)其中 σ 是 Sigmoid 函数w 是权重b 是偏置。这其实就是单个神经元的全部计算。所谓的神经网络就是把这个“单个神经元”复制很多份然后分层堆叠每一层有若干个神经元层与层之间全连接前一层的输出作为后一层的输入。一个神经元做的事情只有两个步骤先对输入做加权求和并加上偏置再通过一个非线性激活函数输出结果。线性部分 z w·x b非线性部分 a activation(z)。如果没有激活函数多层网络叠加在一起仍然是线性变换根本没有表达复杂函数的能力。非线性激活是深度网络能逼近任意函数的关键。我第一次接触这个的时候觉得特别像小时候玩的乐高“神经元”是最小积木块几层积木堆叠起来就能搭出城堡。所谓“深度”其实就是积木的层数更多一些参数通过网络自动学习不再是我们手工拧螺丝。2.2 激活函数选择的门道激活函数是神经网络里最容易忽视却极其重要的细节。早期常用的 Sigmoid 函数 σ(x)1/(1e^(-x))值域在 0 到 1 之间优点是输出可以当作概率但有一个致命问题当 x 绝对值比较大时导数趋近于 0反向传播更新权重时梯度逐层相乘会变得非常小导致深层网络的参数几乎学不动这就是“梯度消失”现象。后来 ReLU 函数成为主流公式是 ReLU(x)max(0,x)计算极度简单而且当 x 大于 0 时导数为 1梯度能有效传下去大幅缓解了梯度消失。但 ReLU 也有自己的毛病当输入一直小于等于 0 时神经元输出恒为 0梯度彻底消失这个神经元以后就不会再更新参数了称为“神经元死亡”。针对死亡问题又有 LeakyReLU、ELU 等替代品。LeakyReLU 在负数区域给了一个很小的斜率比如 0.01x保证梯度不至于完全中断。我的选择经验是隐藏层默认优先用 ReLU遇到训练不稳定或者很多神经元死亡时再切换到 LeakyReLU 或 ELU输出层则根据任务决定二分类用 Sigmoid多分类用 Softmax回归任务直接用线性输出不加激活函数。2.3 前向传播与反向传播的完整闭环训练神经网络是一个“前向算结果、反向改参数”的循环迭代过程。前向传播一张图像从输入层进入经过每个隐藏层的加权求和与激活函数计算最后在输出层得到预测结果。以手写数字识别为例输入是 784 维的像素向量经过两个隐藏层输出层有 10 个节点每个节点对应一个数字 0 到 9 的得分用 Softmax 转换为概率。反向传播预测结果和真实标签之间有一个差异用损失函数衡量比如分类任务常用交叉熵。有了损失值之后根据链式求导法则从输出层开始逐层计算损失对每个参数的偏导梯度信息一路传回输入层。然后优化器常用 SGD 或 Adam拿着这些梯度按一定学习率去更新每个权重。整个过程可以概括成一个循环取一个批次的数据输入模型。前向传播得到预测结果。计算损失函数值。反向传播计算每个参数的梯度。优化器更新参数。清空本轮梯度进入下一轮。关于梯度清空新手最容易漏掉这一步如果不在每一步反向传播之前把累积梯度清零zero_grad那么上一个 batch 的梯度会叠加到当前 batch 上导致参数更新方向完全错乱训练出来的模型效果会非常离谱。3. PyTorch环境搭建与核心概念3.1 为什么我在系列中选 PyTorch 而不是另一个主流框架深度学习领域主流的框架大体有 PyTorch 和 TensorFlow 两派。我做技术选型时主要看三件事调试友好度、生态活跃度、落地方便度。PyTorch 最吸引我的一点是“动态计算图”机制。计算图是运行时才构建的你可以随心所欲地在 Python 的流程控制语句if、for里搭建网络结构甚至可以一边前向传播一边打印中间变量的值调试体验和写普通 Python 代码几乎没区别。TensorFlow 早期是静态图模式要先完整定义好整个图再执行这相当于把调试写成了“先完整规划再运行”一旦哪里写错要在编译期排查对初学者来说很劝退。另外 PyTorch 的生态如今已经非常丰富绝大多数开源模型仓库、论文复现代码都是 PyTorch 实现遇到问题直接去对应仓库的 issues 区找答案基本都能解。我的结论非常明确个人学习和大多数科研项目的首选就是 PyTorch这不是说另一个框架不好而是在“快速验证想法 方便调试”这两个核心需求上PyTorch 的体验更顺滑。3.2 环境搭建与第一个张量安装 PyTorch 只推荐一个方式去官网选好操作系统和 CUDA 版本后复制安装命令到命令行运行。不要使用 pip install torch 这种裸装命令它会默认安装 CPU 版本当时觉得能跑后来想调 GPU 又要重新装卸浪费时间。一个标准的 CPU 环境安装大概是这样的conda create -n dl python3.10 conda activate dl pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu这里单独说一下 CUDA 版本的判断先用 nvidia-smi 查看本机驱动支持的最高 CUDA 版本然后选择小于等于这个版本的 CUDA 对应的 torch 安装包。比如驱动支持 CUDA 12.x就可以安装 cu121 或 cu124 版本 torch。装上之后用一行代码验证 GPU 是否可用import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.device_count())如果能打印出 True说明 GPU 已经被 PyTorch 识别到了。如果没有独立显卡完全不用慌CPU 版做本篇的实战案例完全够用只是训练速度慢几倍而已。3.3 张量、自动求导与模型封装PyTorch 里的核心数据结构是张量你可以把它理解成支持 GPU 加速且能自动记录梯度运算的多维数组。把一个 Python 列表转成张量非常简单import torch x torch.tensor([[1.0, 2.0], [3.0, 4.0]])真正让它区别于普通数组的是自动求导机制。如果一个张量的 requires_grad 设为 TruePyTorch 就会像记账本一样记录对这个张量的所有运算之后调用 .backward()它就会自动计算出梯度并存在 .grad 里。这一套自动求导能力是反向传播算法的工程实现我们写神经网络的时候根本不用手动推导链式法则框架全包了。定义一个网络模型时标准做法是继承 torch.nn.Module。一个最简单的两层网络长这样:import torch.nn as nn class TwoLayerNet(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim): super().__init__() self.linear1 nn.Linear(in_dim, hidden_dim) self.relu nn.ReLU() self.linear2 nn.Linear(hidden_dim, out_dim) def forward(self, x): x self.linear1(x) x self.relu(x) x self.linear2(x) return xinit方法里只定义网络结构和各层参数真正的数据流转逻辑全部写在 forward 方法里。PyTorch 会自动基于 forward 构建计算图反向传播时通过 autograd 自动完成梯度计算。向后调用 loss.backward() 之后所有参数会拿到梯度这时候再用优化器去更新。4. 手写数字识别实战全流程4.1 数据准备加载与预处理所有深度学习项目的起点都是数据。我们用经典的手写数字数据集它包含 0 到 9 的灰度图像每张图片尺寸是 28×28 像素训练集 6 万张测试集 1 万张。这个数据集特别适合做新手实战图像小、类别均衡、任务清晰训练一轮 CPU 只花几十秒。在 PyTorch 里加载数据的标准姿势是使用 DataLoader。它本质上是一个“数据分发器”自动洗牌、自动按批次打包、自动在训练时并行取数据。from torchvision import datasets, transforms from torch.utils.data import DataLoader transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST( root./data, trainTrue, downloadTrue, transformtransform ) valid_dataset datasets.MNIST( root./data, trainFalse, downloadTrue, transformtransform ) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue) valid_loader DataLoader(valid_dataset, batch_size1000, shuffleFalse)数据预处理里有两个细节值得展开讲。ToTensor 会把 H×W 的 0 到 255 像素矩阵转成 0 到 1 之间的张量并且维度从 H×W 自动调整成 C×H×W 的形式深度网络输入通常要求通道维度在第一位。Normalize 用数据集的均值和标准差做标准化(x - 0.1307) / 0.3081让数据分布符合均值为 0、方差为 1 的标准分布梯度更新会更稳定。为什么标准化这么重要如果输入像素值都在 0 到 1 之间不同层的输入范围差异不大梯度更新的方向才能保持稳定。如果不去做标准化像素值直接作为输入网络参数初值稍不合适激活值就可能饱和甚至爆炸模型很难收敛。4.2 网络结构设计从 784 到 10对于手写数字识别最基础可靠的结构是多层感知机。输入层维度是 784因为 28×28784隐藏层我想用两个第一层 128 个神经元第二层 64 个神经元输出层 10 个神经元。class MLP(nn.Module): def __init__(self): super().__init__() self.fc1 nn.Linear(784, 128) self.fc2 nn.Linear(128, 64) self.fc3 nn.Linear(64, 10) self.relu nn.ReLU() def forward(self, x): x x.view(x.size(0), -1) x self.relu(self.fc1(x)) x self.relu(self.fc2(x)) x self.fc3(x) return x注意 forward 里第一行做了形状变换 x.view(x.size(0), -1)把每个样本从原来的 1×28×28 展平成 784 维向量。如果不展平全连接层根本没法接收三维输入。很多新手在写全连接网络时忘记这一步报错信息“mat1 and mat2 shapes cannot be multiplied”会困扰很久。隐藏层大小选 128 和 64是基于一个经验判断任务本身简单完全没有必要追求超大网络。参数越多训练越慢还更容易过拟合。如果后面的项目变成 CIFAR-10 彩色图片分类同样的全连接结构就不够用了那时要升级成卷积神经网络。4.3 训练循环代码的全拆解准备好网络、损失函数、优化器和数据之后训练循环几乎是固定模板import torch.optim as optim model MLP() criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) epochs 5 for epoch in range(epochs): model.train() running_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() running_loss loss.item() _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() epoch_loss running_loss / len(train_loader) epoch_acc correct / total print(fEpoch {epoch1}/{epochs}, Loss: {epoch_loss:.4f}, Acc: {epoch_acc:.4f})这里面每一步的作用都值得说透model.train() 把模型切到训练模式。它不是可选项虽然当前这个网络没有 Dropout 或 BatchNorm行 为基本没差别但放到更复杂的模型里如果忘记这行训练和预测的模式逻辑就会混乱。optimizer.zero_grad() 清空上一轮累积的梯度这一点前面已经强调过了。忘记这句的危害特别隐蔽不会报错但模型的 loss 曲线会像心电图一样乱七八糟。outputs model(images) 执行的是 forward 方法。criterion(outputs, labels) 计算交叉熵损失CrossEntropyLoss 内部已经做了 Softmax不需要在网络输出层额外加。loss.backward() 通过 autograd 反向计算各参数的梯度。optimizer.step() 根据当前梯度更新每个参数。_、predicted torch.max(outputs, 1) 返回每行最大值对应的下标就是模型预测的数字类别。如果你用的是普通 SGD学习率通常要设置到 0.01 或更低Adam 自适应调整学习率用默认 0.001 往往就能跑出不错的结果。这个项目里我用 Adam5 个 epoch 之后准确率已经可以稳定到 97% 以上对新手来说成就感很强。4.4 测试评估阶段与模型保存训练完成不代表结束必须在完全没有参与训练的数据上评估模型的真实表现model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in valid_loader: outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() print(fTest Accuracy: {correct / total:.4f})这里有两个关键点model.eval() 是切换到评估模式with torch.no_grad() 是告诉 PyTorch 在计算过程中不要追踪梯度。评估阶段我们只需要前向计算的预测结果不需要反向传播不关掉梯度追踪的话不仅白占内存某些包含 BatchNorm 的模型在推理模式下还会得到错误结果。模型保存我推荐只存权重的方式而不是 torch.save(model, model.pth) 整个模型对象一起存torch.save(model.state_dict(), mnist_mlp.pth)state_dict 导出的是一个 Python 有序字典键是参数名值是权重张量。这种方式的好处是迁移性好再次使用时只需要重新定义模型结构然后 load_state_dict 加载参数即可。整模型保存看着方便但换了 Python 版本或 PyTorch 版本后经常因为序列化信息不兼容而加载失败。权重文件本身也不大这个 MLP 只有 784×128 128×64 64×10再加上偏置项总共十几万个参数大约几百 KB 而已。5. 训练过程中的坑与排查记录5.1 损失原地踏步不下降这类问题训练的时候经常遇到排在第一位的根源是学习率设置不合适。学习率太大参数更新的步子太大会在最优值附近乱跳损失上下震荡甚至发散学习率太小每步都在磨蹭训练到天荒地老也没法收敛。遇到损失曲线波动剧烈优先调低学习率Adam 从 0.001 降到 0.0003 通常立竿见影如果损失下降得过于缓慢尝试调高一些。排第二的能力问题是梯度消失或爆炸。虽然 ReLU 已经极大缓解了梯度消失但隐藏层数量过多、网络层数太深仍然可能出现。排查的常规操作是打印每一层权重的梯度范数看它是否在好的范围。比如中间某一层梯度接近 1e-10 这种极小量基本就是梯度消失了出现 nan 就是爆炸了。排第三的是数据预处理出了问题例如标准化参数设置错误或者图片像素值没有正确归一化到 0 到 1。文本任务上如果忘了做掩码padding 部分参与计算也可能让损失不收敛。总体来说排查顺序是数据、学习率、网络结构一层一层来不要一上来就重写整个网络。5.2 训练集准确率高但验证集很低这个现象就是典型的过拟合。网络参数太多把训练数据“背”下来了但没有学到可迁移的通用模式。最简单有效的对策有几种一是降低网络容量把隐藏层从 128 减到 64效果立竿见影二是加 Dropout随机让一部分神经元在训练时失活相当于是多个子网络的集成效果三是做数据增强图像稍微旋转、平移、缩放让模型见到更多样式的数据。还有一个非常容易被忽视的坑误把验证集当训练集用了。有人为了方便把加载到的整个数据集直接切分切分之前没有 shuffle导致训练集和验证集分布不一致或者干脆发生了数据泄露。动手写代码前最好先确认数据切分的随机种子设置对每个样本只归属一个集合不重复、不遗漏。5.3 随机性复现不了实验对比老是出问题深度学习的训练过程天然带随机性初始化参数是随机的、数据加载时会打乱顺序、GPU 运算本身也有并发不确定性。这就导致两次完全相同的训练跑出来的结果有细微差异一般这个误差在可接受范围内但如果你是做严谨对比实验就需要设置随机种子import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)另外 DataLoader 里的 shuffle 依赖 Python 的随机数生成器所以固定随机种子要放在创建 DataLoader 之前。对于严格的实验复现还要关闭 CUDA 的 benchmark 模式设置 torch.backends.cudnn.deterministic True。不过这里要实话实说即使做完这些GPU 环境下仍然可能存在微小差异CPU 环境倒是可以基本完全复现。5.4 训练速度慢到无法忍受模型的参数量和计算量都受硬件限制。以本篇文章的 MLP 为例在 CPU 上跑一个 epoch 大约需要 20 秒左右5 个 epoch 就是 100 秒体验还行。但如果换成稍大一点的卷积网络CPU 就要按小时计算了。遇到这种瓶颈检查顺序是先确认 PyTorch 是不是 CPU 版的再确认数据加载是不是成了瓶颈比如把数据集放在机械硬盘上、DataLoader 的 num_workers 保持默认值 0都会显著拖慢整体速度最后看是否用了混合精度训练。PyTorch 提供自动混合精度float16 和 float32 混合计算在支持 GPU 上能提速将近一倍而且显存占用直接减半。我用 torch.cuda.amp.autocast 包住前向和反向传播过程配合 GradScaler 做梯度缩放是实际工程里最常用的提速手段。6. 从第十三篇出发的下一步路线写到这里你已经把深度学习的第一块积木搭起来了理解神经元、激活函数、反向传播这些底层概念用 PyTorch 写了一套可以端到端训练的完整流程并且知道遇到不收敛、过拟合时该怎么排查。这些能力是通往更复杂模型的地基。下一步我建议按照“把模型换强、把数据换硬、把任务换真”的节奏推进。把模型换强是指从全连接网络升级到卷积神经网络用卷积核提取图像空间特征你会发现同样数据量下准确率直接上一个台阶把数据换硬是指尝试 CIFAR-10 这样的彩色小图数据集包含 10 类真实物体图片背景复杂、类内差异大训练过程远不像 MNIST 那么平和把任务换真是指找一个自己感兴趣的小项目比如表情分类、声音情感识别、文本情感分析把完整的训练流程搬到真实数据上这个过程中你会自然遇到数据处理和模型调优的各种实际问题收获会是上一阶段的好几倍。我个人在实际项目里最深的一点体会是深度学习入门期最忌讳“只看不写”。看十段别人写的网络代码不如自己硬着头皮从空文件开始写一遍。这个系列到这里你已经有了一个能跑的模型、一套完整的训练代码在这之上后续我可以继续拆解卷积神经网络、数据增强策略、迁移学习、在自定义数据集上做微调这些都是清晰的、可执行的下一步方向。先把这篇的代码在本地跑通跑通之后再回头看一遍反向传播的原理你会发现自己已经进入了以前觉得“只可意会”的那个领域。