Python手写BP神经网络:从零实现MNIST手写数字识别与迁移
发布时间:2026/10/2 1:23:34来源:尧图网络
简介这套Python神经网络手写数字识别源码包面向机器学习初学者、Python开发者及高校相关课程学生用于理解神经网络如何通过训练实现图像分类重点涵盖BP神经网络、激活函数、前向传播与反向传播等核心机制。压缩包共7个文件仅154KB体积精简但链路完整包含1个Python实现脚本、5张不同样本的识别效果图片和1份Markdown说明文档脚本负责数据加载与网络训练可基于MNIST数据集完成模型训练与预测图片直观展示模型对多个手写数字的识别输出说明文档则梳理项目结构、运行方式与关键代码思路。已有180人学习下载非常适合对照代码理解神经网络从输入到输出的完整流程也方便在此基础上做参数调优、模型改进或作为课程实验与毕业设计的起步模板。1. 手写数字识别为什么总拿神经网络当第一个项目任务定义与数据真相只要你在搜索引擎里敲过“Python 神经网络 手写数字识别”大概率会落到 MNIST 数据集上。这个任务看起来简单但恰好是入门神经网络最锋利的一把刀输入是 28x28 的灰度图输出是 0 到 9 的十类概率数据量不大单机 CPU 就能跑完但前向传播、反向传播、损失函数、梯度下降这些核心机制一个都不少。很多人以为神经网络识别手写数字只是“调库调参”的事实际上只要你亲手用 Python 实现一遍 BP 算法后面再看 PyTorch、TensorFlow 里的抽象接口会有一种“原来黑匣子里面是这么回事”的通透感。这篇笔记适合两类人一类是刚学完 Python 基础、想用神经网络做点实物的新手另一类是已经在用框架训练模型、但没手写过反向传播的熟手。我不打算给你堆一个几百行的工程而是从数学骨架开始落到最小可运行代码再到训练评估和排错最后告诉你如何把 MNIST 模型迁移到你自己写的数字上。读完你不仅能跑通一个手写数字识别网络还能知道每个参数为什么这么设、失败时该看哪一行输出。2. 从像素到概率前馈神经网络的数学骨架与选型理由2.1 神经元、激活函数与 Softmax识别网络最小组成单元一个用于手写数字识别的前馈神经网络输入是一张 28x28 尺寸的图片。把它拉平后就是 784 个像素的灰度值向量喂给第一层神经元。每个神经元做的事情其实只有两步计算输入向量的加权和再套一个非线性激活函数。所谓“神经网络”就是把这些神经元分层串起来层与层之间用权重矩阵连接前一层的输出成为后一层的输入。以两层网络为例第一层叫隐藏层输出维度可以设成 64 或 128第二层是输出层因为 MNIST 有 10 个类别输出层的神经元个数固定是 10。最后一层通常会接 Softmax把十个神经元的输出压缩成一组和为 1 的概率第几个位置的概率最高预测结果就是那个数字。这就是“从像素到概率”的最短路径。这里强调一下隐藏层必须加非线性激活函数常用的有 Sigmoid、tanh 和 ReLU。如果每一层都是线性加权那么无论如何堆层数整个网络在数学上等价于一个线性分类器永远学不出“弯曲”的决策边界。2.2 为什么 MNIST 用全连接而不是卷积边界与理由很多人在搜索“手写数字识别”时看到的大多是卷积神经网络 CNN 的教程紧接着就开始犹豫是不是该直接上 CNN我的建议是在实现第一个神经网络时先老老实实用全连接网络也就是前馈神经网络。原因有三。第一MNIST 的数字是居中、拉伸过的背景干净类别差异主要体现在笔画朝向和形状上这些特征用全连接网络低层组合也能捕获测试集准确率可以轻松到 97% 左右对于入门完全够用。第二全连接网络的实现代码量大约是 CNN 的三分之一你花一个小时手写 BP 就能跑通而 CNN 的卷积、池化、感受野概念对新手容易变成黑匣子还没搞清楚反向传播就先被维度翻转淹没。第三后续迁移到自定义数字时全连接网络对预处理要求较低调试更直观。但并不是说 CNN 不值得学。CNN 在复杂图像、有平移和缩放变化的数据上优势明显这也是为什么搜索热词里“卷积神经网络”常年和手写数字识别绑定。我的建议是第一遍用全连接把机制打通第二遍再用 PyTorch 或 Keras 实现一个 LeNet-5 替换掉全连接层对比两者在验证集上的差距你的理解会比直接抄 CNN 代码深得多。全连接是地基CNN 是精装修别跳级。2.3 损失函数与梯度下降让误差反向传播的闭环神经网络训练的本质是极小化损失函数。对分类任务来说最常用的损失是交叉熵损失。输出概率分布和真实标签之间的交叉熵越小说明预测越接近正确类别。这和均方误差MSE有什么区别手写数字识别本质是一个概率分布拟合问题交叉熵对概率分布的差异更敏感梯度随误差变化也更合理而 MSE 在 Softmax 输出下容易梯度消失训练会慢得多。所以动手写代码时第一反应应该是交叉熵不是 MSE。有了损失函数剩下的问题就是如何更新权重。梯度下降的思想很简单计算损失对每个权重的偏导数然后朝着负梯度方向更新权重。误差反向传播BP算法给出了一个高效计算所有偏导数的方法即先把输出层误差算出来再通过链式法则逐层往前传。这个“传”的过程就是反向传播也是“前馈”网络的动力来源。许多热词里的“神经网络 正向 反向 传播 残差计算”指的就是前向计算输出、反向计算残差、用残差更新权重这三步循环。理解并手写这三步比调十个框架都值钱。3. 用 Python 在本地跑通最小神经网络代码实现与参数设置3.1 搭建环境与数据准备numpy、pandas 与 mnist 数据加载开始写代码前我先给出环境建议Python 3.8 以上即可核心依赖只有 numpy 和 matplotlib。不需要 GPU不需要 CUDA甚至不需要 PyTorch 或 TensorFlow单核 CPU 跑几十个 epoch 也就几十秒。数据加载我推荐直接从 sklearn.datasets 提供的 fetch_openml 接口读 MNIST它内部完成下载、切分和格式转换返回的就是 numpy 数组很适合脱离框架手写网络时使用。import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import fetch_openml from sklearn.model_selection import train_test_split # 第一次运行会自动下载约 12MB数据会缓存在本地 mnist fetch_openml(mnist_784, version1, as_frameFalse, parserauto) X mnist.data.astype(np.float32) / 255.0 # 像素值归一化到 [0,1] y mnist.target.astype(np.int64) # 标签转成 int print(f样本数量: {X.shape[0]}, 特征维度: {X.shape[1]}) X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy) print(f训练集: {X_train.shape}, 测试集: {X_test.shape})这段代码做了三件事加载数据、归一化、按分层抽样切分训练集和测试集。归一化到 [0,1] 非常重要原图的灰度值是 0 到 255如果不除以 255输入到神经元后的加权和会很大Sigmoid 或 tanh 激活函数容易饱和梯度直接消失到接近 0。stratifyy 保证训练集和测试集中 0 到 9 的比例一致这样不会因为某个数字恰好集中在一侧而低估准确率。3.2 核心代码两层网络的前向与反向传播下面就是一个最简但完整的全连接前馈神经网络隐藏层 64 个神经元ReLU 激活输出层 Softmax交叉熵损失。我把前向、反向和梯度更新全部写在一个类里便于你逐一跟踪矩阵维度。class TwoLayerNet: def __init__(self, input_size784, hidden_size64, output_size10, lr0.1): # 用 He 初始化减少 ReLU 带来的神经元死亡 self.W1 np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size) self.b1 np.zeros(hidden_size) self.W2 np.random.randn(hidden_size, output_size) * np.sqrt(2.0 / hidden_size) self.b2 np.zeros(output_size) self.lr lr def forward(self, x): self.z1 np.dot(x, self.W1) self.b1 self.a1 np.maximum(0, self.z1) # ReLU self.z2 np.dot(self.a1, self.W2) self.b2 # Softmax防止数值溢出减去每行最大值 exp_z2 np.exp(self.z2 - np.max(self.z2, axis1, keepdimsTrue)) self.a2 exp_z2 / np.sum(exp_z2, axis1, keepdimsTrue) return self.a2 def backward(self, x, y_onehot, output): batch_size x.shape[0] # 输出层梯度Softmax CrossEntropy 的简化形式 dz2 output - y_onehot self.dW2 np.dot(self.a1.T, dz2) / batch_size self.db2 np.sum(dz2, axis0) / batch_size # 隐藏层梯度 da1 np.dot(dz2, self.W2.T) dz1 da1 * (self.z1 0) # ReLU 导数 self.dW1 np.dot(x.T, dz1) / batch_size self.db1 np.sum(dz1, axis0) / batch_size def update(self): self.W1 - self.lr * self.dW1 self.b1 - self.lr * self.db1 self.W2 - self.lr * self.dW2 self.b2 - self.lr * self.db2 def train_step(self, x_batch, y_onehot): output self.forward(x_batch) self.backward(x_batch, y_onehot, output) self.update() # 返回当前 batch 的交叉熵损失与准确率 loss -np.sum(y_onehot * np.log(output 1e-8)) / x_batch.shape[0] pred np.argmax(output, axis1) true np.argmax(y_onehot, axis1) acc np.mean(pred true) return loss, acc这段代码的关键点在于反向传播的简化写法。当输出层使用 Softmax 且损失是交叉熵时output - y_onehot就是输出层梯度很多人卡在这一步不知道为什么要减成 one-hot。逻辑推导是交叉熵对 Softmax 输入的偏导最终化简为“预测概率减去真实分布”你只需要记住这个优雅的结论不需要每次重新推链式法则。dz1 da1 * (self.z1 0)是 ReLU 的导数前向激活值大于 0 的位置梯度直接穿透否则截断为 0。权重初始化用了 He 初始化标准差取sqrt(2/输入维度)这是为 ReLU 准备的如果你用 Sigmoid初始化应换成 Xavier否则第一层梯度容易过早消失。3.3 训练循环与超参数学习率、batch size、epochs 怎么设有了上面的类训练循环本身只有几行但它决定了整个网络能不能收敛。我一般会先固定一个较小的测试配置跑通代码后再调参。下面给出一个可直接运行的训练脚本包含 mini-batch 切分和每个 epoch 结束后的验证输出。def to_onehot(labels, num_classes10): onehot np.zeros((labels.shape[0], num_classes)) onehot[np.arange(labels.shape[0]), labels] 1 return onehot model TwoLayerNet(hidden_size64, lr0.05) y_train_onehot to_onehot(y_train) batch_size 64 epochs 30 for epoch in range(epochs): indices np.random.permutation(X_train.shape[0]) total_loss, total_acc 0, 0 num_batches 0 for start in range(0, X_train.shape[0], batch_size): batch_idx indices[start:start batch_size] x_batch X_train[batch_idx] y_batch y_train_onehot[batch_idx] loss, acc model.train_step(x_batch, y_batch) total_loss loss total_acc acc num_batches 1 avg_loss total_loss / num_batches avg_acc total_acc / num_batches if epoch % 5 0 or epoch epochs - 1: # 每 5 个 epoch 在测试集上验证一次 test_output model.forward(X_test) test_pred np.argmax(test_output, axis1) test_acc np.mean(test_pred y_test) print(fEpoch {epoch:3d} | 训练损失 {avg_loss:.4f} | 训练准确率 {avg_acc:.4f} | 测试准确率 {test_acc:.4f})这个循环里我用了随机打乱训练顺序的 mini-batch 梯度下降而不是一次性用全部数据做梯度下降。mini-batch 的 batch size 取 64这是一个常见默认值。为什么不是 1 或 78400batch size 太小梯度噪声大训练不稳定太大每个 epoch 更新次数少收敛变慢且内存占用高。数值上我这里 lr 取 0.05如果你把 lr 设为 1.0第一轮损失就会变成 nan设为 0.00130 个 epoch 可能只到 90% 准确率。经验法则是先用 0.1 跑 5 个 epoch 看损失下降趋势若损失震荡除以 10若下降太慢乘以 3 到 5。epochs 的合理范围在 20 到 50MNIST 用全连接网络超过 50 个 epoch 后验证准确率提升很小反而有轻微过拟合风险。运行这段代码最终测试准确率应该在 0.96 左右这就是你手写 BP 应得的回报。4. 训练与评估从准确率曲线到混淆矩阵的可信度验证4.1 训练集/验证集/测试集划分防止自欺欺人很多人做手写数字识别时只用训练集和测试集训练完直接看测试集准确率然后宣布“成功了”。但有一个常见的翻车点你可能会为了调参反复使用测试集最终让模型“记住”了测试集的信息导致实际推广能力被高估。更规范的做法是切出三份数据训练集、验证集、测试集。训练集用来更新权重验证集用来调超参数比如学习率、隐藏层大小观察是否过拟合测试集只在最终确认模型时用一次用完就不要再碰。X_train, X_temp, y_train, y_temp train_test_split( X, y, test_size0.3, random_state42, stratifyy) X_val, X_test, y_val, y_test train_test_split( X_temp, y_temp, test_size0.5, random_state42, stratifyy_temp) print(f训练集: {X_train.shape}, 验证集: {X_val.shape}, 测试集: {X_test.shape})上面的代码先分 30% 做临时集再对临时集一半做验证集、一半做测试集。这样最终测试集完全独立。我在真实项目中见过不少因为偷看测试集调参最后上线准确率暴跌 3 个百分点的案例。手写数字识别虽然简单但训练集和测试集分布略有差异比如手写风格、扫描噪声如果模型被测试集调过参这种差异会被放大。记住一句血泪经验测试集是考卷不是练习册。4.2 评估指标准确率、损失曲线与错分样本可视化准确率是最直观的指标但只告诉一个数字太单薄。损失曲线能反映训练动态横轴是 epoch 或 batch 数纵轴是损失。如果你把训练损失和验证损失画在同一张图上能一眼发现欠拟合、过拟合、学习率过高导致的震荡。下面给出绘制损失曲线和错分样本的代码。history {train_loss: [], val_loss: [], train_acc: [], val_acc: []} # 在训练循环中每个 epoch 结束后记录验证集指标 for epoch in range(30): # ... 省略训练步骤 ... val_output model.forward(X_val) val_loss -np.sum(to_onehot(y_val) * np.log(val_output 1e-8)) / X_val.shape[0] val_acc np.mean(np.argmax(val_output, axis1) y_val) history[train_loss].append(avg_loss) history[val_loss].append(val_loss) history[train_acc].append(avg_acc) history[val_acc].append(val_acc) plt.figure(figsize(10, 4)) plt.subplot(1, 2, 1) plt.plot(history[train_loss], labeltrain_loss) plt.plot(history[val_loss], labelval_loss) plt.xlabel(epoch) plt.ylabel(loss) plt.legend() plt.subplot(1, 2, 2) plt.plot(history[train_acc], labeltrain_acc) plt.plot(history[val_acc], labelval_acc) plt.xlabel(epoch) plt.ylabel(accuracy) plt.legend() plt.tight_layout() plt.show()如果训练损失持续下降、验证损失却在某个 epoch 后开始上升说明训练集过拟合此时应该考虑正则化、增大数据量或降低模型容量。只有验证损失和训练损失同时下降才代表模型真正在学习规律。错分样本可视化同样值得做随机找几个被预测错的样本打印真实标签和预测标签把图片显示出来。你会发现不少错误是“7”被误判成“1”、笔画过轻或图像旋转太严重。别小看这一步它决定了你要不要上数据增强还是改成 CNN。4.3 保存与复用模型把训练好的参数持久化训练完成后如果每次运行都要重新训练既浪费 CPU 又无法部署。把权重和偏置保存到磁盘是最基本的复用方式。因为我们的模型纯 numpy 实现用 np.savez 保存即可。np.savez(mnist_model.npz, W1model.W1, b1model.b1, W2model.W2, b2model.b2) # 加载模型用于推理 data np.load(mnist_model.npz) W1, b1, W2, b2 data[W1], data[b1], data[W2], data[b2] model.W1, model.b1, model.W2, model.b2 W1, b1, W2, b2 # 单张图片预测函数 def predict_digit(image): # image 是已归一化、拉平的一维数组长度为 784 output model.forward(image.reshape(1, -1)) return np.argmax(output, axis1)[0]保存时建议同时保存训练用的预处理参数比如归一化均值。MNIST 原始像素除以 255 后范围是 0 到 1但有些自定义数据集会额外做均值减法如果不保存均值部署时预处理不一致预测就会完全乱套。我习惯把预处理参数也写进 npz 文件并在文件命名里加一个时间戳或隐藏层大小标记避免模型文件覆盖后找不到对应结构。加载模型后最好用一个全新的测试集样本验证一次确认与热加载模型的准确率一致再拿去给别人用。5. 手写数字识别的避坑与常见问题排查现象、原因与解决5.1 损失不下降学习率过大、激活函数饱和、权重初始化不当现象训练前几个 epoch损失一直停留在 2.3 附近几乎不动或者损失开始下降到某个 epoch 后直接变成 nan。原因2.3 恰好是 10 分类交叉熵在均匀分布下的期望损失说明网络输出一直是近似等概率的权重根本没学到东西。最常见的原因是学习率太大或权重初始化不当。如果 W1 初始化的标准差过大比如直接randn * 1.0大批神经元进入 ReLU 的负数区梯度为 0神经元“死亡”如果学习率太大参数更新后可能发散产生 nan。解决先用np.random.randn(...) * 0.01初始化全部权重把学习率调到 0.01跑 5 个 epoch确认损失从 2.3 降到 1.8 以下。然后换成 He 初始化sqrt(2/n)再把学习率逐步调到 0.1观察收敛速度。如果损失仍然不降检查输入是否归一化到 [0,1]以及标签 one-hot 转换是否正确——我见过有人把 y 直接喂进训练维度不匹配但代码用了 reshape 硬顶过去结果梯度完全乱掉。5.2 训练集准确率高但测试集低过拟合与正则化选择现象训练 30 个 epoch 后训练准确率 0.99测试准确率只有 0.93且验证损失在第 15 个 epoch 后开始上升。原因全连接网络参数总量很大我们的输入是 784 维、隐藏层 64 维、输出 10 维参数量已经超过 5 万而 MNIST 只有 6 万训练样本。网络容量足以“记住”训练集中噪声和特定笔画的细节却无法泛化到新样本。解决最直接的办法是减少隐藏层大小从 64 降到 32观察验证准确率是否反而上升。如果仍然过拟合再考虑 L2 正则化在损失函数上加一项权重平方和但我建议先做数据增强——随机平移、旋转 10 度以内模拟真实手写变化。对于纯 numpy 的实现数据增强手动写也不复杂每张图随机裁剪或添加高斯噪声一个小技巧是先用scipy.ndimage的仿射变换。如果换成 PyTorch可以直接用 torchvision 的 transforms但这些库不是本方案必需的。5.3 预测结果出现大量“6”和“9”混淆数据增强与标签噪声现象模型整体准确率正常但混淆矩阵中“6”与“9”互相误判率明显高于其他数字对肉眼看起来几乎一样的数字被分错。原因MNIST 的“6”和“9”在缩放、旋转后确实很像全连接网络对局部方向的鲁棒性弱。再一个原因是训练数据中这两个数字的辨异性特征没有在像素空间被充分展开。严格来说这不是 bug而是模型容量和先验结构不足。解决一个有效技巧是随机旋转 10 度并轻微缩放我在自己的实现中做了在线增强测试准确率提升约 0.3%。另一个思路是增加隐藏层神经元到 128 或增加一层隐藏层但这会放大过拟合必须配合 dropout。对于“6”和“9”的混淆你还可以做一个后处理在 Softmax 概率中若 max 概率和次大概率之差小于 0.2拒绝预测并标记为“不确定”这在工程上比硬着头皮乱猜更有价值。5.4 代码运行极慢矩阵维度错误与 Python 循环陷阱现象训练一个 epoch 要几十秒甚至几分钟随着 epoch 增加越来越慢最后和别人的实现速度相差几个数量级。原因新手最容易踩的坑是把矩阵运算写成 Python for 循环比如每个样本逐条计算权重和而不是用np.dot。还有人在反向传播中反复创建 numpy 数组导致内存分配开销巨大。另一个维度错误是输入 x 形状是 (批量, 784)权重 W1 是 (784, 64)如果误写成np.dot(W1, x)就会报维度不匹配或是因广播而偷偷运行但结果错乱。解决先用一个小批量比如 64 个样本打印每个中间矩阵的 shape维护一个断言前向z1 x W1 b1中x 列数必须等于 W1 行数。运行时优先使用np.dot或避免循环。其次检查是否在更新参数时用了self.W1 - lr * dW1如果是多条语句分开更新numpy 的广播机制可能没问题但如果你写成self.W1 self.W1 - lr * dW1在函数内且没有返回值就不会真正更新这会导致损失永远是 2.3。还一个隐藏性能杀手是每次迭代都调用to_onehot转换标签应该在训练开始前做一次避免重复创建大数组。6. 进阶技巧把识别网络从 MNIST 迁移到自定义手写数字6.1 图像预处理二值化、去边框、缩放与居中MNIST 数据集中的图片已经做了尺寸归一化和居中但你自己收集或拍摄的手写数字往往是白底黑字、分辨率不一、位置偏斜、带有背景噪声。直接用 MNIST 训练好的模型去预测这种图片准确率会掉得很惨。因此预处理的第一步是将任意图片统一成 MNIST 风格灰度化、二值化、去除孤立噪声点、找到数字的边界框、按长边缩放到 20x20、再放到 28x28 画布的中心。import cv2 import numpy as np def preprocess_custom_image(image_path): # 读取为灰度图再反色使数字为白、背景为黑与 MNIST 保持一致 img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) img cv2.bitwise_not(img) # 二值化thresh 可调常用 Otsu 自动确定阈值 _, img cv2.threshold(img, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 找到数字的轮廓并裁剪到边界框 coords np.column_stack(np.where(img 0)) y_min, x_min coords.min(axis0) y_max, x_max coords.max(axis0) cropped img[y_min:y_max 1, x_min:x_max 1] # 保留长宽比缩放避免数字变形 h, w cropped.shape scale 20.0 / max(h, w) new_h, new_w int(h * scale 0.5), int(w * scale 0.5) resized cv2.resize(cropped, (new_w, new_h), interpolationcv2.INTER_AREA) # 放入 28x28 纯黑画布中心 canvas np.zeros((28, 28), dtypenp.uint8) y_offset (28 - new_h) // 2 x_offset (28 - new_w) // 2 canvas[y_offset:y_offset new_h, x_offset:x_offset new_w] resized # 归一化到 [0,1]对应模型的输入格式 return canvas.astype(np.float32) / 255.0这里反色操作很容易忽略。MNIST 原图的背景是黑色数字是白色而普通扫描件往往是白纸黑字。如果图省事直接丢给模型模型会把背景当成主成分预测结果几乎会变成随机的。缩放时用cv2.INTER_AREA而不是默认的线性插值因为插值方式会对细小笔画产生锯齿影响预测。居中时如果new_h是奇数(28 - new_h) // 2会略微偏左可以先判断奇偶再调整但这个偏差对识别影响很小可以忽略。6.2 用 OpenCV 或 PIL 从一张图里提取多个数字实际手指写出来的往往是一整行数字而不是一张图一个数字。这时候需要把每个数字从图片中切开。最简单的做法是连通域分析二值化后找到所有轮廓过滤掉尺寸过小或过大的轮廓然后按 x 坐标排序。下面给出基于 OpenCV 的切割函数。def split_digits_from_image(image_path): img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) _, binary cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) # 找轮廓RETR_EXTERNAL 只取外层边界 contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) digit_boxes [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) # 过滤噪声面积大于 20高宽比在 0.2 到 3 之间避免把噪声或连笔笔画当数字 area w * h if area 20 and 0.2 h / w 3: digit_boxes.append((x, y, w, h)) digit_boxes.sort(keylambda box: box[0]) # 按 x 坐标排序 digits [] for x, y, w, h in digit_boxes: # 向外扩 2 像素避免切到笔画边缘 pad 2 x1, y1 max(0, x - pad), max(0, y - pad) x2, y2 min(img.shape[1], x w pad), min(img.shape[0], y h pad) digit_img binary[y1:y2, x1:x2] # 再调用上一节的 preprocess_custom_image 对裁剪图做归一化 digits.append(preprocess_custom_image_from_array(digit_img)) return digits这段代码里的THRESH_BINARY_INV把白色背景变成黑色数字变成白色和 MNIST 一致。过滤条件area 20能去掉摄像头噪点但如果数字很小可以调低到 10。h/w的上下限主要是防止把一条长竖线当成“1”的完整轮廓后又把其他字符粘连进去。如果你的图片中存在两个数字连笔轮廓把它们连成一个整体此时需要更复杂的投影切割法但我建议先在数据采集时尽量让数字分开比算法更可靠。6.3 重训练策略用 MNIST 预训练权重微调还是从零开始迁移到自定义数字时你有两条路一是直接利用 MNIST 训练好的权重做推理二是把自定义数据集和 MNIST 混起来重新训练。我实际测试下来的经验是如果你的自定义样本只有几十张直接推理并不可靠最优方案是收集至少 200 到 500 张每类样本用 MNIST 预训练的权重作为初始值冻结前几层只微调最后一层。但是我们的全连接网络只有两层层数太少冻结第一层后剩余层参数很少意义并不大不如直接用自定义数据从头训练。这里给你一个更实用的建议把 MNIST 的 6 万张图作为预训练数据然后把自定义的几百张图按 7:3 划分训练集和验证集将两者混合后按 9:1 的样本比例重新训练全连接网络。因为自定义样本占比少学习率要比训练 MNIST 时更低比如 0.01且增加正则化避免小样本过拟合。如果你对精度有更高要求建议此时切换到 PyTorch用预训练的 ResNet18 或 LeNet 做微调热词榜里的“pytorch 手写数字识别”就是在这一步发力。但核心概念你已经用手写网络打通了换框架只是换语法。训练结束后建议做一次极端测试把你自己手写的数字拍照用上面的预处理流程走一遍观察错误样本。你会发现自己写的“0”和“6”因为起笔位置不同很可能会被误判这是全连接网络的极限。我会在数据增强里加入随机旋转来缓解但更根治的办法是改用 CNN 并加大训练集。希望帮到你这条从手写 BP 到自定义数字识别的路走通一次后面学任何神经网络都会轻松不少。本文还有配套的精品资源点击获取
网站建设高端定制企业官网