☰
BP神经网络从零实现:手写数字识别与反向传播精讲
2026/10/31 9:40:26 网站建设 项目流程

简介:面向本硕博及科研人员,提供一套基于BP神经网络的手写数字识别MATLAB实现。模型以MNIST为数据库,网络结构与训练过程自行编码,不依赖工具箱,适合深入理解反向传播的算法学习者研习。压缩包共9个文件,主体为5个M脚本,覆盖网络初始化、sigmoid/dsigmoid激活、训练与测试等环节;另有2个MAT数据文件存放MNIST样本与权重,1个AVI操作录屏演示完整运行流程,1个TXT说明辅助排错,整体11.84MB。目前已有732人学习使用。读者可按主脚本运行,结合录屏复现手写数字识别,并通过自编代码掌握从数据加载、前向传播到误差反向更新的完整实现,兼顾教学与二次开发。

1. 不调工具箱的手写数字识别:BP 神经网络从零实现,到底难在哪

把一张 28×28 的灰度图判断成 0~9,很多人第一个想到的不是卷积网络,而是 BP 神经网络——它简单、解释性强,也是课程里第一个要求你亲手推导的模型。这个标题把实现约束在「自己编程实现、不采用工具箱」:不碰现成的神经网络接口,从权重初始化、前向传播、反向传播到梯度更新全部手写。很多人在 MNIST 上见过 97% 的准确率,但被问到梯度怎么回传、参数为什么是 784×128×10 就卡住了。本文用纯 Python + NumPy 把这条完整路径走一遍,覆盖 MNIST 数据处理、网络实现、调参排错与可视化验证,适合课程设计、期末项目以及想彻底搞懂 BP 的入门者。照下面的代码走一遍,比看视频更直接。

2. 784-128-10 的 BP 网络从哪来:激活函数选择与反向传播四公式

2.1 网络结构与参数规模:为什么 784-128-10 是合理起点

MNIST 的每张图片是 28×28 像素,拉平后是一个 784 维向量,这就是输入层节点数。输出层要判别 10 个数字,所以是 10 个节点。隐藏层取多少没有标准答案,我一般先用 128,理由是它对 MNIST 这个任务规模来说足够表达特征,训练速度也可接受。隐藏层再小到 32,准确率会明显掉;再大到 512,单隐藏层表现提升有限,但训练时间和内存占用涨得很快。

参数规模直接用矩阵形状算出来,这是 BP 网络最直观的账:

参数形状数量
输入到隐藏层权重 W1(784, 128)100352
隐藏层偏置 b1(128,)128
隐藏层到输出层权重 W2(128, 10)1280
输出层偏置 b2(10,)10
合计101770

也就是说,训练过程要优化的参数大约 10 万个。对 MNIST 这种相对简单的任务,单隐藏层 MLP 用 BP 训练,测试集准确率通常落在 95%~97.5% 区间。想再往上走,就得换卷积网络或者加深层次,那不是本文讨论的范畴。

2.2 激活函数选型:隐藏层用 ReLU,输出层配 softmax 加交叉熵

几十年前 BP 的经典教材里,隐藏层和输出层都爱用 sigmoid。但在 MNIST 这类真实训练里,隐藏层用 sigmoid 会让你明显感觉到收敛慢,甚至出现梯度消失。原因在于 sigmoid 的导数最大值只有 0.25,误差从输出层一层层往回乘,梯度会指数级缩小。隐藏层换用 ReLU,导数非 0 即 1,梯度穿过隐藏层时不会被压缩,训练要稳得多。ReLU 的缺点是负区间导数恒为 0,学习率过大会让一部分神经元永远不再激活,这个坑在第 5 章会展开。

输出层的选择不太一样。我们需要的是「这张图属于每个数字的概率」,所以用 softmax 把 10 个实数压成总和为 1 的概率分布。损失函数用交叉熵,它和 softmax 配合时,反向传播的输出层误差有一个非常干净的形式:预测概率减 one-hot 标签。这就是反向传播推导里最常用的那个结果。

2.3 反向传播四公式:输出层误差、隐藏层误差、两个权重梯度

用符号把网络写清楚:输入 X 形状为 (m, 784),经过 W1 和 b1 得到 z1,ReLU 激活得到 a1,再经过 W2 和 b2 得到 z2,softmax 后输出概率 out。损失 L 是交叉熵均值。反向传播要计算的就是 L 对四个参数的梯度。

