
简介这份资源面向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(), dtypenp.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(), dtypenp.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_size784, hidden_size128, output_size10, lr0.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, keepdimsTrue) # 减最大值防止 exp 溢出 e np.exp(x) return e / e.sum(axis-1, keepdimsTrue) 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(axis0) # 隐藏层残差先沿 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(axis0) 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_size64, shuffleTrue): 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_size256): 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, axis1) 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_size64) acc evaluate(model, train_images[:2000], train_labels[:2000]) print(fepoch {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 的 lossfor bs in [1, 16, 64, 256]: model ShallowNN(input_size784, hidden_size128, output_size10, lr0.5) for _ in range(5): train_one_epoch(model, train_images, train_labels, batch_sizebs) acc evaluate(model, val_images, val_labels) print(fbatch_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 万以上对训练集的表现通常更好但对验证集的改善会越来越小甚至出现回退。隐藏层宽度参数量约训练集表现验证集风险322.5 万容易欠拟合学不动准确率偏低12810 万5 到 10 epoch 能到 95% 以上比较稳51240 万训练集涨得快可能过拟合102480 万训练集接近满分需要早停或正则化这里要特别提醒隐藏层宽度不是越大越好。宽度变大后模型能记住训练集中的噪声和个别笔画像素但这些“记忆”对没见过的字没有帮助。一个健康的训练曲线是训练集和验证集同步上升一旦训练集继续涨而验证集停在原地就说明模型开始背题了。用手写代码做实验时最省事的正则化手段就是加验证集早停而不是一开始就引入 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 经常被认成 77 也经常被认成 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_size64)衰减系数 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_size64) 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, invertTrue): img Image.open(path).convert(L) # 转灰度28x28 尺寸统一后面做 arr np.array(img, dtypenp.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_side20): ys, xs np.where(arr 0.5) # 前景像素是笔画值靠 0 if len(ys) 0: return np.zeros((28, 28), dtypenp.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 ), dtypenp.float32, ) / 255.0 canvas np.zeros((28, 28), dtypenp.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_side20是长边目标长度我刻意不让数字顶满 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, cmapgray) plt.title(fpred{pred}, true{true}) plt.axis(off) plt.show()这一步能直接暴露问题笔画有没有被裁掉、数字是不是偏到角落、背景有没有残留。你看到的是一张 28×28 的灰度图和模型看到的是同一张图。如果人眼都认不出数字模型当然认不出这不是神经网络玄学是输入数据质量不过关。等图和模型输出对上了再谈调参和重训。6. 只打印预测数字不够把 10 个概率全部输出模型训练和预处理都跑通后验证时很多人的习惯是只取np.argmax作为最终结果。这不坏但会漏掉一个重要信息模型对这张图到底有多确定。测试一个手写数字时我建议把前三名的概率一起打出来配合置信度判断模型是不是在瞎蒙。def predict_with_confidence(model, img_flat, true_labelNone): 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, bins50, range(0, 1)) plt.xlabel(top1 probability) plt.ylabel(sample count) plt.show()如果直方图在 0.4 到 0.6 之间堆了一大波说明大量样本是模型靠矬子里拔大个蒙对的模型对笔画多样性还没学好。这时候别继续加轮次回去做数据增强比如给训练样本加一点随机平移和旋转。我已经习惯每次验证都顺手打印前三名概率和置信度分布它能帮我快速分清问题出在模型容量、数据分布还是预处理阶段。希望帮到你动手把损失和置信度打印出来比盯着准确率一个数字有效得多。本文还有配套的精品资源点击获取