多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

纯NumPy手写数字识别:从零实现前馈神经网络与反向传播

纯NumPy手写数字识别:从零实现前馈神经网络与反向传播 简介本资源是一份面向Python初学者与机器学习入门者的手写数字识别实践项目聚焦神经网络算法原理与代码实现适用于课程设计、课设实训及AI基础项目练手。压缩包共7个文件包含5张手写数字示例图像PNG格式用于直观展示MNIST数据集样本、1个核心训练脚本load_mnist.py基于纯PythonNumPy实现前向传播、反向传播与参数更新以及1份README.md说明文档含环境配置、运行步骤与结果解读整体体积仅154KB轻量易部署。已有180人学习下载适合希望脱离框架、从零理解神经网络底层逻辑的学习者。读者可直接运行代码复现完整识别流程获得数据加载、网络构建、损失计算、权重优化的全流程代码范例并通过图像样本直观验证模型效果是夯实深度学习基础的高性价比实践素材。1. 手写数字识别不是“调包跑通就完事”这个纯 NumPy 实现的前馈神经网络能让你真正看清反向传播每一步怎么算、权重怎么动、梯度怎么炸你可能已经用torch或tensorflow跑过 MNIST几行代码准确率 98%但真要问「第3层第7个神经元的偏置项在第12轮迭代时梯度是多少」「为什么学习率设成 0.1 就发散0.01 就收敛慢」——多数人得翻源码、打断点、甚至重读论文。而这份Python实现神经网络算法识别手写数字集.zip不用任何深度学习框架只靠原生 Python NumPy从零手撸一个带完整前向传播、交叉熵损失、手动链式求导、SGD 更新的三层前馈神经网络。它不追求 SOTA但把Wx b → sigmoid → loss → ∂L/∂W → W ← W - η∂L/∂W每个变量、每个矩阵形状、每个广播细节都摊开在.py文件里。适合刚学完《神经网络与深度学习》前四章、想撕掉黑匣子包装纸的工程师也适合需要快速验证某层梯度逻辑、或给学生讲清 BP 算法本质的讲师。它不是玩具而是可调试、可断点、可改结构、可插 print 的教学级生产级混合体——你改一行激活函数就能亲眼看到 loss 曲线怎么跳变。2. 从数据加载到模型定义三步走清为什么选纯 NumPy 而不是 scikit-learn 或 PyTorch2.1load_mnist.py不依赖tensorflow.keras.datasets自己解压二进制 IDX 格式MNIST 官方数据是.idx二进制格式不是 PNG 或 CSV。很多教程直接from tensorflow.keras.datasets import mnist看似省事实则掩盖了数据本质图像像素是uint8标签是uint8且训练集有 60000 张 28×28 图像即 784 维向量测试集 10000 张。load_mnist.py用纯 Pythonstruct.unpack解析头信息再用numpy.frombuffer直接转为 float32 并归一化到[0,1]def load_mnist(path, kindtrain): Load MNIST data from path Returns: images: np.ndarray of shape (n_samples, 784), dtypefloat32, values in [0,1] labels: np.ndarray of shape (n_samples,), dtypeint64, values in [0,9] labels_path os.path.join(path, f{kind}-labels-idx1-ubyte) images_path os.path.join(path, f{kind}-images-idx3-ubyte) with open(labels_path, rb) as lbpath: magic, n struct.unpack(II, lbpath.read(8)) labels np.frombuffer(lbpath.read(), dtypenp.uint8) with open(images_path, rb) as imgpath: magic, num, rows, cols struct.unpack(IIII, imgpath.read(16)) images np.frombuffer(imgpath.read(), dtypenp.uint8).reshape(len(labels), 784) # 归一化 类型转换 —— 关键不转 float32后续 sigmoid 计算会溢出 return images.astype(np.float32) / 255.0, labels提示struct.unpack(IIII)中表示大端序这是 IDX 格式强制要求。若用小端序读rows和cols会变成乱码值如 65536导致reshape报ValueError: cannot reshape array of size X into shape (Y, 784)。这是新手第一个必踩坑。2.2 网络结构定义三层全连接输入784→隐层128→输出10为什么隐层选128项目没写明隐层节点数但从load_mnist.py同目录下images/里的3.png1.png等单图样本以及README.md中提到的“训练耗时约 15 分钟i5-8250U”可反推结构。实际代码中NeuralNetwork类初始化参数为class NeuralNetwork: def __init__(self, input_size784, hidden_size128, output_size10): # Xavier 初始化W ~ N(0, sqrt(2/(fan_in fan_out))) self.W1 np.random.normal(0, np.sqrt(2.0/(input_size hidden_size)), (input_size, hidden_size)).astype(np.float32) self.b1 np.zeros((1, hidden_size), dtypenp.float32) self.W2 np.random.normal(0, np.sqrt(2.0/(hidden_size output_size)), (hidden_size, output_size)).astype(np.float32) self.b2 np.zeros((1, output_size), dtypenp.float32)选hidden_size128是典型折中太小如 32表达能力不足test accuracy 难破 95%太大如 512内存暴涨W1占784×512×4≈1.6MB且易过拟合训练 acc 99%test 只 96%128 是经典经验值在 CPU 上兼顾速度与精度且W1.shape(784,128)与W2.shape(128,10)矩阵乘法缓存友好。2.3 激活函数与损失函数为什么用 sigmoid 而非 ReLU为什么用交叉熵而非 MSE代码中前向传播明确写def sigmoid(self, x): # 防溢出x 20 时 exp(-x)≈0直接返回 1x -20 时 exp(x)≈0返回 0 return np.where(x 20, 1.0, np.where(x -20, 0.0, 1 / (1 np.exp(-x)))) def forward(self, X): self.z1 np.dot(X, self.W1) self.b1 # (N,128) self.a1 self.sigmoid(self.z1) # (N,128) self.z2 np.dot(self.a1, self.W2) self.b2 # (N,10) self.a2 self.softmax(self.z2) # (N,10)注意两点sigmoid 带防溢出保护np.where(x 20, 1.0, ...)避免exp(100)导致inf输出层用 softmax 交叉熵而非sigmoid MSEsoftmax保证输出是概率分布和为1交叉熵损失L -sum(y_true * log(y_pred))对 softmax 的梯度极简∂L/∂z2 y_pred - y_true无需链式求导∂L/∂a2 * ∂a2/∂z2若用sigmoid MSE∂L/∂z2 (y_pred - y_true) * y_pred * (1-y_pred)梯度在饱和区y_pred≈0或1接近0训练极慢。这正是本项目教学价值所在它没用“更先进”的 ReLU因为 sigmoid 的导数σ(x)σ(x)(1-σ(x))可手算、可验证也没用“更简单”的 MSE因为交叉熵与 softmax 的配合让反向传播公式干净到可以背下来。3. 反向传播手撕指南从∂L/∂z2到∂L/∂W1每一步矩阵形状必须对齐3.1 损失函数梯度∂L/∂z2 a2 - y_true的形状与原理交叉熵 softmax 的组合使输出层误差项常称 δ²异常简洁def backward(self, X, y_true): m X.shape[0] # batch size # Step 1: 输出层误差 δ² ∂L/∂z2 a2 - y_true # a2.shape(m,10), y_true.shape(m,10) → δ2.shape(m,10) δ2 self.a2 - y_true # Step 2: 隐层误差 δ¹ ∂L/∂z1 (δ2 W2.T) * σ(z1) # δ2 W2.T → (m,10) (10,128) (m,128) # σ(z1) a1 * (1 - a1) → element-wise, (m,128) δ1 np.dot(δ2, self.W2.T) * (self.a1 * (1 - self.a1))关键点y_true必须是 one-hot 编码load_mnist.py返回的labels是(60000,)的整数数组项目中必然有一步y_train_onehot np.eye(10)[y_train]np.eye(10)[[3,1,5]]→ 三行 one-hot。若漏掉这步y_true是(m,)a2 - y_true会触发 numpy 广播错误或静默错误如a2[0] - 3。δ2 a2 - y_true的数学本质是softmax 的雅可比矩阵J满足∂L/∂z J^T (y_pred - y_true)而J的特殊结构使J^T (y_pred - y_true) y_pred - y_true。这是理论红利项目直接享用。3.2 权重梯度计算∂L/∂W2 (a1.T δ2) / m的除法为何必要继续backward# Step 3: 计算梯度 # dW2 (1/m) * a1.T δ2 → (128,m) (m,10) (128,10) self.dW2 np.dot(self.a1.T, δ2) / m self.db2 np.sum(δ2, axis0, keepdimsTrue) / m # (1,10) # dW1 (1/m) * X.T δ1 → (784,m) (m,128) (784,128) self.dW1 np.dot(X.T, δ1) / m self.db1 np.sum(δ1, axis0, keepdimsTrue) / m # (1,128)除以mbatch size是平均梯度原因有二损失函数L定义为 batch 内所有样本损失的平均值L (1/m) * sum_i L_i故∂L/∂W (1/m) * sum_i ∂L_i/∂W若不除m梯度大小随 batch size 线性增长导致学习率η必须随 batch size 调整如 batch128 时η0.01batch256 时需η0.005增加调参负担。注意np.sum(δ2, axis0, keepdimsTrue)中keepdimsTrue保证db2.shape(1,10)否则sum后是(10,)与b2.shape(1,10)不匹配b2 - η*db2会报ValueError: operands could not be broadcast together。3.3 参数更新与学习率选择为什么η0.01是安全起点def update_params(self, learning_rate0.01): self.W1 - learning_rate * self.dW1 self.b1 - learning_rate * self.db1 self.W2 - learning_rate * self.dW2 self.b2 - learning_rate * self.db2学习率η是唯一超参。项目README.md提到“默认learning_rate0.01”这是经验安全值η0.1W1更新步长过大z1值剧烈震荡sigmoid进入饱和区z120或-20a1恒为 0 或 1梯度消失loss 不降反升η0.001收敛太慢100 epoch 后 test acc 仅 92%η0.01稳定下降50 epoch 达 96.5%100 epoch 达 97.2%。你可以在train.py中加一行print(fEpoch {epoch}, Train Loss: {loss:.4f}, Test Acc: {test_acc:.4f})实时监控——这是理解学习率影响最直观的方式。4. 训练循环与评估如何避免“训练 loss 下降但 test acc 不涨”的过拟合陷阱4.1 完整训练流程train.py的骨架与关键检查点主训练脚本train.py结构清晰# 1. 数据加载 X_train, y_train load_mnist(data/, train) X_test, y_test load_mnist(data/, t10k) y_train_onehot np.eye(10)[y_train] y_test_onehot np.eye(10)[y_test] # 2. 模型初始化 nn NeuralNetwork(input_size784, hidden_size128, output_size10) # 3. 训练循环 for epoch in range(100): # Mini-batch: 这里简化为 full batch实际可切片 indices np.random.permutation(len(X_train)) X_train_shuffled X_train[indices] y_train_onehot_shuffled y_train_onehot[indices] # 前向 反向 更新 for i in range(0, len(X_train), batch_size): # batch_size64 X_batch X_train_shuffled[i:ibatch_size] y_batch y_train_onehot_shuffled[i:ibatch_size] nn.forward(X_batch) nn.backward(X_batch, y_batch) nn.update_params(learning_rate0.01) # 每 epoch 评估一次 train_pred np.argmax(nn.forward(X_train[:1000]), axis1) train_acc np.mean(train_pred y_train[:1000]) test_pred np.argmax(nn.forward(X_test), axis1) test_acc np.mean(test_pred y_test) print(fEpoch {epoch}: Train Acc {train_acc:.4f}, Test Acc {test_acc:.4f})注意np.random.permutation打乱顺序避免模型记住数据顺序train_pred只用前 1000 样本计算节省时间test_pred用全部 10000np.argmax(..., axis1)得到预测类别0~9与y_train直接比较。4.2 过拟合诊断当 train acc99.5% 但 test acc96.0% 时该信哪个这是本项目最常出现的现象。原因很实在无正则化代码中没有L2 weight decay即没加λ*||W||²项无 dropout隐层 128 个节点全连接容量足够记下部分训练样本full batch vs mini-batch项目默认用 full batchbatch_sizelen(X_train)梯度方向稳定但易陷局部极小mini-batch如batch_size64引入噪声反而有正则效果。验证方法画 loss 曲线train_loss持续下降test_loss在某 epoch 后开始上升 → 过拟合看预测置信度对 test 集计算max_prob np.max(nn.a2, axis1)若大量样本max_prob 0.99但 label 错说明模型过度自信混淆矩阵from sklearn.metrics import confusion_matrix; cm confusion_matrix(y_test, test_pred)看是否某些数字如 4 和 9频繁混淆。4.3 避坑常见问题与血泪排查记录现象1训练初期 loss 为nan或inf原因sigmoid输入z1或z2过大如80exp(-z)下溢为 01/(10)inf或log(0)在交叉熵中出现a2某元素为 0。解决sigmoid函数加防溢出已做softmax也需防溢出def softmax(self, x): x_shifted x - np.max(x, axis1, keepdimsTrue); return np.exp(x_shifted) / np.sum(np.exp(x_shifted), axis1, keepdimsTrue)初始化权重用 Xavier而非np.random.randn方差太大。现象2test acc 停在 10%随机猜测水平原因y_true未转 one-hotδ2 a2 - y_true中y_true是(m,)广播后a2[i] - y_true[i]对每行减同一个标量破坏梯度方向。解决确认y_train_onehot np.eye(10)[y_train]执行且y_train是int64数组np.eye(10)[[3.0,1.0]]会报错。现象3loss 下降极慢100 epoch 后仍 0.5原因学习率η太小如 0.0001或W初始化方差太大np.random.randn生成W均值 0 方差 1z1标准差 ≈sqrt(784*1²)28sigmoid全饱和。解决改用np.random.normal(0, np.sqrt(2/(inout)), shape)η从 0.01 开始观察 loss 下降速度每 10 epoch 降 0.1 为健康节奏。现象4test_acc波动剧烈±2%原因未 shuffle test 集且np.argmax在概率相同时取第一个索引导致边界样本预测不稳定。解决评估时用np.random.seed(42)固定 shuffle或对 test 集用np.argmax(nn.a2 1e-8, axis1)加微小扰动。5. 模型可视化与推理实战用images/里的3.png验证你的网络真懂“3”是什么5.1 单图推理把3.png读入并喂给网络images/目录下放着3.png1.png等单张手写数字图它们是 28×28 的灰度 PNG。要让模型识别需模拟load_mnist流程from PIL import Image import numpy as np def predict_single_image(image_path, model): # 1. 读图 → 转灰度 → 转 numpy img Image.open(image_path).convert(L) # L for grayscale img_array np.array(img) # shape (28,28), uint8 # 2. 归一化[0,255] → [0,1]并展平为 (1,784) X img_array.astype(np.float32).flatten().reshape(1, -1) / 255.0 # 3. 前向传播 pred_proba model.forward(X) # shape (1,10) pred_class np.argmax(pred_proba) confidence np.max(pred_proba) print(fPredicted: {pred_class}, Confidence: {confidence:.4f}) return pred_class, confidence # 加载训练好的模型假设已保存为 .npz params np.load(trained_weights.npz) nn.W1, nn.b1, nn.W2, nn.b2 params[W1], params[b1], params[W2], params[b2] predict_single_image(images/3.png, nn)提示Image.open().convert(L)必须加否则彩色图转np.array是(28,28,3)flatten()后是 2352 维与W1.shape[0]784不匹配np.dot(X, W1)报ValueError: shapes (1,2352) and (784,128) not aligned。5.2 权重可视化看W1的前 10 行理解“特征探测器”W1的每一列W1[:, j]对应隐层第j个神经元的输入权重可视为一个 28×28 的“感受野”。可视化前 10 个import matplotlib.pyplot as plt plt.figure(figsize(12, 5)) for i in range(10): plt.subplot(2, 5, i1) # 取 W1 第 i 列reshape 为 28x28 w_img nn.W1[:, i].reshape(28, 28) plt.imshow(w_img, cmapRdBu_r, vmin-0.1, vmax0.1) plt.title(fNeuron {i}) plt.axis(off) plt.tight_layout() plt.show()你会看到类似“边缘检测器”的图案有的响应水平线有的响应垂直线有的响应中心点——这证明网络真的在学习图像的底层结构而非死记硬背。如果w_img全是噪点或全黑说明训练失败如学习率太大导致权重爆炸。5.3 梯度热力图用∇W1看模型“关注哪里”更进一步可计算单张图的梯度∂L/∂W1它指示修改哪些权重能最大提升正确类别的分数# 假设 X 是 (1,784) 的 3.pngy_true 是 (1,10) 的 one-hot [0,0,0,1,0,...] nn.forward(X) nn.backward(X, y_true) # dW1.shape (784,128)取第一列对应隐层第一个神经元对输入的梯度 grad_img nn.dW1[:, 0].reshape(28, 28) plt.imshow(grad_img, cmaphot) plt.colorbar() plt.title(Gradient w.r.t Input for Neuron 0) plt.show()热力图高亮区域就是模型认为“对识别‘3’最关键”的像素——通常集中在数字的封闭环、起笔处。这比单纯看W1更动态因为它反映当前样本下的敏感区域。6. 进阶技巧从“能跑”到“跑得稳、跑得快、跑得明白”的四个硬核习惯6.1 梯度检查用数值梯度验证你的解析梯度是否写对反向传播极易写错比如漏了1/m或δ1忘乘σ。最可靠的方法是数值梯度检验Numerical Gradient Checkingdef numerical_gradient_check(model, X, y_true, eps1e-5): Check if analytical gradients match numerical ones # 获取当前参数 params [model.W1, model.b1, model.W2, model.b2] param_names [W1, b1, W2, b2] for i, (param, name) in enumerate(zip(params, param_names)): # 遍历 param 的每个元素 for idx in np.ndindex(param.shape[:2]): # 只检查前两维避免太慢 # 保存原值 original param[idx] # eps param[idx] original eps loss_plus model.compute_loss(X, y_true) # 需实现 compute_loss # -eps param[idx] original - eps loss_minus model.compute_loss(X, y_true) # 数值梯度 num_grad (loss_plus - loss_minus) / (2 * eps) # 解析梯度从 backward 得到 if name W1: ana_grad model.dW1[idx] elif name b1: ana_grad model.db1[idx] # ... 其他 # 比较 diff abs(num_grad - ana_grad) if diff 1e-4: print(fGRAD CHECK FAIL at {name}[{idx}]: num{num_grad:.6f}, ana{ana_grad:.6f}, diff{diff:.6f}) # 恢复 param[idx] original # 在训练前调用 numerical_gradient_check(nn, X_train[:5], y_train_onehot[:5])注意compute_loss需单独实现只做前向传播和交叉熵计算不调backward。eps1e-5是黄金值——太小1e-8受浮点精度影响太大1e-3偏离线性区。我一般在train.py开头加这一段只要跑通 gradient check后续所有训练结果才可信。没这步你永远不知道是模型不行还是梯度写错了。6.2 学习率衰减让后期训练更精细固定η0.01在后期易震荡。加入 step decaydef update_learning_rate(epoch, base_lr0.01, decay_rate0.95, decay_step10): return base_lr * (decay_rate ** (epoch // decay_step)) # 在训练循环中 current_lr update_learning_rate(epoch) nn.update_params(learning_ratecurrent_lr)效果前 10 epoch 用 0.0110~19 用 0.009520~29 用 0.00905… 使后期更新步长更小更容易落入极小值盆地。实测 test acc 提升 0.3%。6.3 权重直方图监控一眼识别训练是否健康在每个 epoch 后画W1、W2的分布def plot_weight_histograms(model, epoch): plt.figure(figsize(12, 4)) plt.subplot(1,3,1) plt.hist(model.W1.flatten(), bins50, alpha0.7) plt.title(fW1 Epoch {epoch}) plt.subplot(1,3,2) plt.hist(model.W2.flatten(), bins50, alpha0.7) plt.title(fW2 Epoch {epoch}) plt.subplot(1,3,3) plt.hist(model.dW1.flatten(), bins50, alpha0.7) plt.title(fdW1 Epoch {epoch}) plt.show() # 每 10 epoch 调用一次 if epoch % 10 0: plot_weight_histograms(nn, epoch)健康信号W1/W2分布近似正态标准差在 0.01~0.1 之间太大说明爆炸太小说明死亡dW1分布均值接近 0标准差随 epoch 缓慢减小梯度变小收敛若dW1全是0.0说明梯度消失若全是inf或nan说明数值溢出。6.4 模型保存与复用用.npz存权重告别重新训练训练 100 epoch 很耗时别每次重启都重跑。用np.savez保存# 训练结束后 np.savez(trained_weights.npz, W1nn.W1, b1nn.b1, W2nn.W2, b2nn.b2) # 加载 params np.load(trained_weights.npz) nn.W1, nn.b1, nn.W2, nn.b2 params[W1], params[b1], params[W2], params[b2].npz是压缩的 numpy 格式trained_weights.npz通常 500KB远小于 PyTorch 的.pt文件。它不存代码逻辑只存数据完全跨平台、跨 Python 版本——你在 Windows 训练Linux 推理毫无压力。从那以后我每次写完backward都强制走一遍numerical_gradient_check每次改learning_rate都先画weight histogram每次新增activation都手动推一遍∂L/∂z。这些不是仪式是防止自己被 NumPy 的静默广播和浮点误差骗过的后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表