推导结果可以浓缩成四个公式,按计算顺序写:

δ2 = out - y_onehot

δ1 = (δ2 · W2ᵀ) ⊙ (z1 > 0)

dW2 = a1ᵀ · δ2 / m

dW1 = Xᵀ · δ1 / m

其中 ⊙ 表示逐元素相乘,(z1 > 0) 是 ReLU 的导数遮罩,m 是批大小。偏置梯度分别是 δ2 和 δ1 按列求和再除以 m。这四行看起来简单,但初学者最容易在 δ1 的形状上翻车:δ2 是 (m, 10),W2ᵀ 是 (10, 128),乘完是 (m, 128),再和 (m, 128) 的遮罩逐元素乘,形状才对得上。

反向传播对不对,光看推导不够,我习惯用数值梯度验证兜底。有限差分法的思路是:对某个权重 w 加一个极小量 eps,损失变化量除以 2eps,就得到数值梯度。如果解析梯度和数值梯度的最大绝对差在 1e-5 量级,说明代码里的链式法则写对了。

def verify_backward(): rng = np.random.default_rng(0) # 微型网络:输入2维,隐藏3节点,输出2类 W1 = rng.standard_normal((2, 3)) * 0.5 b1 = np.zeros(3) W2 = rng.standard_normal((3, 2)) * 0.5 b2 = np.zeros(2) X = rng.standard_normal((4, 2)) y = np.array([0, 1, 0, 1]) def softmax(x): x = x - x.max(axis=1, keepdims=True) e = np.exp(x) return e / e.sum(axis=1, keepdims=True) def forward(): z1 = X @ W1 + b1 a1 = np.maximum(z1, 0) z2 = a1 @ W2 + b2 return softmax(z2) def loss(): out = forward() return -np.log(out[np.arange(len(y)), y] + 1e-12).mean() # 对 W1 逐元素做数值梯度 eps = 1e-6 grad_num = np.zeros_like(W1) for i in range(W1.shape[0]): for j in range(W1.shape[1]): old = W1[i, j] W1[i, j] = old + eps fp = loss() W1[i, j] = old - eps fm = loss() W1[i, j] = old grad_num[i, j] = (fp - fm) / (2 * eps) # 解析梯度:输出层误差 -> 隐藏层误差 -> dW1 out = forward() z1 = X @ W1 + b1 a1 = np.maximum(z1, 0) delta2 = out - np.eye(2)[y] delta1 = (delta2 @ W2.T) * (z1 > 0) grad_ana = X.T @ delta1 / len(y) print("max diff:", np.abs(grad_num - grad_ana).max())

这段验证脚本的要点是:loss 函数在每次调用时都会用当前 W1 重新走一遍前向,数值梯度循环里先加 eps 算一次、再减 eps 算一次,差值除以 2eps 就是中心差分梯度。解析梯度走的是标准反向传播流程,两者对比只差一个数量级足够小的数值误差,就说明反向传播实现可靠。把这个验证跑通再进入正式训练,能省下后面大量的排查时间。

3. MNIST 数据加载:手写解析 IDX 二进制文件,绕开框架下载的 404 坑

3.1 MNIST 文件格式:魔数、维度、大端字节序

MNIST 官网发布的是四个 gzip 压缩的二进制文件:训练图片、训练标签、测试图片、测试标签。很多人习惯直接用 torchvision 的接口下载,但这条链路最近经常回报 404,换个网络环境就卡住。自己解析 IDX 格式的好处就在这里:手动下载四个 .gz 文件放到本地目录后,代码直接读压缩包里的字节,不依赖任何框架或在线仓库。

IDX 格式的头部规则我觉得比想象中简单,就两组信息需要记。图片文件头部 16 字节:前 4 字节是魔数 2051,接着 4 字节是样本数量,再接着 4 字节行数 28,最后 4 字节列数 28。标签文件头部 8 字节:魔数 2049,然后 4 字节样本数量。所有整数字段都是大端序,即高位字节在前,用 struct 模块的>前缀解析就正好对应。

3.2 自己写解析代码:gzip + struct.unpack 读出手写图片与标签

解析代码我一般放在独立的 load_mnist.py 里,便于复用。核心就是先用 gzip.open 打开压缩文件,按偏移量读头部,再用 np.frombuffer 把剩余的原始字节直接变成 NumPy 数组,全程不需要手动解压。

