简介:这份资源面向Python机器学习初学者与神经网络入门者,用纯Python实现手写数字识别,帮助理解从数据加载、模型训练到预测评估的完整流程。压缩包共7个文件,包括1个核心源码load_mnist.py、5张示例图片及1份说明文档,整体仅158KB,轻量易用。load_mnist.py负责读取MNIST数据集并完成预处理,代码结构清晰、注释简明,便于对照学习反向传播、权重更新等关键原理;示例图片展示了不同手写数字的输入形态,配合README可快速复现实验并观察识别效果。目前已有133人学习使用,对希望动手实践神经网络、摆脱纯理论阅读的初学者来说,是一份精简且可直接运行的参考资料。
1. 手写数字识别:先想清楚这是一个分类问题
如果给你一张白底黑字的数字图片,让你写程序判断它是 0 到 9 里的哪一个,直觉做法是拿它和模板逐像素比对。但这套思路很快翻车:同样是 7,有人带横杠,有人带斜线,有人写到一半拐个小弯,全局像素相似度一算全乱。基于Python实现神经网络算法识别手写数字集,本质上是在做一个 10 分类的统计模型,让程序从大量样本里自动学出“哪些笔画组合更像哪个数字”,而不是背模板。这个项目是入门神经网络最稳的一步:数据量适中、任务单一、效果可量化,问题不大,却把数据读取、前向传播、反向传播、参数调优和验证这几条主线全走了一遍。适合刚读完 Python 基础、想理解神经网络内部发生了什么的人;也适合已经用 PyTorch 跑过 MNIST、但想回头补“权重到底怎么更新”的熟手。
2. 手搓一个前馈神经网络:从 MNIST 读取到反向传播最小实现
2.1 用 Python 解析 MNIST 二进制:先从 28×28 像素说起
MNIST 的标准发布包是四个二进制文件,没有 CSV 那么好读。train-images-idx3-ubyte 前 16 个字节是文件头,依次是魔数、样本数、行数、列数,全部是大端序;真正的像素数据从第 17 个字节才开始。train-labels-idx1-ubyte 稍微简单些,前 8 个字节是魔数和标签数,后面每个字节就是一个 0 到 9 的标签。
我一般会把这两个解析函数直接写在项目里,不引第三方库。下面这段代码是我常用的最小实现:
import numpy as np def load_mnist_images(path): with open(path, "rb") as f: data = np.frombuffer(f.read(), dtype=np.uint8) magic = int.from_bytes(data[:4].tobytes(), "big") # 校验用,不是 2051 说明文件不对 count = int.from_bytes(data[4:8].tobytes(), "big") # 样本数 rows = int.from_bytes(data[8:12].tobytes(), "big") # 行数,MNIST 里是 28 cols = int.from_bytes(data[12:16].tobytes(), "big") # 列数,MNIST 里是 28 pixels = data[16:].reshape(count, rows, cols) return (pixels / 255.0).astype(np.float32) def load_mnist_labels(path): with open(path, "rb") as f: data = np.frombuffer(f.read(), dtype=np.uint8) return data[8:].astype(np.int64) # 前 8 字节是魔数 + 标签数这段代码的关键在于用int.from_bytes(..., "big")明确指定大端序。x86 机器默认小端,直接拿np.frombuffer去读会把文件头读反,数据维度全乱。pixels / 255.0这一步不是可有可无,它把 0 到 255 的灰度压到 0 到 1,避免后面 softmax 的指数运算在数值上溢出。调用时把四个文件解压到 data 目录下,两行就能拿到训练集:
train_images = load_mnist_images("data/train-images-idx3-ubyte") train_labels = load_mnist_labels("data/train-labels-idx1-ubyte")这里训练集是 60000 张图,每张 28×28,所以train_images.shape是(60000, 28, 28)。神经网络输入层要展开成 784 维向量,后面前向传播时再reshape。
2.2 前向传播:784 维输入经过隐藏层输出 10 个概率
我们要做的是一层隐藏层的前馈神经网络。隐藏层用 sigmoid 激活,输出层用 softmax,因为手写数字是 10 类互斥的识别问题,softmax 天然保证输出加起来等于 1,语义上就是“模型认为这张图是每个数字的概率”。网络初始化用正态分布乘以sqrt(1 / input_size),目的是让加权和经过 sigmoid 之前不会早早进入饱和区。
class ShallowNN: def __init__(self, input_size=784, hidden_size=128, output_size=10, lr=0.5): # 权重标准差控制在输入维度的平方根量级,防止前向结果过大或过小 self.W1 = np.random.randn(input_size, hidden_size) * np.sqrt(1.0 / input_size) self.b1 = np.zeros(hidden_size) self.W2 = np.random.randn(hidden_size, output_size) * np.sqrt(1.0 / hidden_size) self.b2 = np.zeros(output_size) self.lr = lr def sigmoid(self, x): return 1.0 / (1.0 + np.exp(-np.clip(x, -500, 500))) def softmax(self, x): x = x - x.max(axis=-1, keepdims=True) # 减最大值,防止 exp 溢出 e = np.exp(x) return e / e.sum(axis=-1, keepdims=True) def forward(self, x): if x.ndim > 2: x = x.reshape(x.shape[0], -1) self.z1 = x @ self.W1 + self.b1 self.a1 = self.sigmoid(self.z1) self.z2 = self.a1 @ self.W2 + self.b2 self.p = self.softmax(self.z2) return self.psigmoid里的np.clip(x, -500, 500)是防御性写法。输入极端时np.exp(-x)可能下溢成 0 导致除零,clip 之后至少分母不会变成 0。softmax里先减x.max()是标准做法,比如某张图的 z2 向量是[100, 99, 98, ...],不减最大值直接算 exp 会得到无穷大。
参数约定也要说清楚:W1的形状是(784, 128),意味着把 784 维输入映射到 128 维隐藏层;W2的形状是(128, 10),把隐藏层映射到 10 个类别。行是当前层维度,列是下一层维度,后面反向传播的梯度形状和它保持一致。如果你把转换方向反了,代码散成一片,排查起来非常难受。
2.3 反向传播与参数更新:softmax 交叉熵在三个式子里的落点
反向传播是整个项目最劝退的环节,实际上只需要三组梯度。输出层的推导结果是漂亮的:如果输出层是 softmax,损失是交叉熵,那么输出层的残差就是p - y,其中p是模型预测概率,y是 one-hot 标签。这个结论可以直接用,不用每次都从链式法则硬推。
def compute_gradients(self, x, y_onehot): # 输出层残差:交叉熵对 z2 的导数,推导结果就是 p - y delta2 = self.p - y_onehot grad_W2 = self.a1.T @ delta2 / x.shape[0] grad_b2 = delta2.mean(axis=0) # 隐藏层残差:先沿 W2 把误差传回来,再乘上 sigmoid 导数 delta1 = (delta2 @ self.W2.T) * self.a1 * (1 - self.a1) grad_W1 = x.reshape(x.shape[0], -1).T @ delta1 / x.shape[0] grad_b1 = delta1.mean(axis=0) return grad_W1, grad_b1, grad_W2, grad_b2 def update(self, grads): self.W1 -= self.lr * grads[0] self.b1 -= self.lr * grads[1] self.W2 -= self.lr * grads[2] self.b2 -= self.lr * grads[3]delta2 = p - y是最值得记住的一行。如果你换成 MSE 损失,输出层残差会变成(p - y) * p * (1 - p),多乘了一个激活导数,训练慢很多。隐藏层的self.a1 * (1 - self.a1)是 sigmoid 的导数,它最大只有 0.25,所以经过隐藏层时梯度天然会缩小。这也解释了为什么网络加深以后训练变难,不是代码写错,是梯度本身就在指数级衰减。
每个梯度除以x.shape[0]是在做批量平均,这样损失和梯度不随 batch size 变化而大幅缩放。更新时W -= lr * grad就是最朴素的梯度下降,没有任何动量,没有自适应学习率,先跑通再谈优化器。
2.4 训练一个 epoch:批大小、shuffle 与 loss 统计
训练循环看似简单,但里面藏着两个影响收敛的细节:先 shuffle 再分批,loss 取整个 epoch 的平均。随机打乱顺序可以避免模型因为数据排列规律学到错误的短期关联;分批计算则让每次参数更新看到的都只是一部分样本,梯度带噪声,反而更容易跳出坏的局部区域。
def train_one_epoch(model, images, labels, batch_size=64, shuffle=True): idx = np.arange(len(images)) if shuffle: np.random.shuffle(idx) total_loss = 0.0 for start in range(0, len(idx), batch_size): batch_idx = idx[start:start + batch_size] xb = images[batch_idx] yb = np.eye(10)[labels[batch_idx]] # one-hot 编码 probs = model.forward(xb) # 加 1e-12 防止 log(0),括号里不要漏 loss = -np.sum(yb * np.log(probs + 1e-12)) / batch_size model.update(model.compute_gradients(xb, yb)) total_loss += loss return total_loss / max(1, len(idx) // batch_size)np.eye(10)[labels[batch_idx]]这一行把标签批量转成 one-hot,例如标签 3 变成[0,0,0,1,0,0,0,0,0,0]。损失是交叉熵,probs是前向输出概率,它和yb逐元素相乘后求和,模型预测得越离谱,loss 越大。
配套一个简单的评估函数,统计准确率:
def evaluate(model, images, labels, batch_size=256): correct = 0 for start in range(0, len(images), batch_size): end = min(start + batch_size, len(images)) probs = model.forward(images[start:end]) pred = np.argmax(probs, axis=1) correct += np.sum(pred == labels[start:end]) return correct / len(images)到此,模型已经能跑通一轮训练。用默认参数跑 10 个 epoch,训练集准确率通常在 95% 到 98% 之间,具体数值受初始化种子和 shuffle 顺序影响。跑不出这个量级,问题多半不在代码,而落在下一章要展开的参数设置上。
3. 训练阶段的三个必调参数:学习率、批大小与隐藏层宽度
3.1 学习率从 0.5 起步:手写梯度下降不配用太小的学习率
用 PyTorch 或 TensorFlow 时,默认学习率常常是 0.001 或 0.01,因为自带 Adam 这类自适应优化器。但我们现在手写的是普通梯度下降,权重更新完全靠W -= lr * grad,没有动量,也没有自适应缩放。这时候学习率设成 0.01,十个 epoch 可能只让 loss 从 2.30 降到 2.28,几乎看不出变化。
我一般会把初始学习率设在 0.5 到 1.0 之间。原因是刚才初始化时把权重标准差压到了sqrt(1 / input_size)量级,隐藏层加权和的规模大致在 1 附近,sigmoid 处于中间段,梯度既不过大也不过小。学习率 0.5 在这个前提下的更新步长是合适的,loss 下降曲线会看到明显的波形。
for epoch in range(10): lr = max(0.05, 0.5 * 0.95 ** epoch) # 每轮衰减 5%,下限 0.05 model.lr = lr avg_loss = train_one_epoch(model, train_images, train_labels, batch_size=64) acc = evaluate(model, train_images[:2000], train_labels[:2000]) print(f"epoch {epoch + 1}: loss={avg_loss:.4f}, acc={acc:.3f}")这里每轮把学习率乘 0.95,是手写版常见的“学习率衰减”。前期大步快跑进入正确区域,后期小步收敛减少震荡。你可以先固定 0.5 跑一轮,再把衰减打开跑一轮,对比 loss 曲线会发现后期明显平稳。如果训练中 loss 不降反升,第一件要查的事就是学习率,而不是初始化。
3.2 批大小 64 是性价比起点:梯度噪声反而是好事
批大小的影响比学习率更隐蔽,因为它不直接出现在更新公式里,而是通过梯度质量起作用。批大小等于 1 时,每个样本都产生一次更新,梯度噪声最大,训练曲线非常抖;批大小等于 65536 时,梯度几乎等于全量数据梯度,更新很稳,但 10 个 epoch 里只能更新不到 10 次,模型根本走不远。
我通常建议在 16 到 128 之间试一圈,固定其他参数,对比同一个 epoch 的 loss:
for bs in [1, 16, 64, 256]: model = ShallowNN(input_size=784, hidden_size=128, output_size=10, lr=0.5) for _ in range(5): train_one_epoch(model, train_images, train_labels, batch_size=bs) acc = evaluate(model, val_images, val_labels) print(f"batch_size={bs}, val_acc={acc:.3f}")实验做完通常会看到两条规律:批大小 1 时 loss 波动大,但前期下降最快,容易在后期卡在准确率上不去的位置;批大小 256 时 loss 曲线平滑,但 5 个 epoch 里下降幅度有限。64 是两者之间最省心的位置,梯度噪声够用,更新次数也足够多。如果你的机器内存吃紧,选 64 还有一个好处:784 维输入乘 128 维权重只在单批内计算,内存占用可以忽略。
批大小真正要和你绑定的参数是学习率。批越大,梯度越平滑,越可以用稍微大一点的学习率;批越小,梯度越吵,学习率还大的话容易直接在损失面边缘乱蹦。所以调参顺序一般是先定批大小,再调学习率,最后才动网络宽度。
3.3 隐藏层 128 是默认值:宽了不一定涨点
隐藏层宽度决定模型的表达能力。128 个神经元意味着参数总量大约是 784×128 加 128×10,约 10 万出头,这个规模对 60000 张训练图来说是小意思。宽度加到 512,参数量直接翻四倍到 40 万以上,对训练集的表现通常更好,但对验证集的改善会越来越小,甚至出现回退。
| 隐藏层宽度 | 参数量(约) | 训练集表现 | 验证集风险 |
|---|---|---|---|
| 32 | 2.5 万 | 容易欠拟合,学不动 | 准确率偏低 |
| 128 | 10 万 | 5 到 10 epoch 能到 95% 以上 | 比较稳 |
| 512 | 40 万 | 训练集涨得快 | 可能过拟合 |
| 1024 | 80 万 | 训练集接近满分 | 需要早停或正则化 |
这里要特别提醒:隐藏层宽度不是越大越好。宽度变大后,模型能记住训练集中的噪声和个别笔画像素,但这些“记忆”对没见过的字没有帮助。一个健康的训练曲线是训练集和验证集同步上升,一旦训练集继续涨而验证集停在原地,就说明模型开始背题了。用手写代码做实验时,最省事的正则化手段就是加验证集早停,而不是一开始就引入 dropout。
4. 反向传播不收敛的排查:4 个踩坑记录
4.1 输入没归一化:Loss 第一轮就是 nan
现象:代码逻辑看起来都对,但第一次打印 loss 就是nan,准确率稳定在 0.1 左右,怎么调学习率都没用。
原因:图片像素直接用 0 到 255 的整数输入。784 维输入全乘上权重,哪怕每个权重只有 0.03,加权和也可能到几十的规模。软max 做exp运算时指数项直接溢出成无穷大,分母无穷大,概率变成 0,交叉熵里出现log(0),于是 loss 成了nan。反向传播拿到nan后更新权重,网络整个废掉。
解决:在数据加载阶段就把像素除以 255,让输入落在 0 到 1 区间。如果想更稳定,可以做标准化,用 MNIST 上常用的两个统计量把数据压到均值为 0、标准差为 1 的分布:
images = images.astype(np.float32) / 255.0 images = (images - 0.1307) / 0.3081这种做法在 PyTorch 里对应transforms.Normalize,手写版就把这两行放在 load 之后。标准化之后输入会有负值,sigmoid 和 softmax 都接受任意实数输入,没问题。重新跑训练,loss 会从约 2.3 开始下降,因为初始概率大致是均匀的 10 类。
这类问题有个特征:它出现得非常早,第一行日志就能看出来。训练集上看到 nan,先别怀疑反向传播公式写错,先检查输入数据是不是混进了异常值。
4.2 输出层硬套 sigmoid:准确率卡在 80%,2 和 7 互相抢
现象:训练能跑,loss 也在降,但准确率在 79% 到 81% 之间长时间不上涨。观察错误样例发现,2 经常被认成 7,7 也经常被认成 2。
原因:有人图省事,输出层用了 sigmoid,损失函数用均方误差。sigmoid 的输出节点是互相独立的,它把多分类问题当成 10 个独立的二分类问题来解。某张 7 的图,输出节点 7 的概率是 0.8,节点 2 的概率是 0.6,均方误差算起来损失不高,但 argmax 会把图判成 7 还是 2,完全取决于两个节点之间的细微差,模型根本没有“这 10 个类里只能选一个”的约束。
解决:把输出层改成 softmax,损失函数改成交叉熵。具体到代码,就是前向传播里最后一行从sigmoid(z2)换成softmax(z2),损失从np.mean((probs - y) ** 2)换成:
probs = model.forward(xb) loss = -np.sum(yb * np.log(probs + 1e-12)) / batch_size这个改动看起来只是换了两个函数,但优化的几何完全不同。交叉熵会让模型把正确类别的概率往上推,同时把错误类别的概率往下压,而且梯度对“分类边界上的样本”更敏感。改完以后再跑,准确率大概率会直接跳到 90% 以上。
4.3 学习率不衰减:验证集准确率来回跳
现象:loss 曲线前几个 epoch 降得很快,后面不仅不降,还在一个区间里反复震荡。验证集准确率今天是 94%,明天(重新训练)变成 88%,同一套代码两次结果差很多。
原因:学习率固定 0.5 太大。前期网络离局部最优远,大步长没问题;后期网络靠近谷底,每一步都迈过谷底跳到另一边,参数始终在最优值附近来回摆。加上批量梯度本身有噪声,摆动幅度被放大,最终准确率取决于最后一次更新踩在哪,于是每次训练结果都漂。
解决:在训练循环里做学习率衰减,这是我在这类手写网络里最常用的后悔药方案。
initial_lr = 0.5 for epoch in range(15): model.lr = initial_lr * (0.9 ** epoch) # 每轮打九折 train_one_epoch(model, train_images, train_labels, batch_size=64)衰减系数 0.9 是让学习率每轮降到原来的 90%,到第 10 轮时已经只剩初始值的 35% 左右。你也可以用第 3 章里那种带下限的衰减,总之核心是“后期必须小步走”。如果你的训练已经跑完才发现固定学习率震荡,也可以把最后几轮的梯度手动变小,但最省事的是从一开始就写进循环。
4.4 隐藏层 512 训练集 97%,验证集只有 88%:过拟合不是玄学
现象:隐藏层从 128 换成 512 后,训练集准确率蹭蹭往上涨,10 个 epoch 能到 97%,但验证集准确率反而比 128 神经元还低,只有 88% 左右。
原因:模型容量太大,参数超过 40 万,训练样本里的笔画噪声也被当成规律学进去了。输出层对训练集样本形成某种“记忆”,换一张写字风格略微不同的图,这种记忆就失效。这是过拟合的典型表现,尤其在只跑训练集不看验证集的时候最容易漏掉。
解决:把数据划出验证集,每轮都看一眼验证准确率,训练集涨而验证集停的时候做早停。更简单的手段是退回到 128 或 64 的隐藏层宽度。加入 dropout 也可以,但手写版里实现 dropout 要改前向、反向两处,会引入更多调试成本。先用早停撑住,等模型跑稳了再考虑正则化。
best_val_acc = 0.0 patient = 0 for epoch in range(30): train_one_epoch(model, train_images, train_labels, batch_size=64) val_acc = evaluate(model, val_images, val_labels) if val_acc > best_val_acc: best_val_acc = val_acc patient = 0 else: patient += 1 if patient >= 3: break这个“连续三轮验证集不刷新就停”的规则简单有效,专门对付过拟合和训练后期的无效抖动。新手在 MNIST 上最容易犯的错就是只看训练集数字,觉得越高越好,实际上验证集才是模型真实水平的温度计。
5. 让模型去认你手写的新图:预处理四个边界与一个排错习惯
5.1 真实图片的第一步:灰度、反色与去背景
MNIST 的训练集图片是黑底白字的 28×28 像素图,像素值 0 是背景,255 是笔画。而你手机拍出来的数字通常是白底黑字,尺寸可能是 3000×2000。直接丢进模型前必须做三件事:转灰度、反色、归一化。转灰度是因为模型输入只有一维亮度信息,彩色通道会引入与数字无关的噪声;反色是因为模型学到的规律是“亮像素代表笔画”,白底黑字如果不反色,笔画反而变成了背景。
from PIL import Image def preprocess_image(path, invert=True): img = Image.open(path).convert("L") # 转灰度,28x28 尺寸统一后面做 arr = np.array(img, dtype=np.float32) if invert: arr = 255.0 - arr # 白底黑字 -> 黑底白字 arr = arr / 255.0 return arrinvert参数记得做成开关。如果你采集的数据是黑底白字,反色反而会把前景背景弄反,识别率断崖式下跌。我自己踩过一次:用一个夜间模式拍图的摄像头取数据,没关反色,模型把几乎所有数字都认成了 0,因为整个画面反转后全都变成了笔画像素。
5.2 等比缩放然后贴到 28×28:为什么不是直接拉伸
有了灰度图还不够,还要把数字从图片中间抠出来,缩放到 28×28。很多人直接用resize((28, 28)),这是最常见的翻车点。如果一张图的数字偏左,另一张偏右,直接拉升会让笔画位置在 28×28 画布里乱飘,而模型对位置非常敏感。
正确做法是先找到笔画包围盒,裁剪出数字区域,再等比缩放,最后贴到 28×28 画布中央。这样数字大概率落在画布中央,和 MNIST 训练集的分布更接近。
def crop_and_resize(arr, long_side=20): ys, xs = np.where(arr < 0.5) # 前景像素是笔画,值靠 0 if len(ys) == 0: return np.zeros((28, 28), dtype=np.float32) y0, y1, x0, x1 = ys.min(), ys.max(), xs.min(), xs.max() cropped = arr[y0:y1 + 1, x0:x1 + 1] rows, cols = cropped.shape scale = long_side / max(rows, cols) # 长边缩放到 20 像素 new_r, new_c = max(1, int(rows * scale)), max(1, int(cols * scale)) resized_np = np.array( Image.fromarray((cropped * 255).astype(np.uint8)).resize( (new_c, new_r), Image.BILINEAR ), dtype=np.float32, ) / 255.0 canvas = np.zeros((28, 28), dtype=np.float32) top = (28 - new_r) // 2 left = (28 - new_c) // 2 canvas[top:top + new_r, left:left + new_c] = resized_np return canvas这里有两个边界参数值得细说。long_side=20是长边目标长度,我刻意不让数字顶满 28 像素,而是留出上下左右的空白边。MNIST 训练集里的数字普遍没有顶满整个画布,四周留一点呼吸空间反而更像训练集数据。第二个是arr < 0.5的判断,如果背景没有洗干净,写字纸的底色带灰,阈值就选不稳,数字可能连着一大片灰边被一起裁进包围盒。
5.3 自写数字冒烟测试:按文件名前缀批量验证
预处理写完别急着看单张效果,先建一个小批量自写数据集测通过率。我的做法是准备一二十张自己写的数字图,文件名按照“真实值_日期.png”命名,比如7_20250112.png,然后用 glob 批量预测。
import glob correct, total = 0, 0 for path in glob.glob("my_digits/*.png"): arr = preprocess_image(path) canvas = crop_and_resize(arr) probs = model.forward(canvas.reshape(1, 784))[0] pred = int(np.argmax(probs)) true = int(path.split("/")[-1].split("_")[0]) total += 1 correct += (pred == true) print(f"{path} => 预测 {pred}, 真实 {true}, 置信度 {probs[pred] * 100:.1f}%") print(f"通过率: {correct / total:.0%}")按文件名解析真实值,省去手动打标。通过率 100% 不代表模型真的强,要先确认置信度不是 30% 这种“矬子里拔大个”的结果。我建议不光看通过率,还要看那些没通过的图到底长什么样,往往问题不在模型权重,而在预处理。
5.4 识别失败先看预处理后的图:图形排错习惯
第 5 章最容易犯的错是模型一不对就重训,其实多数时候是图没处理好。比如一张数字 1 写得特别细,缩放后笔画只有 1 到 2 个像素宽,模型看起来就是一条线,和训练集里胖乎乎的 1 差异很大。再比如数字 2 拍歪了,整个数字倾斜 30 度,模型对旋转非常敏感,这种图预处理阶段就要做旋转校正。
我现在的习惯是,任何一张测试图识别失败,先把它打印出来看:
import matplotlib.pyplot as plt plt.figure(figsize=(4, 4)) plt.imshow(canvas, cmap="gray") plt.title(f"pred={pred}, true={true}") plt.axis("off") plt.show()这一步能直接暴露问题:笔画有没有被裁掉、数字是不是偏到角落、背景有没有残留。你看到的是一张 28×28 的灰度图,和模型看到的是同一张图。如果人眼都认不出数字,模型当然认不出,这不是神经网络玄学,是输入数据质量不过关。等图和模型输出对上了,再谈调参和重训。
6. 只打印预测数字不够:把 10 个概率全部输出
模型训练和预处理都跑通后,验证时很多人的习惯是只取np.argmax作为最终结果。这不坏,但会漏掉一个重要信息:模型对这张图到底有多确定。测试一个手写数字时,我建议把前三名的概率一起打出来,配合置信度判断模型是不是在瞎蒙。
def predict_with_confidence(model, img_flat, true_label=None): probs = model.forward(img_flat.reshape(1, -1))[0] top_indices = np.argsort(probs)[::-1][:3] for i in top_indices: print(f"数字 {i}: {probs[i] * 100:.2f}%") if true_label is not None: print(f"真实值: {true_label}") return top_indices[0], probs[top_indices[0]]这个习惯在模型大约 90% 准确率的时候特别有用。比如一张手写 7,模型给了 7 的概率 38%,给了 1 的概率 35%,argmax 是 7,看起来预测正确,但模型其实非常没有把握。这种图就是典型的边界样本,告诉你可以考虑加几张这类风格的训练图,或者做一点旋转增强。
更进一步的验证方法是把一批测试样本的 top1 置信度收集起来画直方图:
import matplotlib.pyplot as plt confidences = [] for img, label in zip(val_images, val_labels): probs = model.forward(img.reshape(1, -1))[0] confidences.append(probs[np.argmax(probs)]) plt.hist(confidences, bins=50, range=(0, 1)) plt.xlabel("top1 probability") plt.ylabel("sample count") plt.show()如果直方图在 0.4 到 0.6 之间堆了一大波,说明大量样本是模型靠矬子里拔大个蒙对的,模型对笔画多样性还没学好。这时候别继续加轮次,回去做数据增强,比如给训练样本加一点随机平移和旋转。我已经习惯每次验证都顺手打印前三名概率和置信度分布,它能帮我快速分清问题出在模型容量、数据分布还是预处理阶段。希望帮到你,动手把损失和置信度打印出来,比盯着准确率一个数字有效得多。
本文还有配套的精品资源,点击获取