首页 / 资讯中心 / 文章详情

手写BP神经网络识别MNIST:从零实现反向传播与梯度下降

手写BP神经网络识别MNIST:从零实现反向传播与梯度下降 ★ FEATURED ARTICLE
简介BP神经网络MNIST手写数字识别Python实现资源包主要面向计算机视觉与神经网络初学者旨在通过一个完整的工程实例帮助读者理解反向传播算法从网络初始化、前向传播、误差计算到权重更新的完整实现流程。压缩包共2000个文件包含大量MNIST手写数字图片PNG、已训练好的模型权重与偏移量MAT、可直接运行的Python预测与训练脚本PY以及工程配置类文件JSON/XML/IML等整体大小约69.36MB结构清晰便于按需查阅。目前已有8756人学习下载。资源中既提供了训练好的参数文件也提供了用于自行训练的MAT格式训练数据集。用户既能直接运行预测脚本快速验证识别效果也能覆盖参数重新训练网络通过调整超参数观察结果变化从而深入掌握BP神经网络的调参与实现细节适合作为机器学习课程设计或入门练习的参考资料。1. 为什么还要手写BP网络不依赖框架的MNIST识别才是理解深度学习底层的捷径调过框架接口的人都懂用现成工具把 BP神经网络 mnist手写数字识别 跑通几行代码准确率就上去了可一旦面试官问起反向传播里每一层的梯度到底怎么算很多人就卡住了。这篇博文正是把这个黑匣子拆开的一次练习只用Python和NumPy从零实现一个两层BP神经网络让它在MNIST数据集上完成训练和识别目标是把测试集准确率做到90%以上。这个标题听起来像是入门Demo但它背后的价值在于强迫你把前向传播的矩阵形状、反向传播的链式法则、梯度下降的更新步长全部亲手写一遍。适合正在准备算法岗位面试的开发者也适合上完深度学习入门课但从未自己实现过BP的工程师。你只需要会NumPy基本矩阵运算就够了。框架能替你省掉的那些麻烦这里全部得靠自己解决这也是整个项目真正值钱的地方。下面从网络结构开始逐步讲。2. BP网络的结构共识784-128-10 与 sigmoid 的前向传播MNIST 数据集里的每张手写数字图都是 28×28 像素的灰度图。把它展平成一个向量就是 784 维输入识别结果对应数字 0 到 9共 10 类所以输出层是 10 个神经元。中间夹一层隐藏层隐藏神经元数量从几十到几百都可以128 是大多数人第一次实现时的选择速度和效果都比较均衡。这一章先把网络结构为何如此设计讲清楚再给出可以独立运行的前向传播代码块。2.1 从MNIST图片到分类标签784输入与10输出的维度推导一张 MNIST 图片在计算机里是一块 28×28 的灰度值矩阵每一个像素是一个 0~255 的整数。把矩阵按行展开得到 784 个数值这就是输入层每个神经元拿到的原始特征。你不需要手动提取边缘、角度这些高级特征BP 网络会自己从像素组合里学习到有用的模式。输出层为什么是 10因为数字类别一共有 10 个网络输出一个 10 维向量比如 [0.1, 0.05, 0.7, 0.02, ...]向量里第 3 个值最大就判定为数字 2。严格来说这是把分类问题当作一个连续的回归目标来训练最后一层并不一定需要 softmax直接比较 10 个输出值大小也能得到分类结果。隐藏层的作用是给网络引入非线性表达能力。如果只有一个输入层直连输出层那么无论怎么训练网络都只能学到一个线性分类边界MNIST 这种复杂的图像模式是分不开的。加上隐藏层和激活函数后网络才能描述非线性的决策面。隐藏层神经元数量是唯一需要手工拍板的结构超参128 是一个经验起点后面可以按训练结果上下调整。2.2 激活函数与损失函数为什么教程都从sigmoid和均方误差起步激活函数负责把线性变换结果 z 拉进一个非线性区间。BP 手写实现里最常用的是 sigmoidS(z) 1 / (1 exp(-z))它的输出范围是 0 到 1并且有一个特别方便求导的性质S(z) S(z) * (1 - S(z))。也就是说只要知道激活值本身导数就立刻得到。ReLU 在实际工程里性能更好但它的导数是个分段函数在 z0 处不可导手工推导反向传播时多了一层分支判断。对于以理解 BP 为首要目标的项目sigmoid 的导数形式和代码实现都是最顺的。读者后续完全可以在跑通后再把激活函数替换成 ReLU 做对比实验。损失函数我一般先用均方误差MSEL 0.5 * sum((output - target)^2)。前面的 0.5 是让求导后的系数变成 1纯粹为了手写推导方便。MSE 搭配 sigmoid 在输出层会产生梯度衰减但 MNIST 分类任务足够简单两层网络仍能练到不错的水平。交叉熵收敛更快不过它更适合作为跑通之后的进阶改造而不是第一版。2.3 前向传播的numpy最小实现维度对齐与广播机制有了结构先看前向传播的最简代码块。它把输入 x 从 784 维变换到 128 维隐藏层再变换到 10 维输出层import numpy as np def sigmoid(z): return 1.0 / (1.0 np.exp(-z)) def sigmoid_derivative(a): # 输入 a 是 sigmoid 的激活值导数直接用激活值表达 return a * (1.0 - a) def forward(x, w1, b1, w2, b2): # x: (batch_size, 784) # w1: (784, 128), b1: (1, 128) # w2: (128, 10), b2: (1, 10) z1 x w1 b1 a1 sigmoid(z1) z2 a1 w2 b2 a2 sigmoid(z2) return a1, a2代码里三个关键点要留意。第一x w1 的维度是 (batch_size, 784) 乘 (784, 128)结果自动是 (batch_size, 128)这要求权重矩阵的行数必须等于输入维度。第二b1 是 (1, 128)但加法时 NumPy 会把 b1 沿 batch 维度广播让每一个样本都加上同一组偏置。第三最后一层输出 a2 的维度是 (batch_size, 10)每一行表示一个样本对 10 个类别的预测响应。这个前向过程就是标题里 BP 神经网络的前半部分。它本身不包含任何学习逻辑只是把输入映射成预测。接下来需要的数据准备和初始化决定了这个映射起点是随机乱猜还是有意义的搜索方向。3. 数据准备与初始化MNIST加载的二进制格式、one-hot编码与权重初始化MNIST 不是一个图片文件夹而是四个二进制文件。有人图方便直接用现成库下载但手写加载 20 来行代码能让你彻底搞懂数据格式也能摆脱对外部下载模块的依赖。这一章解决的是数据怎么进网络的问题并顺便避开两个最常见的预处理坑。3.1 手写加载MNIST二进制文件避开大端字节序的解析坑MNIST 的图像文件结构是前 4 字节魔数接下来 4 字节图片数量再 4 字节行数再 4 字节列数之后才是像素数据。标签文件更简单前 4 字节魔数然后 4 字节标签数量后面就是 0~9 的整数。用 NumPy 可以直接跳过头部读取代码块如下import numpy as np def load_images(path): # 图像文件头部是 4 个 4 字节大端整数共 16 字节 # offset16 表示直接从像素区开始读 with open(path, rb) as f: data np.frombuffer(f.read(), dtypenp.uint8, offset16) # 每张图是 28*28784 个像素顺便归一化到 [0,1] images data.reshape(-1, 784).astype(np.float32) / 255.0 return images def load_labels(path): # 标签文件头部是 2 个 4 字节整数共 8 字节 with open(path, rb) as f: data np.frombuffer(f.read(), dtypenp.uint8, offset8) return data.astype(np.int64)这里最容易踩的坑是字节序。MNIST 的头部整数是大端big-endian存储而 x86 机器默认小端。如果试图用 np.fromfile(path, dtypeu4, count4) 去读头部必须用 前缀明确指定大端。上面代码之所以没有这个问题是因为 offset 直接跳过头部像素和标签都是单字节 uint8不存在字节序歧义。读取之后务必验证 shape训练图像应为 (60000, 784)训练标签为 (60000,)测试图像为 (10000, 784)。如果 reshape 出来的总数对不上多半是文件路径或 offset 写错了。这一步多花十秒钟能省掉后面所有神秘报错。3.2 灰度归一化与标签one-hot两个不能省略的预处理归一化把每个像素从 0~255 缩放到 0~1。表面上看只是除了个 255实际作用大得多。sigmoid 在输入很大时进入饱和区梯度几乎为零像素值 255 直接喂进去z1 会被推到很大反向传播时隐藏层误差被压得极小网络几乎学不动。把输入控制在 [0,1] 区间是在源头上保护梯度流动。标签的 one-hot 编码同样不能省。如果直接把整数标签 3 当作 y输出层是 10 维向量两者没法做减法。one-hot 把标签 3 变成向量 [0,0,0,1,0,0,0,0,0,0]才能和网络输出逐元素比较。代码块如下def one_hot(labels, num_classes10): n len(labels) y np.zeros((n, num_classes), dtypenp.float32) y[np.arange(n), labels] 1.0 return y检查时可以用 y_train.sum(axis1)每一行求和应该全是 1。如果出现异常值说明 labels 里有越界数字或者 one_hot 写错了。预处理阶段保持每做一步就验证 shape 和取值的纪律后面训练阶段的排查成本会大幅下降。3.3 权重初始化与超参设定从学习率到batch size的参数表权重初始化决定了训练起点。如果初始权重都取标准正态分布的大随机数比如 np.random.randn(784, 128) 不做缩放那么 z1 的方差会随着网络维度累计放大sigmoid 直接饱和。常见做法是乘一个 0.01 的小系数或者用 Xavier 初始化让权重在正负 sqrt(6 / (fan_in fan_out)) 之间均匀分布。偏置初始化为 0 是安全的。梯度下降会自己调整它不存在对称性破坏问题。训练超参我习惯按下表设置这也是标题这类小型手写项目最常用的一档配置超参数默认值说明隐藏层神经元数12864~256 均可越大拟合越强但越慢学习率0.1MSE版本震荡就降到0.01训练轮数 epoch30更多轮数收益递减batch size128小批量梯度下降的常用值权重初始化Xavier 均匀分布避免 sigmoid 饱和随机种子42保证实验可复现有人说学习率 0.1 对 MSE 会发散这跟是否对梯度做平均有关。下一章训练循环里会把梯度除以 batch size这样学习率就与批次大小解耦0.1 是安全起点。4. 反向传播与训练循环四个公式的最小可运行实现前向传播算出的预测和真实标签之间有一个误差反向传播要做的就是把误差对每一层权重求梯度。四个核心公式记住之后训练循环就是一次矩阵赋值。这一章是整个标题项目的灵魂。4.1 链式法则推导输出层与隐藏层的误差项先把符号固定z1 是隐藏层线性输出a1 是 sigmoid 激活后的隐藏层输出z2 是输出层线性输出a2 是最终预测。损失用 L 0.5 * sum((a2 - y)^2)。输出层的误差项记为 delta2它等于损失对 z2 的偏导。链式法则拆开是损失对 a2 求导得到 (a2 - y)a2 对 z2 求导得到 sigmoid 导数 a2 * (1 - a2)两者逐元素相乘delta2 (a2 - y) * a2 * (1 - a2)隐藏层的误差项 delta1 要继续沿网络往回传。a2 对 a1 的依赖经过权重 w2所以 delta1 等于 delta2 与 w2 的转置做矩阵乘再乘上 a1 的 sigmoid 导数delta1 (delta2 w2.T) * a1 * (1 - a1)有了误差项梯度就一目了然w2 的梯度是 a1 的转置乘以 delta2w1 的梯度是输入 x 的转置乘以 delta1。偏置的梯度是误差项在 batch 维度上求和。这四个式子对应代码里的四行赋值是 BP 的全部核心。4.2 训练循环的numpy实现前向、反向与参数更新下面给出一个可直接跑通的训练函数。它包含打乱数据、切分小批量、前向计算、反向求梯度、参数更新五个环节def train(model, X_train, Y_train, epochs30, batch_size128, lr0.1): w1, b1, w2, b2 model n X_train.shape[0] for epoch in range(epochs): # 每个 epoch 打乱一次样本顺序减少批次间相关性 perm np.random.permutation(n) X_train, Y_train X_train[perm], Y_train[perm] for i in range(0, n, batch_size): xb X_train[i:ibatch_size] yb Y_train[i:ibatch_size] # 前向传播 a1, a2 forward(xb, w1, b1, w2, b2) # 输出层与隐藏层的误差项 delta2 (a2 - yb) * a2 * (1.0 - a2) delta1 (delta2 w2.T) * a1 * (1.0 - a1) # 计算梯度并除以 batch_size让学习率与批次大小无关 grad_w2 a1.T delta2 / batch_size grad_b2 np.sum(delta2, axis0, keepdimsTrue) / batch_size grad_w1 xb.T delta1 / batch_size grad_b1 np.sum(delta1, axis0, keepdimsTrue) / batch_size # 梯度下降更新参数 w1 - lr * grad_w1 b1 - lr * grad_b1 w2 - lr * grad_w2 b2 - lr * grad_b2 return (w1, b1, w2, b2)代码里最容易写错的地方在维度方向a1.T delta2 要求 a1 是 (batch, 128)delta2 是 (batch, 10)转置后相乘得到 (128, 10)正好和 w2 同形状。如果写成 a1 delta2.T得到的是 (batch, batch)那就是方向性错误损失会不降反升。除以 batch_size 这一步很关键。如果不除学习率实际被放大了 batch_size 倍用小批次训练时会出现同样的学习率时而收敛、时而发散的现象。我一般统一在梯度上做平均让超参在不同 batch size 下可迁移。4.3 训练质量怎么判断损失曲线、测试准确率与过拟合边界训练过程要打印两个数字当前 epoch 的平均损失以及测试集准确率。损失下降说明梯度方向正确准确率反映最终任务效果。测试集评估不需要反向传播只要前向一次并取 argmaxdef evaluate(model, X_test, Y_test): w1, b1, w2, b2 model _, a2 forward(X_test, w1, b1, w2, b2) preds np.argmax(a2, axis1) labels np.argmax(Y_test, axis1) acc np.mean(preds labels) return acc训练初期准确率从 10% 附近快速爬升说明网络开始学到数字轮廓。按上面超参配置两个隐藏层通常能把测试准确率带到 93%~95% 的区间。如果明显低于 90%不要急着改网络结构先检查数据预处理和梯度方向大概率是某个环节写错了。训练集准确率和测试集准确率之差如果超过两个百分点可以考虑加一点正则化手段比如权重衰减或增大训练轮数。但 MNIST 本身相对简单两层网络过拟合风险不大更多时候是训练不充分。5. 训练过程的5个常见坑从损失不降到卡在10%准确率的排障下面这些坑多数来自手写 BP 时的血泪经验。每一条我都按现象、原因、解决的顺序写排查时可以对照自己的日志判断。5.1 损失完全不动先检查标签编码和输入范围现象是每轮打印的损失几乎不变准确率始终在 10% 上下徘徊和随机猜测差不多。此时网络有输出但梯度没有把参数推向任何有效方向。最常见的原因是标签没有正确做 one-hot。如果 y 还是长度为 10 的整数形状或者 one_hot 函数写成了 y[labels, np.arange(n)] 导致索引错位损失函数计算出的误差就是错的。另一个高发原因是输入没有归一化784 个 0~255 的像素值直接把 sigmoid 打到饱和区梯度趋近于零。解决方式是打印一批数据看一眼X_train 的最大值是否小于等于 1Y_train 的每一行是否只有一个 1。这两项检查五分钟能完成却挡住了大量后续排查工作。5.2 损失爆炸成NaN十有八九是初始化太大现象是训练几步之后损失从正常值直接跳到 NaN之后所有数值都是 NaN。有人会怀疑是学习率问题但对这个规模的项目来说头号元凶是权重初始化过大。大权重让 z1 的数值变得很大sigmoid 溢出后分母出现 inf或者梯度计算中出现 0 * inf 的组合NaN 就产生了。学习率过大会让参数一步跳出合理区间但往往也是从初始化偏大开始的。解决方法是把初始化改小。我用 np.random.uniform(-limit, limit) 且取 limit sqrt(6 / (fan_in fan_out))也就是 Xavier 初始化。偏置保持全 0学习率暂时降到 0.01 重新跑。如果还是 NaN再检查输入里有没有 NaN。5.3 准确率卡在90%上下两层网络的表达力边界现象是训练到一半时准确率进入平台期怎么跑都只在 90% 左右震荡上不去 93%。这个阶段网络已经学会区分多数数字但部分相似字形比如 4 和 9、3 和 8容易被混淆。原因是两层网络和 sigmoid 的组合确实有表达力上限同时 MSE 在输出层的梯度本来就偏小后期收敛变慢。这不算翻车而是模型容量和损失函数共同作用的结果。解决思路是按顺序尝试三件事把隐藏层从 128 加到 256观察准确率是否继续上升把学习率从第 20 个 epoch 开始降低到 0.01让参数在局部精细调整最后可以把手写版的 MSE 换成交叉熵损失。多数情况下前三步就能突破 90% 瓶颈。5.4 训练慢到怀疑人生把内层循环换成矩阵乘现象是一个 epoch 要跑几分钟30 个 epoch 下来人已经失去耐心。原因几乎都是代码里用 Python for 循环逐张图片做前向和反向。Python 解释器循环开销远大于矩阵运算本身60000 张图逐个跑时间全浪费在循环调度上。解决方法是把所有样本堆成一个大矩阵一次矩阵乘法同时处理整个 batch。上面第 4 章的代码已经是批量化形式如果你自己实现时用了 for sample in range(batch_size) 这种写法请改成矩阵运算。999 次循环变成一次 numpy 运算后速度提升肉眼可见。另外一个隐藏性能问题是数据 dtype 用了 Python float 默认的双精度。语言层面无妨但可以统一转成 np.float32内存占用减半矩阵乘速度在某些机器上也会更快。5.5 第二次运行结果不一致随机性不是玄学是初始化与数据顺序现象是同一份代码跑两次第一次准确率 93%第二次只有 90%。代码没变结果却不同很多人会觉得这是玄学其实原因非常明确随机初始化和每个 epoch 的样本 shuffle 顺序都不同梯度下降落入了不同的局部最优。解决方式是固定随机种子。在 load 数据之后、初始化之前写一行 np.random.seed(42)保证每个 epoch 的打乱顺序和权重初始值完全一致。固定种子除了让结果可复现还能让你在排查问题时确信准确率变化来自代码修改而不是随机波动。如果你要做超参对比实验种子不固定会导致两轮实验的差异混杂了随机因素结论不可靠。跑通项目之后也建议分别用种子 42 和 0 各测一次记录准确率波动范围这才是对这个任务水平的真实认知。6. 梯度检查用数值差分验证反向传播没写错反向传播代码写完先别急着训练完整数据集。有个更快的验证方法叫梯度检查用数值微分的近似值去对比解析梯度。如果两者一致说明整个 BP 链路的代码是对的如果不一致就按逐层排查的思路找公式或矩阵方向上的问题。常见的做法是给某个权重 w 加一个极小量 epsilon再减一个极小量用中心差分近似梯度(loss(wepsilon) - loss(w-epsilon)) / (2 * epsilon)。下面是抽出单个权重做检查的示例代码块def compute_loss(a2, y): return 0.5 * np.sum((a2 - y) ** 2) def single_numerical_gradient(x, y, model, i, j, eps1e-5): w1, b1, w2, b2 model old w1[i, j] # 权重加上一个小扰动计算损失 w1[i, j] old eps _, a2_plus forward(x, w1, b1, w2, b2) loss_plus compute_loss(a2_plus, y) # 权重减去一个小扰动计算损失 w1[i, j] old - eps _, a2_minus forward(x, w1, b1, w2, b2) loss_minus compute_loss(a2_minus, y) w1[i, j] old # 记得恢复原值 return (loss_plus - loss_minus) / (2 * eps)拿到数值梯度后对比第 4 章训练函数里算出的 grad_w1[i, j] 即可。判定标准是相对误差分母用两个梯度绝对值的和分子是差的绝对值。相对误差小于 1e-7说明反向传播实现完全正确在 1e-5 左右说明可能有浮点截断或笔误超过 1e-3几乎可以确定某个矩阵乘方向或导数项写错了。梯度检查的实操要注意三点。第一必须在单个样本上做不要在完整 batch 上做否则每个权重都要重新前向一次成本太高。第二只抽几个权重检查就够了比如 w1 抽三五个元素w2 抽三五个元素不需要遍历全部参数。第三检查的权重应该落在 sigmoid 的非饱和区如果恰好选到一个输入极大导致梯度接近 0 的位置数值误差会被放大判断会失真。这套方法不仅对这个项目有用。以后无论自己手写 CNN 还是其他自定义网络只要解析梯度存疑都可以用同一个思路验证。我自己的习惯是任何网络在跑完整训练之前先花十分钟做一次梯度检查过了再谈调参。回头看我第一次跑通这个项目时的记录最难忘的就是那个由初始化权重过大引发的 NaN排查了一晚上才发现连 sigmoid 都饱和到没有梯度。之后我养成了两个习惯任何网络先跑梯度检查再谈训练任何异常先看维度再看参数。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站