import gzip import struct import numpy as np def load_mnist(data_dir, kind='train'): """从 MNIST 原始 gz 文件读取数据,kind 为 'train' 或 't10k'""" labels_path = f'{data_dir}/{kind}-labels-idx1-ubyte.gz' images_path = f'{data_dir}/{kind}-images-idx3-ubyte.gz' with gzip.open(labels_path, 'rb') as f: magic, n = struct.unpack('>II', f.read(8)) labels = np.frombuffer(f.read(), dtype=np.uint8) with gzip.open(images_path, 'rb') as f: magic, n, rows, cols = struct.unpack('>IIII', f.read(16)) images = np.frombuffer(f.read(), dtype=np.uint8) images = images.reshape(n, rows * cols) return images, labels X_train, y_train = load_mnist('./data', 'train') X_test, y_test = load_mnist('./data', 't10k') print(X_train.shape, y_train.shape, X_test.shape, y_test.shape)

逻辑说明:labels 文件头是 8 字节,所以struct.unpack('>II', f.read(8))一次读两个大端无符号整数,第一个是魔数,第二个是样本数。图片文件头是 16 字节,所以用四个I读取。读完头部后文件指针正好落在数据区,np.frombuffer 会把这段原始字节按 uint8 直接映射成数组,不需要循环逐像素读取。最后把图片按 (样本数, 784) 的形状展开,方便后面直接做矩阵运算。

3.3 归一化与 one-hot 编码:数据进网络前必须做的两件事

原始像素值是 0~255 的整数,直接喂给网络会让加权求和的结果偏大,softmax 很容易饱和,梯度变得极小。常见做法是先把像素除以 255 缩放到 0~1,再用训练集的全局均值和标准差做标准化。注意均值标准差只能从训练集计算,测试集直接复用同一组数值,不能在测试集上重新统计,否则就引入了数据泄漏。

one-hot 编码是针对标签的。输出层有 10 个节点,标签 3 必须变成 [0, 0, 0, 1, 0, 0, 0, 0, 0, 0] 这样的向量,才能和 softmax 输出做逐元素减法。用 np.eye(10)[y] 一行就能完成,这也是反向传播公式里 out - y_onehot 能成立的前提。

# 归一化:像素值缩放到 [0,1],再做标准化 X_train = X_train / 255.0 X_test = X_test / 255.0 mean = X_train.mean() std = X_train.std() X_train = (X_train - mean) / std X_test = (X_test - mean) / std # 标签 one-hot:形状从 (n,) 变成 (n, 10) y_train_oh = np.eye(10)[y_train] y_test_oh = np.eye(10)[y_test] print(X_train.dtype, X_train.min(), X_train.max())

参数说明:X_train / 255.0会把 uint8 数组自动转换成 float64,后续标准化不会损失精度。X_train.mean()和X_train.std()是对全部 60000 张图片整体统计,得到约 0.1307 和 0.3081,这是 MNIST 的全局特征。标准化后的数据大致落在 [-1.5, 2.5] 区间,输入范围稳定,网络训练不容易出现极端梯度。

4. 自己敲一遍 BP 训练代码:前向、反向与 SGD 更新逐行拆解

4.1 权重初始化:Xavier 均匀分布,避开对称性陷阱

这里需要先澄清「不采用工具箱」的边界:不调用现成的神经网络封装,但用 NumPy 做矩阵运算是合理的,因为我们要手写的是网络结构与学习算法,不是重新实现矩阵乘法。初始化直接用均匀分布,范围按 Xavier 公式取:sqrt(6 / (fan_in + fan_out))的正负区间。这个选择的背后是让每一层的输入输出方差尽量一致,避免信号在传播过程中逐渐放大或消失。

全零初始化是最典型的翻车点。如果 W1 和 W2 都是零,同一层的所有神经元会收到完全相同的输入和梯度,无论训练多少轮都学不到差异化特征。随机初始化后,每个隐藏节点才有机会被不同的局部特征激活。

