简介:围绕BP神经网络与MNIST手写数字识别,这份Python实现面向希望深入理解反向传播细节、掌握计算机视觉入门项目的高校学生与算法初学者。压缩包共约2000个文件,以MNIST图片png为主体,并包含训练好的权重与偏移量mat文件、可直接运行的py脚本以及json/xml配置数据,整体约69.36MB,既支持输入图片路径后缀快速预测,也保留训练集mat文件供自行训练与复现实验。已有8759人学习下载。读者可据此对照网络结构、前向传播、误差反传和参数更新流程,借助现成权重验证识别效果,再基于训练数据调整学习率、迭代次数与隐藏层设置,排查常见收敛问题,从而把BP算法从公式落实到可运行代码,积累手写数字识别项目的完整实践经验。
1. 先看清定位:一个不依赖框架的 BP 神经网络 MNIST 手写数字识别 Python 实现
「跑完 20 轮,准确率 11.3%,和闭着眼睛在十个数字里随便挑一个差不多。」这是我在一个用纯 NumPy 手写的 BP神经网络 MNIST 手写数字识别 Python 实现里见过最多的开局——代码能跑,损失也在降,但模型就是学不会。这类项目的价值恰恰在这儿:它把计算机视觉入门里最容易被框架藏起来的那段链路,数据怎么进来、梯度怎么传、参数怎么更新,全部摊在明面上。它不适合想直接冲到 99% 准确率的人,适合想弄明白一次前向传播和一次反向传播之间到底发生了什么的新手,也适合需要把训练过程拆开做实验对比、做教学演示的熟手。整份实现围绕 784 维输入、一到两个隐藏层、10 类输出的全连接网络展开,只依赖 NumPy 和 Python 标准库,CPU 上就能跑完全流程。
2. 目录结构与数据管线:MNIST 从 idx 原始文件到 784 维输入
数据管线是这类项目里最不起眼、翻车率却最高的一段。很多人把精力全放在网络结构上,结果读进来的图像是转置的、标签是浮点的,训练半天准确率纹丝不动,回头查才发现是数据层埋的雷。
2.1 一个够用的目录骨架
这份实现我一般会按下面的结构摆,边界清晰,后面换网络结构、加推理脚本都不用动数据层:
bp-mnist/ ├── data/ # 原始 idx 文件放在这里 │ ├── train-images-idx3-ubyte │ ├── train-labels-idx1-ubyte │ ├── t10k-images-idx3-ubyte │ └── t10k-labels-idx1-ubyte ├── src/ │ ├── dataset.py # idx 解析、归一化、one-hot、mini-batch │ ├── network.py # 参数初始化、前向传播、反向传播 │ ├── train.py # 训练循环、验证、保存权重 │ └── predict.py # 单张图片推理 ├── checkpoints/ # 权重落盘位置 └── requirements.txt # 实际上只有 numpydataset.py只负责把字节流变成 NumPy 数组,network.py只负责数学,train.py负责调度和日志。这种切法的好处是调试时能单点替换:想换激活函数只动network.py,想换数据增强只动dataset.py,互不牵连。常见做法是把三者塞进一个main.py,一开始省事,改到第二版就开始互相打架了。
2.2 解析 idx 文件:大端序和 magic 校验
MNIST 的四个文件是自定义的 idx 格式,头部用大端序写了魔数和维度。Python 里最省事的读法是struct加np.frombuffer,别用 PIL 或者别的手写解析器绕远路:
import struct import numpy as np def load_images(path): with open(path, 'rb') as f: # 前 16 字节:magic(4) + 图像数(4) + 行(4) + 列(4),全部大端序 magic, num, rows, cols = struct.unpack('>IIII', f.read(16)) assert magic == 2051, f'unexpected magic {magic}, 文件可能没解压或损坏' buf = f.read(num * rows * cols) # frombuffer 是零拷贝,astype 才真正生成新数组 data = np.frombuffer(buf, dtype=np.uint8).astype(np.float32) return data.reshape(num, rows * cols) # (60000, 784) def load_labels(path): with open(path, 'rb') as f: # 前 8 字节:magic(4) + 标签数(4) magic, num = struct.unpack('>II', f.read(8)) assert magic == 2049, f'unexpected magic {magic}, 文件可能没解压或损坏' buf = f.read(num) return np.frombuffer(buf, dtype=np.uint8).astype(np.int64) # 一定是整型>IIII里的>表示大端序,四个I是四个无符号 32 位整数。这两个assert不是摆设:如果直接把下载下来的.gz压缩包丢进data/而不解压,struct.unpack会读出一串完全不对的数,后面的 reshape 要么报错要么悄悄错位,这种错误最难查。图像数组 reshape 成(N, 784)而不是(N, 28, 28),是因为全连接层只认一维输入;如果后面要改卷积,再在这一层reshape(-1, 28, 28, 1)补回来就行。
2.3 归一化、one-hot 与 mini-batch 迭代
原始像素是 0 到 255 的整数,直接喂进网络会让每一层的加权和落在几百的量级,sigmoid 直接饱和、ReLU 的梯度尺度也乱。除以 255 是最小成本的修复:
def normalize(x): # 缩放到 [0, 1],让各层输入量级一致 return x / 255.0 def one_hot(y, num_classes=10): out = np.zeros((y.shape[0], num_classes), dtype=np.float32) out[np.arange(y.shape[0]), y] = 1.0 # 花式索引,一次写完全部标签 return outone_hot里那行花式索引值得多看一眼:out[np.arange(n), y] = 1.0等价于对每个样本i执行out[i, y[i]] = 1,比 Python 循环快两个数量级。这里必须保证y是int64,如果前面偷懒没做astype,y是uint8,花式索引在某些 NumPy 版本上会报类型错误或者静默截断,属于典型的玄学问题。
数据准备齐了,训练时还要分批。我习惯写一个生成器,把打乱和切片封在一起,训练脚本里只留一行for xb, yb in iterate_minibatches(...):
def iterate_minibatches(x, y, batch_size, shuffle=True): n = x.shape[0] idx = np.random.permutation(n) if shuffle else np.arange(n) for start in range(0, n, batch_size): sel = idx[start:start + batch_size] yield x[sel], y[sel]shuffle=True在训练集上开、验证集上关,这个细节决定了每个 epoch 的 batch 组成是否随机。如果忘了打乱,而原始数据又恰好按标签排过序(某些自定义数据集会这样),梯度方向会呈现周期性摆动,损失曲线看着像心电图。MNIST 官方给的文件本身是打乱的,但换成自己的数据集就得留意。
3. 网络结构拆解:784-128-10 三层 BP 网络的前向链路
结构本身不复杂,784 → 128 → 10两个权重矩阵,隐藏层用 ReLU,输出层不加激活函数、直接接 softmax。真正需要讲清楚的是每个矩阵的形状、为什么要选这个初始化、以及 softmax 里那一步减法到底防的是什么。
3.1 参数初始化为什么选 He
权重初始化是新手最容易糊弄过去的一步,np.random.randn(...) * 0.01一写就完事,然后训练不动就开始怀疑人生。隐藏层用 ReLU 的时候,我一般固定用 He 初始化:
def init_params(layer_sizes, seed=42): rng = np.random.default_rng(seed) params = {} for i in range(len(layer_sizes) - 1): fan_in, fan_out = layer_sizes[i], layer_sizes[i + 1] # He 初始化:方差 2/fan_in,适配 ReLU 把负半轴砍掉一半的特性 params[f'W{i+1}'] = rng.normal( 0.0, np.sqrt(2.0 / fan_in), size=(fan_in, fan_out) ).astype(np.float32) params[f'b{i+1}'] = np.zeros(fan_out, dtype=np.float32) return paramslayer_sizes传[784, 128, 10],循环会生成W1:(784,128)、b1:(128,)、W2:(128,10)、b2:(10,)四组参数。注意形状顺序是(fan_in, fan_out),前向时写x @ W,和 PyTorch 的nn.Linear(in, out)权重形状一致,方便后面把权重导出对照。
为什么是2/fan_in而不是1/fan_in:ReLU 把负半轴全部置零,等效于砍掉一半的方差,标准差要放大sqrt(2)倍才能让每层输出的方差大致保持不变。如果换成 sigmoid 或 tanh,就得换回 Xavier 的1/fan_in,用错了不会立刻报错,只会表现为「前几层几乎不更新」,这就是没有后悔药的那种错。偏置全零没问题,但权重绝不能全零——全零会让同一层的所有神经元梯度完全一样,训练完还是一模一样的复制品,对称性永远破不掉。
3.2 前向传播的矩阵形状
前向传播写成循环,层数变了不用改代码:
def forward(x, params, num_layers): cache = {'a0': x} # 缓存每层输入,反向传播要用 a = x for i in range(1, num_layers): z = a @ params[f'W{i}'] + params[f'b{i}'] # 线性变换 a = np.maximum(z, 0.0) # ReLU,原地语义但返回新数组 cache[f'z{i}'] = z cache[f'a{i}'] = a # 输出层不接激活,直接吐 logits,交给 softmax 交叉熵 zL = a @ params[f'W{num_layers}'] + params[f'b{num_layers}'] cache[f'z{num_layers}'] = zL return zL, cache拿一个 batch 64 走一遍:x是(64, 784),W1是(784, 128),第一层输出z1和a1都是(64, 128);第二层W2是(128, 10),输出 logits 是(64, 10)。cache里存了a0、z1、a1、z2,反向传播时按需取用。这里用np.maximum(z, 0.0)而不是z * (z > 0),前者在 NumPy 里更快,且不会产生额外的布尔数组。
输出层故意不加 softmax,是因为 softmax 和交叉熵合并求导后会化简成probs - y_onehot,把 softmax 挪到损失函数里去算,梯度形式最干净。如果在这里先 softmax 再传出去,反向时就得手动展开 softmax 的雅可比矩阵,既慢又容易错。
3.3 softmax 数值稳定与交叉熵
softmax 直接按定义写会溢出:exp(800)直接inf,然后inf / inf得nan,损失变成nan之后整个训练就废了,而且梯度里一旦出现nan,它会沿着链式法则污染所有参数,没有恢复的可能。
def softmax(z): # 减去每行最大值:数学上等价,数值上把 exp 的输入压到 <= 0 z = z - z.max(axis=1, keepdims=True) exp = np.exp(z) return exp / exp.sum(axis=1, keepdims=True) def cross_entropy(probs, y_onehot, eps=1e-12): # 只取正确类别的概率,clip 防止 log(0) logp = np.log(np.clip(probs, eps, 1.0)) return -np.sum(y_onehot * logp) / y_onehot.shape[0]z.max(axis=1, keepdims=True)这里keepdims=True不能省,少了它广播维度对不上,(64,10)减(64,)会直接报错。eps的作用是防止某个类别概率被算成精确的 0,log(0)得到-inf,乘以 one-hot 里的 0 会变成nan,又是一个难查的黑匣子。
到这一步,前向链路就完整了:x(64,784) → z1(64,128) → a1(64,128) → z2(64,10) → softmax → probs(64,10) → loss。把probs.argmax(axis=1)和真实标签比一下就是准确率,这个指标比损失更直观,训练日志里两个都要打。
4. 反向传播实战:一次 mini-batch 的梯度怎么算出来
反向传播是这套代码的核心,也是最容易抄错的地方。形状对不上会直接报错,这还好;怕的是形状恰好对上但公式写错,训练照样能跑,只是收敛得莫名其妙。
4.1 输出层梯度:softmax + 交叉熵的合并化简
先把结论写出来:当损失是交叉熵、输出层是 softmax 时,对 logits 的梯度就是probs - y_onehot,再除以 batch size。这个化简省掉了 softmax 雅可比矩阵的整个计算过程:
def backward(y_onehot, params, cache, num_layers): m = y_onehot.shape[0] # batch size,用于对梯度求平均 grads = {} probs = softmax(cache[f'z{num_layers}']) # dL/dz_L = (softmax(z) - y) / m,形状 (m, 10) dz = (probs - y_onehot) / m for i in range(num_layers, 0, -1): # dL/dW_i = a_{i-1}^T @ dz,形状 (fan_in, fan_out) grads[f'W{i}'] = cache[f'a{i-1}'].T @ dz # dL/db_i = 对 batch 维求和,形状 (fan_out,) grads[f'b{i}'] = dz.sum(axis=0) if i > 1: # 把梯度传回上一层:先过 W_i 的转置,再过 ReLU 的导数 da = dz @ params[f'W{i}'].T dz = da * (cache[f'z{i-1}'] > 0) return gradsdz = (probs - y_onehot) / m这一行是整套反向传播里信息密度最高的一行。除以m是为了让梯度是整个 batch 的平均梯度,而不是总和——少了这个除法,学习率得跟着 batch size 一起缩放,换个 batch size 就得重调学习率,非常难受。
cache[f'a{i-1}'].T @ dz这个矩阵乘法的形状值得推一遍:a_{i-1}是(m, fan_in),转置后(fan_in, m),乘dz(m, fan_out),结果(fan_in, fan_out),正好和W_i同形。任何一边对不上,NumPy 都会立刻抛形状错误,所以这一步反而是安全的。
4.2 隐藏层梯度:链式法则逐层回传
da = dz @ params[f'W{i}'].T是把输出层的梯度按权重比例分配回上一层的每个神经元,形状从(m, fan_out)变回(m, fan_in)。紧接着的dz = da * (cache[f'z{i-1}'] > 0)是 ReLU 的导数:输入大于 0 的位置梯度原样通过,小于等于 0 的位置梯度直接归零。
这里用cache[f'z{i-1}'] > 0而不是cache[f'a{i-1}'] > 0,两者在 ReLU 下结果一样,但用z更贴合定义,换成 LeakyReLU 时只需要把掩码改成常数,改动点集中在一处。循环从num_layers递减到 1,i > 1的判断保证不会去取不存在的a0的上一层,边界处理就在这一行。
一个常见误解是「反向传播要从最后一层开始一层层往前算梯度」,方向没错,但顺序上必须先算dW_i再算da,因为da用到了W_i本身。如果先更新了W_i再算da,用的就是新权重,梯度方向和实际前向时不一致,训练会变得很不稳定。这也是为什么训练循环里必须「先 backward 拿全部梯度,再统一更新」。
4.3 训练循环与超参落点
把前向、反向、更新串起来,就是这个项目的主循环:
def train(x_train, y_train, x_val, y_val, layer_sizes, epochs=20, batch_size=64, lr=0.1, seed=42): params = init_params(layer_sizes, seed=seed) num_layers = len(layer_sizes) - 1 y_train_oh = one_hot(y_train) best_acc, best_params = 0.0, None for epoch in range(1, epochs + 1): total_loss, total_correct = 0.0, 0 for xb, yb in iterate_minibatches(x_train, y_train_oh, batch_size): logits, cache = forward(xb, params, num_layers) probs = softmax(logits) total_loss += cross_entropy(probs, yb) * xb.shape[0] total_correct += (probs.argmax(axis=1) == yb.argmax(axis=1)).sum() grads = backward(yb, params, cache, num_layers) for k in params: # 朴素 SGD 更新 params[k] -= lr * grads[k] val_acc = evaluate(x_val, y_val, params, num_layers) if val_acc > best_acc: # 只保留验证集最好的那份 best_acc, best_params = val_acc, {k: v.copy() for k in params} print(f'epoch {epoch:02d} loss {total_loss / len(x_train):.4f} ' f'train_acc {total_correct / len(x_train):.4f} val_acc {val_acc:.4f}') return best_params, best_acc默认超参我一般从lr=0.1、batch_size=64、epochs=20起步,隐藏层宽度 128。这个组合在 MNIST 上通常 5 到 8 个 epoch 就能到 97% 以上,20 轮能摸到 98% 左右。学习率 0.1 对纯 SGD 偏大但能接受,如果 loss 曲线出现明显震荡,先降到 0.01 再看。
best_params那行做的是深拷贝:{k: v.copy() for k in params}。如果直接写best_params = params,后面每次更新都会改到同一个字典对象,最后存下来的其实是最后一轮的权重,验证集准确率会莫名其妙地掉一截。这个坑我踩过不止一次,血泪经验就是保存模型的时候永远显式copy()。
evaluate函数在验证集上跑的时候不要打乱、不要更新参数,shuffle=False,并且必须用np.no_grad的等价写法——NumPy 里没有自动求导,天然就是推理模式,只要别误调backward就行。
5. 避坑与排查:训练不收敛时先查这七处
这套代码跑不起来或者跑起来不学,原因基本集中在下面七处。我按「现象 → 原因 → 解决」的顺序列,遇到问题从上往下对一遍,命中率很高。
5.1 数据与标签层面的四个坑
现象:训练损失在 2.3 附近不动,准确率稳定在 10% 左右。原因通常是标签没做 one-hot,或者做了但形状是(60000,)而不是(60000, 10),y_onehot * logp广播出一个错误的损失值,梯度方向完全乱了。解决方式是在train()开头加一行断言:assert y_train_oh.shape == (len(y_train), 10),让错误在第一时间暴露,而不是等到训练十轮之后才发现。
现象:读取 idx 文件时报unexpected magic 35615之类。原因很单纯:data/里放的是.gz压缩包,struct把 gzip 头当成魔数读了。解决就是先解压,把四个 idx 文件直接放到data/下。另一个变体是文件下载不完整,magic对但num特别小,加一个assert num > 1000能提前拦住。
现象:训练到第三四个 epoch,损失突然变成nan。原因多半是 softmax 没做减最大值的数值稳定处理,或者学习率给到了 1.0 以上,权重一步更新跨过了收敛区间。解决分两步:先确认softmax里有z - z.max(...),再把学习率降到 0.1 或 0.01 重跑。如果降到 0.001 还是nan,去检查输入里有没有未归一化的原始像素值。
现象:损失正常下降,训练准确率能到 98%,验证准确率只有 90% 出头。这是过拟合的典型信号。全连接网络参数量接近 10 万,MNIST 训练集只有 6 万张,训过头就会记住样本。解决是加 L2 正则(在grads里给W补一项weight_decay * W)、加 dropout,或者更简单——用best_params做早停,验证集连续几轮不涨就停。
5.2 训练过程层面的三个坑
现象:损失曲线每个 batch 剧烈抖动,整体趋势看不出下降。原因是batch_size太小(比如 8 或 16),单批梯度噪声盖过了真实方向。解决办法是把batch_size调到 64 或 128,或者在打印日志时对最近 50 个 batch 的损失取滑动平均。我一般会同时打印原始 loss 和滑动平均,前者看稳定性,后者看趋势。
现象:训练集和验证集准确率都卡在 11% 到 12% 之间,怎么调学习率都没用。这种「完全不学」的状态,九成是初始化出了问题:要么权重全零导致对称性破不掉,要么用了* 0.01的固定小方差,前几层输出几乎不变。解决是把初始化换成按fan_in缩放的 He 或 Xavier,并固定随机种子复现。seed=42这类固定种子在排查阶段非常关键,否则每次跑出来的结果都不一样,根本没法比较两次改动。
现象:训练能收敛,但每次重跑结果波动很大,验证准确率在 95% 到 98% 之间跳。这是随机种子、数据打乱顺序、参数初始化三件事共同作用的结果,属于正常范围,但如果波动超过 2 个百分点,就值得怀疑。我的做法是固定种子跑三次,看均值和方差;如果方差大,先加大batch_size或降低学习率,再考虑加正则。改完任何一处超参,都要用同一组种子重跑,不然改动的效果会被随机性淹没。
6. 二次开发:从全连接走到卷积与工程化导出
想在这份实现上往下走,最自然的一步是把全连接换成卷积。全连接层把 28×28 的图像拉平,空间结构全丢了,而手写数字的局部笔画特征恰恰是卷积擅长的。改造时不用动dataset.py,只在forward里把x从(m, 784)reshape 成(m, 28, 28, 1),然后按conv → relu → pool → flatten → fc的顺序搭。用 NumPy 手写卷积的性能很差,我一般会在这个阶段切到 PyTorch 做对照实验,用同一份数据管线验证两边的验证集准确率是否接近,差得太多就说明数据预处理有分歧。
权重导出是另一个高频需求。训练完的params是个普通字典,直接np.savez('checkpoints/bp.npz', **params)就能落盘,predict.py里np.load回来,按同样的layer_sizes重建后加载即可。这里有个小技巧:把layer_sizes一起存进 npz,加载时先读它再init_params,避免手写维度对不上。下面这段是我常用的推理封装:
import numpy as np def load_model(path): ckpt = np.load(path) layer_sizes = ckpt['layer_sizes'].tolist() # 维度跟着权重一起存 params = {k: ckpt[k] for k in ckpt.files if k != 'layer_sizes'} return params, len(layer_sizes) - 1 def predict_one(x_flat, params, num_layers): # x_flat: (784,) 的归一化像素,返回 (类别, 置信度) logits, _ = forward(x_flat.reshape(1, -1), params, num_layers) probs = softmax(logits)[0] idx = int(probs.argmax()) return idx, float(probs[idx])验证改造有没有效,我习惯用两条硬指标:一是固定种子跑三次,验证集准确率的方差要小于 0.5 个百分点;二是在训练集的一个小子集(比如 500 张)上必须能过拟合到 99% 以上,过拟合不了说明模型容量或梯度链路有问题,别急着上全量数据。这套 sanity check 比盯着损失曲线猜要快得多。
从那以后我每次改完数据管线或者换激活函数,都强制先跑一遍「500 张过拟合」再开全量训练,不然很容易在错误的方向上白跑半小时。希望帮到你。
本文还有配套的精品资源,点击获取