资讯动态

MNIST手写数字识别实战:从零实现两层神经网络的关键步骤

发布时间:2026/10/15 17:06:03 来源:尧图企业网站定制
1. 为什么从 MNIST 开始做手写识别数据集的体量和小鱼书的套路最近不少人都在翻《小鱼书》——就是那本封面画着一条鱼的深度学习入门教程里面第一个正经项目就是 MNIST 手写数字识别。我一开始也有个疑问都什么年代了又不是没有更高级的数据集为什么大家还是愿意从这个 28×28 的小图片开始练手等我真的把一个手写数字识别从头到尾跑通之后才明白它的位置完全没法替代。先说清楚一个容易混淆的点MNIST 的数据内容其实只是 0~9 十个手写数字并不是完整的手写文字——文章标题写成手写文字识别也没错因为它是所有 OCR光学字符识别项目里最基础的那块拼图。你后续无论做身份证号识别、快递面单识别还是试卷客观题批改第一步永远避不开怎么把图片中的一个字符稳定地认出来。MNIST 最有价值的地方在于它的小而全。整个数据集包含 60000 张训练图片和 10000 张测试图片每张图打开之后就是一个 28×28 的灰度矩阵像素值范围从 0 到 255。相比之下如果你一上来就去啃街景门牌号SVHN或者扫描文档数据集图片分辨率、背景复杂度、文字倾斜角度都会把问题成倍放大。新手很可能连数据预处理这一步都没走完就心态崩了。而 MNIST 把噪声压缩到了最低让我可以把精力集中在模型本身上。当时我跟着《小鱼书》的思路目标很清楚不借助任何现成的深度学习框架只用 Python 的标准计算库从零实现一个两层神经网络让它能对手写数字图片做分类。这样做的收益不仅仅是我会调接口了而是把网络内部每一层参数的变化、梯度怎么回传、损失怎么下降全都亲手看了一遍。经历了这一遍后面再上手框架的封装 API基本是畅通无阻的。2. 准备数据时容易忽略的细节归一化、one-hot 和 mini-batch 到底在解决什么问题数据部分的代码通常在项目里是最不起眼的可恰恰是翻车率最高的地方。按照《小鱼书》配套脚本的思路从数据集加载到投入训练之前有几道关键工序必须明确。首先是读取数据的形态。原始 MNIST 图片如果直接解析每张是 (28, 28) 的二维数组。如果输入到全连接层我们需要把二维图像拉平成一行也就是变成 784 个数值。这就是代码里 flattenTrue 的含义。加载之后再确认一下四个数组的形状x_train 是 (60000, 784)t_train 是 (60000,)x_test 是 (10000, 784)t_test 是 (10000,)。这里我只跑了 print 检查形状没有急着建模——这一步看似多余但能帮你提前暴露数据文件没下载全之类的问题。然后是归一化。像素原始取值是 0~255 的整数直接丢进网络不是不行但效果会差很多。因为数值范围过大的输入会让初始权重下的加权和也偏大落在激活函数的饱和区。具体来说如果使用 sigmoid 这类激活函数输入值的绝对值一旦偏大函数输出就会贴到 0 或 1 附近梯度趋近于 0参数就很难继续更新。所以我把每个像素除以 255让取值范围压缩到 [0, 1] 的浮点数区间。这一步我是亲测有效的同样的网络结构不归一化训练到后面 loss 下降得很慢归一化之后明显顺畅。第三步是标签的处理方式。t_train 里的数值是 0~9 的整数对于分类任务更好的选择是把标签改成 one-hot 向量。简单说数字 3 变成一个长度为 10 的向量第 3 个位置是 1其余是 0。这样做的原因是输出层用的是 softmax它的输出天然是每个类别的概率而交叉熵损失函数的数学形式也正好要求标签以这种形式参与计算。加载数据时直接指定 one_hot_labelTrue就能得到 shape 为 (10000, 10) 的标签矩阵。最后要讲的是 mini-batch 训练机制。训练集有 60000 张图片如果每一轮都把所有样本算一遍梯度计算量太大不说也没有必要。实践里常见做法是每次随机抽一小批比如 100 张用这一批的梯度来近似整体梯度。我写了一个随机选取函数利用 np.random.choice 从 0~59999 中无放回抽取 100 个索引然后从 x_train 和 t_train 中取出对应批次。下面是一段我跑通的代码骨架import numpy as np def get_batch(x, t, batch_size100): batch_mask np.random.choice(len(x), batch_size) x_batch x[batch_mask] t_batch t[batch_mask] return x_batch, t_batch为什么随机抽因为相邻的样本往往高度相似比如同一页纸扫描出来的图片如果按顺序取批次模型可能只学到了局部分布。随机采样相当于让每一批数据都尽量代表整体分布不但训练更稳定而且收敛速度在多数情况下也会更快。不过要注意一个坑load_mnist 默认返回的 x_train 如果设置了 normalizeTrue它内部其实已经做了归一化。如果你同时又在自己的代码里再除一次 255等于把像素值压到了 0~0.003 的区间。模型很容易变成什么都学不到loss 一开始就很高。我一开始就踩过这个重复归一化的坑输出层的概率非常平均跟瞎猜差不多。用 print 看一下 x_train 的最大值就能立刻发现是不是处理重复了。3. 两层网络的搭建过程输入层 784、隐藏层 50、输出层 10每一层在干什么很多教程喜欢把神经网络说得很玄其实拆开看就是一连串矩阵乘法和非线性变换。我这次实现的两层网络数学结构非常清晰。假设输入是 x第一层权重 W1 的形状是 (784, 50)偏置 b1 是 (50,)。那么第一层先算 z1 x W1 b1得到 (batch_size, 50) 的中间结果。注意 784 这个数字不是随便定的——它就是 28×28 图像拉伸后的像素总数也就是说每个像素都接到了隐藏层的每个神经元上。然后用激活函数对 z1 做变换。这里用的是 sigmoid。很多人不理解为什么非要用非线性函数如果中间不加激活函数两层线性变换等价于一层线性变换网络再深也是白搭。sigmoid 把任意实数映射到 (0,1) 区间给模型引入非线性表达能力这也是网络能识别复杂模式的重要原因。那为什么这个项目用 sigmoid 而不用现在更常见的 ReLU《小鱼书》在这个入门阶段选择它是因为 sigmoid 的梯度公式比较简单——被回传时是常数乘上 (1 - 输出)容易推导也容易验证非常适合新手对照手动计算。实际项目里我碰过不少因为 ReLU 死亡导致的神经元坏死问题而 sigmoid 在这个规模的数据上不会出现这种状况。紧接着第二层再算一次线性变换z2 h W2 b2其中 h 是第一层经过 sigmoid 后的结果W2 的形状是 (50, 10)b2 是 (10,)。最后得到的 z2 是一个 (batch_size, 10) 的矩阵每一行代表这个样本在 10 个类别上的得分。为了让得分变成概率输出层还要过一个 softmax 函数。softmax 的表达式很简单每个输出除以所有输出的指数和。它的作用是让较大的得分在概率上拉开差距同时保证所有类别的概率加起来等于 1。为什么分类问题最后一定要 softmax因为交叉熵损失函数和 softmax 是一对配合默契的搭档使用交叉熵损失时softmax 的梯度会被大幅简化最终变成模型预测概率减去真实标签这个简洁形式对于编写反向传播代码简直太友好了。网络前向传播我用一段简洁代码概括def predict(self, x): z1 x self.W1 self.b1 h1 sigmoid(z1) z2 h1 self.W2 self.b2 y softmax(z2) return y初次写这个项目时我反复确认了权重尺寸。W1 的第二个维度取 50纯属一个可调的隐藏层大小并不是固定法则。取小了模型容量不足可能识别精度上不去取大了参数变多训练时间变长还容易过拟合。50 是很多入门教程验证过的安全值我用它跑出来的测试精度在 97% 上下。如果你想探索可以从 50 改成 100 或 128观察结果差异这也是这个项目非常适合做实验的地方。4. 训练环节最容易翻车的几个问题loss 不降、过拟合、梯度验证不通过训练是整条链路中最考验耐心的一环。我按照标准流程跑了反向传播也做了梯度检查整体还算顺利但其中有三个问题值得单独拿出来说因为它们是新手最常碰到的。第一个问题是 loss 完全不降。如果你初始化权重时没有控制随机数规模而是直接用标准正态分布随机生成十有八九会遇到 loss 卡在某个高位迟迟不动的情况。原因在于权重绝对值偏大导致中间层输出进入 sigmoid 的饱和区梯度趋近于 0。解决办法很简单把初始权重乘以一个很小的系数我用的是 0.01。这样初始输出就不会过分集中在 0 和 1 两端反向传播时梯度也能正常流动。这里我验证过一次去掉这个缩放系数训练 10 个 epoch 后测试精度只有 40%加上之后直接跳到 95% 以上。第二个问题是过拟合。表现为训练集识别率接近 100%但测试集识别率提高到一个平台后反而下降。我做过一个对比实验把隐藏层从 50 个神经元加到 200 个训练精度确实冲高但测试精度比原来还低典型过拟合。对于 MNIST 这种量级的数据集最简单的缓解方法一是减少隐藏层神经元数量二是加入正则化项三是早停——在验证集精度不再提升时及时结束训练。实际调参时我习惯把训练过程和测试过程分成两个阶段分别记录精度曲线一旦发现两条曲线的差距拉大就说明模型开始背诵训练集了。第三个问题是梯度验证。手写实现反向传播之后如果你怀疑梯度算错了别对着代码空想直接做一次数值梯度检查。原理是用中心差分公式近似求导把某个参数微调一个微小量 epsilon观察损失变化再和反向传播算出的梯度对比。下面是我用的对比方法def numerical_gradient(f, x): h 1e-4 grad np.zeros_like(x) it np.nditer(x, flags[multi_index], op_flags[readwrite]) while not it.finished: idx it.multi_index tmp_val x[idx] x[idx] tmp_val h fxh1 f(x) x[idx] tmp_val - h fxh2 f(x) grad[idx] (fxh1 - fxh2) / (2 * h) x[idx] tmp_val it.iternext() return grad别小看这个技巧。很多 bug 表面上看是精度没上去其实罪魁祸首是反向传播代码里某个矩阵转置写错了。数值梯度虽然算得慢但它不求任何数学推导非常直白。我开始也只是抱着验证一下的心态跑的结果还真发现第二层权重梯度差了 0.003 左右定位到是 b2 的广播机制写错了。如果你也手写反向传播强烈建议把这个检查函数放到正式训练之前通用省时省力。训练过程里还有一个容易被忽视的工程点epoch 的统计方式。我当时用的是批大小 100总样本 60000那么一个 epoch 等于处理完 600 个批次。如果代码里只是在 for i in range(10000) 里循环并不等于训练了 10000 个 epoch反而是 10000 个 iteration。建议在日志里打印当前 iteration 当前 epoch避免把收敛进度搞混淆。我们看精度曲线时横轴用 epoch 比用 iteration 更直观。5. 用真实手写图片做预测预处理不当再好的模型也没用模型训练完之后最有成就感的环节是拿它去识别我自己手写的数字。但这里有个现实问题模型只在 28×28 的灰度图上训练过而我在画图软件里随便画的数字尺寸、粗细、颜色、位置全都不一样。如果直接把图片丢进模型大概率得到错误结果。经验告诉我真正考验功力的不是网络结构而是推理前的预处理管线。我给自己设计了一套推理流程。第一步准备一张白底黑字的数字图大小随意比如 224×224。第二步把彩色图转成灰度图再取反色——因为训练数据里的笔迹是黑色像素值接近 0而背景是白色像素值接近 255如果照片里数字是黑色的背景是白色的直接喂进去会得到完全相反的特征模型预测结果非常混乱。所以必须做 255 - x 的反色操作。第三步最关键把数字从整张图中裁剪出来再做缩放。很多人直接 resize 到 28×28结果数字被压缩在中间一个小角落模型看到的有效信息极少。正确做法是先找到数字的最小外接矩形。简单粗暴的方式是扫描灰度矩阵找到所有低于某个阈值比如 128的像素的横纵坐标范围这个范围就是数字的包围盒。然后根据包围盒的中心把它移动到图像的中间。接下来以这个边框为基础四周留出一定比例边距再 resize 到 28×28。这样数字主体会占据图片的大部分面积模型处理起来和训练集里的分布更接近。第四步把处理完的 28×28 矩阵 flatten 成 784 的向量做归一化除以 255 后转 float然后调用已经训练好的 predict 方法。输出的 y 是 10 个浮点数取 argmax 就是最终预测类别。我一般还会顺便把概率最高的两个类别打印出来。比如有一次我写了个9但模型给出的分布是 9 的概率 89%、4 的概率 10%说明模型其实在犹豫只是 9 赢了。这个信息比单一预测值有用得多能让你判断图片预处理是否还有改进余地。我整理了一个简单的推理代码def preprocess_image(img_path): from PIL import Image img Image.open(img_path).convert(L) arr np.array(img, dtypenp.float32) arr 255 - arr # 反色让笔迹变成白色 rows np.where(np.any(arr 128, axis1))[0] cols np.where(np.any(arr 128, axis0))[0] r1, r2, c1, c2 rows[0], rows[-1], cols[0], cols[-1] arr_crop arr[r1:r21, c1:c21] from PIL import Image arr_pil Image.fromarray(arr_crop.astype(np.uint8)) arr_resized arr_pil.resize((20, 20), Image.LANCZOS) arr_final np.zeros((28, 28), dtypenp.float32) arr_final[4:24, 4:24] np.array(arr_resized) return (arr_final / 255.0).flatten().reshape(1, -1)这里我把裁剪后的内容先缩放到 20×20再贴到一个 28×28 的黑色画布中央而不是直接 resize 到 28×28。这样做的好处是避免数字同时被横向拉宽或纵向压扁保留原始笔画的比例。实测下来直接 resize 到 28×28 的准确率只有 80% 左右改成 20×20 贴边距之后准确率能提高到 93% 以上。推理环节还有个小技巧如果字体很细、识别失败可以对图片做一次形态学膨胀处理让笔画变粗一点贴近 MNIST 里的平均笔迹粗细。如果字体过粗反过来做腐蚀操作。本质上都是让真实图片的数据分布往训练集分布靠拢这一步在入门项目里越早意识到收益越大。6. 顺手总结一下这个项目里真正沉淀下来的经验MNIST 手写数字识别项目做完之后我最大的体会是深度学习入门从来不是从调框架 API开始的而是从亲手把矩阵乘起来看着 loss 一点点降下去开始的。通过跟着小鱼书的思路手写两层神经网络验证了归一化、权重初始化、mini-batch 训练、梯度检查这些基础操作的实际价值。后续再切换到成熟框架时很多封装好的组件在我眼里不再是黑盒因为我已经知道里面的结构大致是什么样的。如果新手想复现这个流程我给三个比较实在的建议第一别急着上卷积神经网络先把普通两层的全连接网络跑通先从数学上理解什么是识别第二每次改参数只改一个同时打印训练集和测试集的精度别让模型悄悄过拟合第三最后用自己手写的图片测试时预处理一定要和训练时的数据分布对齐否则测试结果不理想时你会误以为是模型的问题。这些都是我踩过坑之后才真正理解的也是这个项目最宝贵的地方。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