class BPNet: def __init__(self, n_in=784, n_hidden=128, n_out=10, seed=42): rng = np.random.default_rng(seed) bound1 = np.sqrt(6.0 / (n_in + n_hidden)) bound2 = np.sqrt(6.0 / (n_hidden + n_out)) self.W1 = rng.uniform(-bound1, bound1, (n_in, n_hidden)) self.b1 = np.zeros(n_hidden) self.W2 = rng.uniform(-bound2, bound2, (n_hidden, n_out)) self.b2 = np.zeros(n_out) # 动量缓冲,第一次 update 时延后初始化 self.vW1 = None self.vW2 = None self.vb1 = None self.vb2 = None

参数说明:seed=42固定随机种子是为了让实验结果可复现。bound1 和 bound2 分别控制两层权重的初始化范围,隐藏层输入输出维度差异不大,所以两个范围都在 0.2 左右。偏置一律初始化为零,因为偏置不参与对称性问题,随机初始化偏置反而会带来不必要的偏移。

4.2 前向传播:从 784 维输入到 10 维概率输出

前向传播就是把输入逐层变换。第一层做线性变换加 ReLU,第二层做线性变换加 softmax。注意训练时每次前向都要把中间结果存下来,反向传播要用它们计算梯度。这些缓存不需要额外字典,直接挂在 self 上就行,简单直接。

def forward(self, X): # X: (m, 784),返回概率矩阵 (m, 10) self.z1 = X @ self.W1 + self.b1 self.a1 = np.maximum(self.z1, 0) self.z2 = self.a1 @ self.W2 + self.b2 return self.softmax(self.z2) def softmax(self, x): # 减去每行最大值,防止 exp 溢出 x = x - x.max(axis=1, keepdims=True) e = np.exp(x) return e / e.sum(axis=1, keepdims=True)

逻辑说明:np.maximum(z1, 0)是 ReLU 的向量化实现,负数置零,正数保留。softmax 里先减每行最大值是数值稳定性的关键一步,否则当 logits 里出现较大数值时,np.exp 会直接算出无限大。溢出后输出变成 nan,损失函数也随之崩掉,这是新手最容易踩的坑之一。

4.3 反向传播与梯度更新:四行代码算出全部梯度

反向传播严格按第 2 章推导的公式实现。delta2 就是 softmax 输出减 one-hot 标签,delta1 用 W2 的转置把误差回传到隐藏层,再乘上 ReLU 的遮罩。梯度裁剪我加在权重梯度计算之后,把单次梯度限制在 [-5, 5] 区间,防止个别异常样本把参数一步推出正常范围。

def backward(self, X, y_oh, out): m = X.shape[0] # 输出层误差:softmax 输出减 one-hot 标签 delta2 = out - y_oh # 隐藏层误差:误差回传后乘 ReLU 导数遮罩 delta1 = (delta2 @ self.W2.T) * (self.z1 > 0) # 权重梯度,除以 m 得到平均梯度 self.dW2 = self.a1.T @ delta2 / m self.db2 = delta2.sum(axis=0) / m self.dW1 = X.T @ delta1 / m self.db1 = delta1.sum(axis=0) / m # 梯度裁剪,防止数值爆炸 self.dW1 = np.clip(self.dW1, -5, 5) self.dW2 = np.clip(self.dW2, -5, 5)

逻辑说明:out - y_oh能直接作为输出层误差,是交叉熵损失配合 softmax 的简化结果。(self.z1 > 0)生成布尔遮罩,ReLU 在负区间导数为 0,在正区间导数为 1。X.T @ delta1的维度是 (784, m) 乘 (m, 128),结果正是 (784, 128),和 W1 形状一致。梯度裁剪我放在了存储梯度之后、更新参数之前,这样调试时能看到裁剪前的原始值。

梯度更新采用带动量的 SGD。动量项可以理解为给梯度方向加了一个历史累积,能有效抑制 batch 之间带来的震荡。更新公式是:速度 = 动量 × 旧速度 + 梯度,参数 = 参数 - 学习率 × 速度。

def update(self, lr=0.1, momentum=0.9): if self.vW1 is None: self.vW1 = np.zeros_like(self.W1) self.vW2 = np.zeros_like(self.W2) self.vb1 = np.zeros_like(self.b1) self.vb2 = np.zeros_like(self.b2) self.vW1 = momentum * self.vW1 + self.dW1 self.W1 -= lr * self.vW1 self.vW2 = momentum * self.vW2 + self.dW2 self.W2 -= lr * self.vW2 self.vb1 = momentum * self.vb1 + self.db1 self.b1 -= lr * self.vb1 self.vb2 = momentum * self.vb2 + self.db2 self.b2 -= lr * self.vb2

