纯NumPy从零实现手写数字识别:手写反向传播全复盘

📅 发布时间:2026/10/14 23:27:44
纯NumPy从零实现手写数字识别:手写反向传播全复盘
说实话看到这次练习的标题时我第一反应是“就这”老师发下来的说明只有三行给定一份手写数字数据集不许调用任何高级框架的自动求导从零把分类模型写出来文档里必须解释每一步选择的理由最后交一份能复现的代码。任务名就叫“课上练习1”没有更多背景一开始我也以为这是个热热身的小作业真正做起来才发现它几乎把入门阶段最容易跳过的基础知识全部串了一遍。这篇文章记录的是我自己做这次练习的完整过程。包括我怎么选结构、怎么推梯度、怎么写前向反向、怎么排查训练不收敛以及最后复盘时我意识到的问题。如果你正在补机器学习基础或者准备面试时被问到“手撕反向传播”这篇内容应该能帮你省不少时间。我的结论先放在前面这个练习的价值不在准确率而在于把你从“会用框架”逼到“理解模型”的位置上。1. 项目整体定位一次看似简单的“从零实现”1.1 这个练习重点关注的三个隐藏要求表面上看任务是做一个图像分类。数据集是28×28的灰度数字图10个类别标准到不能再标准。但老师不让用自动求导这就把整个任务的难度抬高了一个层级。你把“不允许用自动求导”这句话翻译一下它实际上是在要求三件事第一必须手工推导梯度。这要求你理解反向传播不是某个框架帮你算好的黑盒而是一连串基于链式法则的矩阵乘法。第二必须合理选择模型结构并给出依据。你不能说“我随便搭了一个两层网络”你得解释为什么隐藏层要用带激活函数的全连接层、为什么分类用Softmax和交叉熵。第三代码必须可复现。数据集划分、随机种子、训练轮数、学习率每一项都要可追溯不然课堂展示的时候别人一跑结果不一样那就很尴尬。我一开始低估的正是第一点。用手写数字识别这样的任务去练反向传播看起来杀鸡用牛刀实际做的时候才发现这条“杀鸡路”上有太多坑维度对不上、Softmax溢出、学习率过大导致损失变成NaN、初始化用零导致梯度全都相同。这些坑平时用框架时根本遇不到因为框架帮你把坑填平了。但恰恰是这些坑才让这次练习有意义。1.2 为什么“从零实现”比直接调框架更值得做我周围的同学分成了两派。有一派直接用了现代深度学习框架几十行代码训练一个模型很快就把准确率跑到97%以上然后用框架自带的自动求导交了作业代码很干净结果也很好。另一派包括我在内坚持全程只用NumPy前向、反向、参数更新全部自己手写花了整整两个晚上调试。现在回头看我反而更认同后一派的收获。这不是说调框架没有价值。实际工程里没人会手写反向传播框架的效率、稳定性和生态都是巨大的优势。但作为课堂练习目的不是产出最优模型而是检验你是否真的理解模型。举个例子你知道调用某个组件可以自动计算梯度但你不一定清楚梯度在链式法则下是怎么从输出层流回输入层的你知道设置初始化方法可以加快收敛但你不一定知道如果把它设成全零网络会怎样。这些知识在调框架时可能永远只是“听说过”但在手写代码时你必须每条都亲自处理。用一个生活类比会开车和会修车是两回事。日常通勤不需要会修车但如果你打算长期靠车吃饭至少在发动机报警时你得知道是哪一块出了问题。手写一遍模型就像把发动机盖掀开看了一眼。以后再用框架遇到损失不下降、梯度爆炸、维度不匹配你能更快定位到问题而不是把报错信息整个复制到搜索引擎里。1.3 这个练习适合谁参考如果你已经能用框架搭出模型但每次调参都靠“试”那这篇内容适合你如果你正在准备算法岗位面试大概率会被问到底层原理那这篇内容也适合你如果你只是刚开始学机器学习可能有点吃力但跟着代码走一遍收获会很大。我说句实在话这个练习最适合的人是那种“模型能跑通、但说不出为什么”的中间状态学习者。正好卡在“会用”和“理解”之间。做一遍从零实现能把这段距离缩短一大截。2. 核心选型与原理拆解为什么每个选择长这样2.1 模型结构为什么选两层全连接而不是卷积网络我在模型选型上犹豫过很久。手写数字识别是一个公开数据集卷积网络很容易达到99%以上的准确率全连接网络能达到97%左右。老师对准确率没有硬性要求所以我优先考虑的不是性能而是“能否用手写代码清晰说明”。当时我列了一个对比表方案准确率潜力手写反向传播难度代码量选型判断Softmax回归单层约92%-93%低少只能做线性边界作为基线还行两层全连接网络约97%中等适中结构经典能体现非线性适合练习卷积网络约99%高多卷积反向传播对初学者太复杂练习成本高最后我选了“输入784-隐藏128-输出10”的两层全连接网络。784是28×28展平后的向量长度128是隐藏层神经元数10是类别数。为什么隐藏层选128而不是更大或更小在这个任务里128已经足够表达非线性边界更大的隐藏层会明显拖慢训练而且手动推导矩阵乘法出错的概率更高更小的隐藏层比如32也能跑但准确率会降到95%左右我还是希望结果好看一点。这里有一个关键点必须强调隐藏层必须带激活函数。如果两层全连接之间不加非线性激活那数学上两层就等价于一层因为线性变换的复合仍然是线性变换。加ReLU之后模型才真正具备拟合非线性决策边界的能力。这个点面试也常考代码里也容易漏。2.2 激活函数、损失函数与参数初始化的取舍我最初写代码时用的是Sigmoid激活函数因为教材里讲得多公式也熟。但实际训练之后发现收敛偏慢后来换成了ReLU效果立刻好了很多。原因不复杂Sigmoid在输入绝对值较大时梯度趋近于0多层传播时很容易出现梯度消失ReLU在正区间梯度恒为1反向传播时信号衰减慢。但ReLU也不是没有代价。它的导数在输入小于等于0时为0写反向传播时要用z 0生成一个0/1掩码矩阵。这个逻辑很简单但很容易被忽略。如果你忘记乘这个掩码梯度就会多出一截训练结果会很奇怪。损失函数我用的是交叉熵而不是均方误差。原因主要有两个一个是分类问题用交叉熵更符合概率建模的直觉输出可以解释为每个类别的概率另一个是Softmax和交叉熵组合在一起时反向传播的梯度表达式会非常简洁直接等于预测概率减去真实标签的独热编码也就是p - y。对写代码来说这种简洁性意味着更少出错。参数初始化也必须单独说。如果权重设成全零所有神经元的输出相同反向传播时每个神经元接收到的梯度也相同整个网络无论训练多少轮都退化为一个神经元无法学习不同特征。所以要用随机初始化。一般做法是使用服从正态分布的小随机数再乘一个缩放因子。这里我用了np.sqrt(2 / 输入维度)这个缩放比例它对应常见的He初始化适合配合ReLU使用。2.3 Softmax的数值稳定一行代码修复一个隐蔽BugSoftmax的公式本身很简单对每个样本计算各个类别的exp(logit)然后归一化成概率。但直接按公式写有一个非常隐蔽的问题如果某个logit比较大比如超过20exp就会变得很大有可能溢出为无穷大导致损失变成NaN或者inf。解决方法是每个样本的logits先减去该样本的最大值再做指数运算。因为softmax是平移不变的整体减去一个常数不会改变结果但能让指数运算的输入控制在一个安全范围。我当时漏掉这一步第一次训练时还在怀疑学习率是不是太大了后来才发现问题出在数值稳定性上。就一行代码却能把一个“看似模型问题”的Bug彻底解决。3. 实操全流程用纯NumPy从数据到训练一步步写出来3.1 环境与工程结构这次练习我全程只用Python和NumPy没有引入任何深度学习框架。版本上我用的是Python 3.9和NumPy 1.24都是很常见的版本不需要额外配置GPU普通笔记本CPU就能跑完整个训练流程10轮训练大约需要一两分钟。代码结构上我建议按这个方式拆文件虽然我一开始没这么做后来调试时发现单文件越写越长最后不得不返工拆开。比较合理的结构是data.py负责数据加载、归一化、独热编码、训练验证集划分。model.py负责参数初始化、前向传播、反向传播。train.py负责训练循环、损失计算、准确率评估、参数更新。utils.py放一些公共函数比如Softmax、ReLU、数值梯度检查。拆开之后的好处很明显哪一步出了问题直接定位到对应文件不用在一坨代码里上下翻。课堂展示的时候也更容易解释每个模块的职责。3.2 数据加载、归一化与批量切分MNIST数据集的原始像素值在0到255之间如果直接喂给模型数值范围太大会让权重更新变得不稳定。常见的做法是把像素值除以255缩放到0到1之间。这是一个很小的步骤但影响很大。我写的加载函数大概是这样的import numpy as np def load_data(path./mnist.npz): data np.load(path) x_train data[x_train].reshape(-1, 28 * 28).astype(np.float32) / 255.0 y_train data[y_train] x_test data[x_test].reshape(-1, 28 * 28).astype(np.float32) / 255.0 y_test data[y_test] return (x_train, y_train), (x_test, y_test) def to_onehot(y, num_classes10): return np.eye(num_classes)[y]标签不能直接作为数字喂给交叉熵损失因为类别0到9是离散的不是连续的回归值。我们需要把每个标签转换成长度为10的独热编码比如数字5变成[0,0,0,0,0,1,0,0,0,0]。用np.eye(10)[y]一行就能完成这也是最简洁的做法。训练时我没有用全量数据一次性更新梯度而是采用Mini-batch方式每批64个样本。为什么不用全量全量梯度下降每一步的计算量太大而且容易陷入局部平坦区域为什么不用单样本单样本更新太频繁梯度噪声大收敛过程抖动明显。64是一个比较常见的中间值计算速度和稳定性相对均衡。3.3 前向传播与反向传播的代码实现前向传播就是把输入依次经过线性变换、激活、二次线性变换、Softmax。这个流程很直观代码也不长。def relu(z): return np.maximum(z, 0) def softmax(z): z z - np.max(z, axis1, keepdimsTrue) exp_z np.exp(z) return exp_z / np.sum(exp_z, axis1, keepdimsTrue) def forward(x, params): W1, b1, W2, b2 params z1 x W1 b1 a1 relu(z1) z2 a1 W2 b2 probs softmax(z2) return z1, a1, z2, probs反向传播是整个练习的核心。我推了一下梯度分三步第一步输出层到隐藏层的梯度。因为使用的是Softmax加交叉熵梯度表达式直接是probs - y_onehot这个结果非常漂亮省去了很多中间推导。第二步传递到隐藏层之后要乘上ReLU的导数掩码也就是z1 0的位置梯度保留其余置零。这是最容易写错的一步。第三步对每个参数求梯度然后用梯度均值更新。注意这里要先除以批次大小因为损失是所有样本的平均交叉熵。def backward(x, y_onehot, params, cache): W1, b1, W2, b2 params z1, a1, z2, probs cache m x.shape[0] dz2 probs - y_onehot dW2 a1.T dz2 / m db2 np.sum(dz2, axis0) / m da1 dz2 W2.T dz1 da1 * (z1 0) dW1 x.T dz1 / m db1 np.sum(dz1, axis0) / m return dW1, db1, dW2, db2这里有一个容易绕晕的点我写的dz2 probs - y_onehot严格来说是每个样本自己的梯度没有除以批次大小。我在计算dW2和db2时才除以m。如果先在dz2上除以m那后面就不能再除以m了否则梯度会变得非常小。两种写法都对但一定要保持一致。我建议在dW、db上统一除以样本数这样代码语义更清晰。我复盘时发现几乎所有同学第一次手写反向传播都栽在维度上。常见的错误是矩阵乘法方向写反。判断维度是否正确的快速方法如下任意梯度矩阵的形状必须和对应参数矩阵的形状完全一致。比如W1的形状是(784, 128)那么dW1也必须是(784, 128)如果不一致说明中间某一步矩阵乘法的顺序错了。3.4 训练循环、参数更新与结果验证参数初始化我放在训练前用固定随机种子保证结果可复现。然后用SGD加动量更新参数动量为0.9学习率一开始设置为0.1。def init_params(hidden_size128): rng np.random.default_rng(42) W1 rng.standard_normal((784, hidden_size)) * np.sqrt(2.0 / 784) b1 np.zeros(hidden_size) W2 rng.standard_normal((hidden_size, 10)) * np.sqrt(2.0 / hidden_size) b2 np.zeros(10) return W1, b1, W2, b2训练循环里的核心思路是每个轮次开始前打乱训练数据顺序然后按64个一批遍历每批计算前向、反向、更新参数。每个轮次结束后在验证集上计算一次准确率。我写了动量更新后训练过程的损失下降比纯SGD更稳定。一个典型输出结果epoch 1: train_loss0.531, val_acc0.913 epoch 5: train_loss0.171, val_acc0.961 epoch 10: train_loss0.091, val_acc0.972最终测试集准确率大约在97.1%到97.4%之间。这个数字比我预期的高不少因为手写数字识别本身是一个相对简单的任务全连接网络已经能学出不错的特征。到这里这个练习的代码部分就完成了但我个人认为后面排查问题的过程才是这次练习最值钱的部分。4. 踩坑记录训练不收敛的那些真实原因和排查方法4.1 常见问题速查表整个练习过程中我周围同学和我自己遇到的坑五花八门。我把高频问题整理成了表格方便后续做类似练习的人快速对照。现象可能的原因解决方案损失完全不下降学习率太小尝试调大学习率或检查梯度是否为全零损失变成NaN学习率过大调小学习率检查是否做了Softmax数值稳定训练准确率高、测试准确率低过拟合加入L2正则、Dropout、或扩大训练数据代码报错矩阵维度不匹配反向传播中乘法顺序写反用“梯度形状必须和参数形状一致”来检查更新后模型没有变化权重初始化全零改为随机初始化训练到一半损失突然升高学习率过大学过头降低学习率或加入学习率衰减这里每一个现象背后都有真实的调试故事不是套话。我最初使用Sigmoid时模型在验证集上一直卡在95%左右上不去换了ReLU之后才突破。如果你遇到类似准确率上不去的情况先别急着堆模型复杂度检查激活函数和初始化往往更有效。4.2 我实际踩过的三个坑第一个坑是忘记做Softmax数值稳定。我第一次跑训练损失在前几步还算正常到第三轮直接变成了inf再过一步就变成了NaN。当时我以为是学习率太大调到0.001依然崩溃。排查了很久才想起来exp(大数)会溢出。修复方法就是每个样本的logits先减去最大值。从那以后我再也没遇到过训练初期就NaN的情况。第二个坑是动量参数的初始化。我一开始把速度变量初始化为零这没问题动量更新需要从零开始累积。但问题出在更新顺序上。正确顺序是把过时的速度变量用于更新再更新速度变量如果把顺序写反用到了已经更新过的速度动量的效果就变得混乱损失曲线会出现奇怪的周期性波动。这个Bug不报错但结果很诡异排查起来特别费时间。第三个坑和矩阵乘法方向有关。我在计算dW2的时候一直记不清应该是a1.T dz2还是dz2.T a1。用维度检查法就非常直观a1形状是(m, 128)dz2形状是(m, 10)我需要得到(128, 10)那么只能是a1.T dz2没有第二种可能。这个习惯帮我躲过了很多次低水平错误。4.3 梯度检查判断反向传播到底写没写对我知道有很多人跟我一样写完反向传播后心里没底怕“代码跑通但梯度是错的”。最靠谱的验证方法是数值梯度检查。原理很简单就是利用导数的定义给某个参数加上一个极小的扰动计算损失变化量再除以扰动大小就得到数值梯度。def compute_grad_numerically(loss_fn, params, idx, eps1e-5): old params[idx].copy() params[idx] old eps loss1 loss_fn() params[idx] old - eps loss2 loss_fn() params[idx] old return (loss1 - loss2) / (2 * eps)然后把数值梯度和手写反向传播得到的梯度做对比计算相对误差。相对误差小于1e-6说明代码基本没错小于1e-4也还能接受如果大于1e-2那基本可以确定反向传播有Bug。我建议的做法是先用一个小网络测试比如输入维度5、隐藏层10、样本数3这样数值梯度计算很快。不要在完整MNIST上跑数值梯度因为参数太多算一次会非常耗时。这个小技巧帮我抓出了除Softmax之外最后几个低级错误也是我认为这次练习中最重要的一个排查工具。5. 练习复盘做完之后我对模型理解的三个变化5.1 从“代码跑通”到“知道为什么能跑通”做完这次练习我最大的变化是之前很多靠记忆记住的结论现在变成了真正理解的东西。举个例子以前我知道“用交叉熵而不是均方误差”但不知道为什么。手写反向传播之后我亲眼看到Softmax加交叉熵的梯度是p - y这种极其简洁的形式而如果换成均方误差梯度里还会多乘一个Softmax的导数项不仅在代码上更复杂在数值上还容易造成梯度消失。理解了这一层之后以后选损失函数就不再是背答案而是真正知道权衡点在哪里。另一个例子是初始化。以前我听说“权重不能设全零”也是一知半解。手写代码时如果设成全零梯度传回去之后每一层的输入相同、输出相同、梯度相同整个网络学不出任何差异化的特征。这个现象一旦亲眼见到就不会再忘。5.2 后续可以继续尝试的三个改进方向练习交完之后我并没有停在那个能跑的版本上。后续我根据自己的兴趣做了三个方向的扩展这里也分享给你。第一个方向是加正则化和Dropout。我在隐藏层输出后加了一个Dropout层测试准确率在97%的基础上小幅提升更重要的是我直观体会到了“防止过拟合”到底是什么效果。训练集准确率和验证集准确率之间的差距缩小了这就是正则化在做的事。第二个方向是换成卷积网络。虽然本次练习不允许用自动求导但交完作业后我可以用框架快速搭一个两层卷积模型看到准确率从97%涨到99%以上。这个对比让我理解了卷积操作和全连接的本质区别参数共享与局部感受野。第三个方向是尝试自己实现Adam优化器。SGD加动量已经能取得不错结果但Adam在收敛速度和稳定性上有明显优势。手动实现一遍Adam之后你再去看那篇著名的Adam论文时会发现里面每个公式都不再抽象。我还建议你做一个实验把学习率分别设置为0.01、0.1、1.0依次训练并记录损失曲线。你会直观看到欠拟合、正常收敛、梯度爆炸三种情况这种经验是任何文字都替代不了的。5.3 给同样在完成这类练习的人一句实在话真要把这次练习复盘完我最想说的不是某一行代码而是一个心态问题。开始我总觉得“不调框架写模型”是倒退后来才意识到只有手脚被绑住一次才会用心看路。按老师要求我最后交的代码不到两百行却比我在网上看过的一大堆工程模板有用得多。如果你也遇到一个看起来太简单的课堂练习别急着搜索现成答案先自己写一遍试试。哪怕跑出来的准确率并不高那份代码也会成为你之后看任何库都绕不开的地基。