参数说明:momentum=0.9 是使用最广泛的默认值,含义是保留上一轮 90% 的更新方向,只叠加当前 10% 的新梯度。lr=0.1 配合 batch_size=64 在 MNIST 上是经过验证的稳定组合。如果发现损失震荡,先把学习率降到 0.01;如果收敛太慢,先检查数据是否归一化,而不是盲目调大学习率。

4.4 训练循环与超参数:随机打乱、batch 划分、逐 epoch 评估

训练循环要做三件事:每个 epoch 开始前随机打乱训练集顺序,按 batch_size 切分,每个 batch 走一次前向、反向、更新。每跑完一个 epoch 就在验证集上评估一次准确率,用来判断训练是否在正常推进。这里我把训练集拆成 50000 张训练和 10000 张验证,测试集保持 10000 张不参与任何训练相关决策。

def evaluate(model, X, y): out = model.forward(X) pred = out.argmax(axis=1) return (pred == y).mean() def train(model, X, y, X_val, y_val, epochs=20, batch_size=64, lr=0.1): n = X.shape[0] rng = np.random.default_rng(123) for epoch in range(epochs): perm = rng.permutation(n) X_s, y_s = X[perm], y[perm] for i in range(0, n, batch_size): Xb = X_s[i:i + batch_size] yb = y_s[i:i + batch_size] yb_oh = np.eye(10)[yb] out = model.forward(Xb) model.backward(Xb, yb_oh, out) model.update(lr=lr) val_acc = evaluate(model, X_val, y_val) print(f'epoch {epoch + 1:02d}, val acc: {val_acc:.4f}')

逻辑说明:rng.permutation(n)生成一个打乱的索引数组,用它同时重排 X 和 y,保证图片和标签的对应关系不被打乱。np.eye(10)[yb]在每个 batch 里现场做 one-hot,避免提前转换后索引错位。验证集评估走同一套 forward 流程,但不更新参数,这是验证和训练的本质区别。实际跑下来,20 个 epoch 后验证准确率通常能到 96% 以上,测试集结果和验证集基本一致。

超参推荐值汇总:

参数推荐值说明
隐藏层节点数128MNIST 单隐藏层的性价比选择
epoch20再多提升有限,且开始过拟合
batch_size64梯度估计稳定,训练速度适中
learning_rate0.1配合 momentum 0.9 的稳定起点
momentum0.9抑制 batch 带来的损失震荡

5. 训练避坑指南:把 MNIST 精度从 90% 拉到 97% 的常见问题与排查

5.1 损失先下降后变成 nan:softmax 溢出、学习率过大、梯度爆炸

现象是前几个 epoch 损失正常下降,突然某一步变成 nan,之后所有值都是 nan。原因通常是两处:一是 softmax 没做减最大值的稳定处理,logits 数值稍大就溢出;二是学习率过大导致参数更新步长太大,梯度瞬间爆炸。

解决分两步走。先检查代码里 softmax 是否实现了x - x.max(axis=1, keepdims=True),没有就先补上。然后降低学习率到 0.01 重新训练,同时保留梯度裁剪逻辑。这样处理后 nan 基本不会再出现,如果仍然出现,再打印一下输入数据的 min 和 max,确认归一化步骤真的生效了。

5.2 准确率一直卡在 90% 以下:标签编码错误或数据未归一化

现象是损失在降低,但准确率始终在 70%~90% 之间上不去。最常见的原因是标签处理出了问题,比如直接用整数标签和 softmax 概率做比较,或者 one-hot 编码的行索引写错,导致标签 0 被当成「没有类别」。另一个常见原因是输入像素没有归一化,网络在 0~255 的输入下反复震荡,只能学到粗糙的边界。

解决的排查顺序我一般是这样:先单独打印一组out.argmax(axis=1)和y的前 20 个值,肉眼确认预测值是否在 0~9 范围内分布。再检查np.eye(10)[y]生成的 one-hot 矩阵行和是否为 1。最后确认 X_train 经过预处理后均值接近 0、标准差接近 1。把这三步走完,90% 以下的情况基本能定位。

5.3 训练损失剧烈震荡:batch 太小或学习率太激进

现象是损失曲线像锯齿一样上下跳动,验证集准确率也随之波动。原因可能是 batch_size 降到 16 以下后,单个 batch 的梯度方向噪声太大;也可能是学习率 0.1 对这个网络状态来说偏高,尤其在中后期参数接近局部最优时,过大的步长会让损失反复横跳。

解决先看 batch_size,小于 64 就升回 64。震荡依旧存在的话,把学习率从 0.1 降到 0.03 或 0.01 试试。另一种做法是给训练循环加一个简单的学习率衰减,每 5 个 epoch 把学习率乘 0.8,让训练前期走得快、后期收得稳。

5.4 自绘数字识别不准:训练集和你的手写风格不在同一分布

现象是测试集准确率 96%,但用画图工具写一个数字丢进去,预测结果经常不对。原因是 MNIST 的训练样本是上世纪九十年代收集的手写体,与你现在用鼠标或手机写出来的笔画风格、粗细、位置差异很大。测试集和训练集同分布,所以测试准确率高,但这不代表泛化到任意手写风格。

解决的常见做法是预处理对齐:把自绘图片转灰度,取灰度阈值做二值化,找到笔画的外接矩形,缩放填充到 20×20 像素左右,再居中到 28×28 的画布中心。这一套操作能让自绘数字大致符合 MNIST 样本的空间分布特征。但也要有心理准备,即使预处理做到位,单隐藏层 MLP 的自绘识别率也很难像测试集那么高,这是模型容量本身的边界。

5.5 换随机种子后准确率忽高忽低:单隐藏层 MLP 收敛点不稳定

现象是只改 seed 参数,其他完全不变,两次训练准确率能差 2 到 3 个百分点。原因是单隐藏层网络参数少,不同初始化会收敛到不同局部最优,缺少卷积网络那种更强的特征提取约束。这在 MNIST 上属于正常波动,不属于代码 bug。

解决的办法是用固定 seed 做实验对比,所有调参结论都基于同一随机种子下验证。要真想压波动,可以训练三次取多数投票,或者把隐藏层扩到 256 并加 dropout,代价是训练时间变长。做课程设计时,我通常会固定 seed 并把结果写进报告,同时注明单次运行波动范围,避免被误解为算法不稳定。

6. 把网络内部打开来看:权重可视化、混淆矩阵与自绘图片验证

训练结束后别急着收工,先做一轮可视化验证。第一个习惯是看权重:把 W1 的每一列 reshape 成 28×28,画成灰度图,就能直接看到网络学到了什么。你会发现一部分隐藏节点呈现横线、竖线、斜线或者局部笔画结构,这证明特征提取真的发生了。如果所有权重图都是均匀噪点,说明训练没有充分收敛。

import matplotlib.pyplot as plt W = model.W1.T # 形状 (128, 784) fig, axes = plt.subplots(4, 4, figsize=(8, 8)) for i, ax in enumerate(axes.flat): ax.imshow(W[i].reshape(28, 28), cmap='gray') ax.axis('off') plt.tight_layout() plt.show()

第二个习惯是看混淆矩阵。不用引入 sklearn,自己用循环统计就可以。把预测正确的样本归到对角线上,矩阵里非对角线位置的值就是最容易混淆的数字对。MNIST 里常见的是 4 和 9、3 和 8 互相认错,这是单隐藏层模型在像素特征层面的天然局限。

第三个习惯是拿自绘图片做端到端验证,这也回应了第 5 章的预处理讨论。我这里给出一个最精简的预处理流程:灰度化、反色、裁剪包围盒、缩放居中。经验是反色这一步容易漏,MNIST 背景是黑色、笔画是白色,而很多绘图工具默认是白底黑字,方向反了预测就会出大问题。

from PIL import Image import numpy as np def preprocess_digit(path): img = Image.open(path).convert('L') arr = np.array(img) arr = 255 - arr # 反色,让笔画变白色 arr = arr / 255.0 # 实际使用中还要裁剪、缩放并居中到 28x28 return arr.reshape(1, 784).astype(np.float32)

做可视化不是为了好看,而是给训练结果提供验收依据。我一般把这三张图放进项目报告里:权重图、混淆矩阵、若干错误样本截图。看到权重图里有结构、混淆矩阵集中在合理数字对,就说明 BP 网络确实学会了区分手写数字,而不是靠什么玄学调参碰出来的结果。先可视化再谈优化,这是我在每次训练里都绕不开的一步,也是想清楚网络内部到底发生了什么的最短路径,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询