资讯动态

用Python实现神经网络识别手写数字:从MNIST到反向传播实战

发布时间:2026/9/16 3:20:51 来源:尧图企业网站定制
简介基于Python实现的手写数字识别项目利用神经网络算法在MNIST数据集上训练并完成数字图片分类。MNIST包含28x28像素的手写数字样本代码文件load_mnist.py负责数据加载与模型构建项目覆盖了从数据预处理到结果可视化的主要步骤适合正在学习机器学习、深度学习基础或希望用Python快速体验图像识别的开发者参考。资源共7个文件主要包括1个Python脚本、5张识别效果示意图和1份README说明文档压缩包整体仅158KB轻量易下载目前已有133人在线学习项目结构简洁代码与图片分开存放便于对照运行结果理解网络训练过程。通过阅读源码与效果图可掌握数据预处理、神经网络前向传播与反向传播的基本流程并在此基础上扩展调参或改进模型结构。项目体量虽小但流程完整适合课堂演示或课程设计参考。1. 用Python实现神经网络算法识别手写数字集为什么先选MNIST手写数字识别是图像分类里最经典的第一课几万张28×28的灰度图每张图上只有一个0到9的手写数字任务就是让程序把图片对应到正确的类别。用Python实现神经网络算法去解决它不是为了拿最优结果而是用一个只有二十万个参数的两层网络亲自验证前向传播、反向传播、梯度下降这三件套能真正跑通。MNIST作为手写数字集的标准起点数据量小、标注干净、准确率可量化即使没有GPU仅靠CPU训练几分钟也能在测试集上拿到94%以上的准确率。适合两类人一类是看完Python基础语法准备做第一个机器学习项目的开发者另一类是已经用框架很久但没手写过反向传播的程序员。这个任务比爬虫和Web后端更能直观理解“模型到底学到了什么”。2. 手写数字集加载与预处理像素归一化、One-Hot和批次切分的Python实现2.1 加载MNIST的两种路径keras接口和手工解析IDX加载手写数字集最快的方式是使用tensorflow.keras.datasets.mnist.load_data()。环境只要Python 3.8以上编辑器无论是vscode还是pycharm都行把代码存成.py文件直接运行。第一次调用会自动下载数据集并缓存到本地之后不再联网。from tensorflow.keras.datasets import mnist (x_train, y_train), (x_test, y_test) mnist.load_data() print(x_train.shape, y_train.shape, x_test.shape, y_test.shape) # 输出 # (60000, 28, 28) (60000,) # (10000, 28, 28) (10000,)x数组是60000张28×28单通道灰度图像素类型是uint8取值0到255y是长度为60000的一维数组每项是0到9的整数标签。如果只是为了识别手写数字集这个目标keras接口足够。它虽然自带训练框架但这里只借用它的数据集管道后续建模仍然用纯numpy。不想引入tensorflow依赖时可以用标准库里的gzip手工解析MNIST的IDX文件。IDX文件头固定为4个32位大端整数依次是魔数、维度数、各维尺寸读取方式如下import gzip import numpy as np def read_idx_images(path): with gzip.open(path, rb) as f: header np.frombuffer(f.read(16), dtypeu4) magic, num, rows, cols header[0], header[1], header[2], header[3] raw np.frombuffer(f.read(), dtypenp.uint8) return raw.reshape(num, rows, cols)这里的u4表示大端无符号32位整数read(16)读走文件头标签文件格式类似只是头部只有magic和num两个整数后面每个字节是一个标签。手动解析能帮你验证数据文件到底长什么样对后续排查数据形状问题有帮助但日常实验我一般只用keras接口省掉版本和路径管理的麻烦。两种加载方式对比如下方式依赖首次下载适用场景keras.datasets.mnisttensorflow自动下载并缓存想立刻开始训练torchvision.datasets.MNISTtorchvision实例化时下载后续要换PyTorch训练gzip手工解析IDX仅标准库自己管理文件路径学习文件格式或做离线部署2.2 像素归一化为什么除以255而不是只减均值加载后的图像数据是uint8直接参与矩阵运算有两个问题数值在0到255之间会让加权求和结果特别大ReLU输出一上来就是几十甚至上百损失和梯度的尺度不稳定。常见做法是把像素值统一压到[0,1]区间这也是数据处理里的基本思路先看数据范围再决定用什么变换。import numpy as np x_train_flat x_train.reshape(x_train.shape[0], -1).astype(float32) / 255.0 x_test_flat x_test.reshape(x_test.shape[0], -1).astype(float32) / 255.0reshape的第一个参数写x_train.shape[0]保留60000这个样本数第二个参数写-1让numpy自动推断成784。astype(float32)必须做它把8位整数转成4字节浮点避免后续矩阵乘法产生大量中间int类型溢出。除以255.0是逐元素除法结果落在[0,1]让每个像素在神经网络里被当作亮度比例而不是绝对强度。有些教程会再做去均值除标准差对MNIST这种灰度背景统一的数据集没有必要。手写数字集所有图像来自同一扫描环境直接除以255已经能让训练稳定收敛。去均值是处理图像差异很大的数据集时才需要的预处理。2.3 One-Hot编码和mini-batch生成器分类网络最后通过softmax输出10个类别的概率训练时要与真实标签计算交叉熵。如果标签仍然用整数0到9计算过程里会多一次索引取值更常见、也更便于向量化的做法是把每个标签展开成长度为10的One-Hot向量def to_one_hot(labels, num_classes10): eye np.eye(num_classes) return eye[labels] y_train_onehot to_one_hot(y_train) # (60000, 10) y_test_onehot to_one_hot(y_test) # (10000, 10)np.eye(10)生成10×10单位矩阵第i行就是只有第i位为1的向量用labels数组做索引一次取出所有对应行并拼成(N,10)比循环逐个转换快得多也符合numpy风格。注意这里的labels仍然是原始整数标签One-Hot编码后再进网络。训练全连接网络时通常不会一次把6万张图全塞进矩阵运算而是分成小批次。mini-batch大小会影响梯度噪声和收敛速度生成器写法如下def iterate_minibatches(x, y, batch_size, shuffleTrue): assert len(x) len(y) indices np.arange(len(x)) if shuffle: np.random.shuffle(indices) for start in range(0, len(indices), batch_size): batch_idx indices[start:start batch_size] yield x[batch_idx], y[batch_idx]yield让每次只生成一个批次的数组不会把整个数据集的副本全放进内存。shuffleTrue在每个epoch开始时重新洗牌避免同一批里连续出现同一个数字的图片。如果某次排序恰好让前32张全是数字7这个batch的梯度会偏得特别明显训练曲线更容易震荡。验证集和测试集预测时shuffle应该设False因为评估阶段不需要随机性。3. 用numpy实现神经网络算法前向传播、反向传播与参数更新3.1 网络结构输入784、隐藏层256、输出10识别手写数字集的任务可以设计成多种结构这里选择一层隐藏层的全连接网络。输入层784对应28×28展开后的像素隐藏层256个神经元用ReLU激活输出层10个神经元接softmax得到每个类别的概率。第一层到第二层的权重维度是(784,256)第二层到输出层是(256,10)偏置分别是256和10。为什么隐藏层用ReLU而不是sigmoidsigmoid在输入绝对值较大时梯度会趋近0反向传播经过两层之后梯度很容易消失ReLU导数是0或1正区间梯度恒为1训练时能稳定回传。对于MNIST这种灰度、结构简单的数据ReLU已经足够不需要更复杂的激活函数。网络参数量如下参数维度参数量W1(784, 256)200,704b1(256,)256W2(256, 10)2,560b2(10,)10合计203,5306万张训练图对20万个参数来说并不宽裕网络容量足够记住训练集里很多特例所以第4章会出现过拟合问题需要正则化。3.2 前向传播两层变换和softmax的数值稳定性前向传播把一张图的784个像素逐步变成10个类别的概率。过程是z1 x W1 b1a1 ReLU(z1)z2 a1 W2 b2probs softmax(z2)用numpy实现一次前向def relu(x): return np.maximum(0, x) def softmax(logits): shifted logits - np.max(logits, axis1, keepdimsTrue) exp_values np.exp(shifted) return exp_values / exp_values.sum(axis1, keepdimsTrue) def forward(x, params): W1, b1, W2, b2 params[W1], params[b1], params[W2], params[b2] z1 np.dot(x, W1) b1 a1 relu(z1) z2 np.dot(a1, W2) b2 probs softmax(z2) cache {z1: z1, a1: a1, z2: z2, probs: probs} return cachesoftmax里先减去该样本的最大logit再取exp是为了防止指数爆炸。假设某个logit是100np.exp(100)会溢出成inf减掉最大值后最大指数项变成exp(0)1所有值都在可表示范围内。axis1是对每个样本的10个类别分别归一化keepdimsTrue保留(N,10)的维度方便下一步广播除法。forward返回的cache里保存了中间变量z1、a1、z2它们不参与预测但会在反向传播里被复用避免重复计算。预测时只需要probs使用np.argmax取最大概率的下标就是预测类别。3.3 反向传播交叉熵配合softmax时梯度只有一行训练目标是让预测概率分布接近真实One-Hot标签损失用交叉熵L -log(p_y)p_y是模型给正确类别分配的概率。把softmax和交叉熵两个算子合在一起求梯度会得到非常整洁的结果dL/dz2 probs - y_onehot这个式子的含义是输出层的误差等于模型预测概率减去真实标签概率向量。当模型对正确类别给0.8时这一项的梯度是0.2左右会推动正确类别的logit继续增大其他类别相应缩小。这是整个反向传播里最重要的一段理解它以后梯度方向基本不会写错。完整反向传播def backward(x, y_onehot, cache, params): batch_size x.shape[0] probs cache[probs] a1 cache[a1] z1 cache[z1] dz2 probs - y_onehot grads {} grads[W2] np.dot(a1.T, dz2) / batch_size grads[b2] np.sum(dz2, axis0) / batch_size da1 np.dot(dz2, params[W2].T) dz1 da1 * (z1 0) grads[W1] np.dot(x.T, dz1) / batch_size grads[b1] np.sum(dz1, axis0) / batch_size return grads逐行说明dz2的维度是(N,10)np.dot(a1.T, dz2)得到(256,10)除以batch_size得到平均梯度da1把误差往上一层传播维度是(N,256)z1 0返回布尔矩阵与da1相乘相当于把ReLU导数为0的神经元的梯度置零x.T与dz1点乘得到(784,256)再除以batch_size。所有梯度都除以batch_size这样损失曲线不会随batch size变大而整体抬高不同batch_size之间也更可比。3.4 权重初始化和确定性实验环境如果不做随机初始化而是把W1和W2全部设为0那么同一层所有神经元的输入完全一样反向传播时梯度也完全一样参数永远保持相同256个神经元退化为一个网络表达能力大幅下降。因此权重必须随机初始化来打破对称性。MNIST输入已经归一化到[0,1]为了让每层输出的方差尽可能稳定常见做法是使用He初始化它的缩放系数是sqrt(2 / n_in)def init_params(n_input784, n_hidden256, n_output10, seed42): rng np.random.default_rng(seed) W1 rng.standard_normal((n_input, n_hidden)) * np.sqrt(2.0 / n_input) b1 np.zeros(n_hidden) W2 rng.standard_normal((n_hidden, n_output)) * np.sqrt(2.0 / n_hidden) b2 np.zeros(n_output) return {W1: W1, b1: b1, W2: W2, b2: b2}standard_normal生成标准正态分布随机数乘以sqrt(2/n)把方差调节到适合ReLU的尺度。偏置初始化为0没有问题因为每个神经元收到的输入不同不会出现对称退化。固定seed之后同一份代码每次运行得到的初始化完全相同这有助于排查问题先让结果可复现再谈调参。如果训练到一半发现损失变成NaN优先检查learning_rate是否过大如果损失完全不下降优先检查forward里softmax有没有正常归一化以及backward里dz2是否写成了probs - y_onehot而不是反过来的方向。4. 训练循环与超参数调优用Python把准确率从90%推到97%4.1 训练循环代码和参数说明把所有模块组合起来一个完整的训练过程如下params init_params(seed42) learning_rate 0.1 batch_size 64 epochs 15 for epoch in range(epochs): total_loss 0.0 n_batches 0 for x_batch, y_batch in iterate_minibatches(x_train_flat, y_train_onehot, batch_size, shuffleTrue): cache forward(x_batch, params) probs cache[probs] loss -np.mean(np.log(probs[np.arange(len(y_batch)), np.argmax(y_batch, axis1)] 1e-12)) grads backward(x_batch, y_batch, cache, params) for key in params: params[key] - learning_rate * grads[key] total_loss loss n_batches 1 train_acc np.mean(np.argmax(forward(x_train_flat, params)[probs], axis1) y_train) test_acc np.mean(np.argmax(forward(x_test_flat, params)[probs], axis1) y_test) print(fepoch {epoch1:2d} | loss {total_loss / n_batches:.4f} | train {train_acc:.4f} | test {test_acc:.4f})loss的计算里np.argmax(y_batch, axis1)取回每个样本的真实类别下标probs[...]取出正确类别对应的概率取负对数后求平均。加1e-12防止某个概率被舍入成0造成log(0)inf这只是数值保护不影响训练梯度。更新阶段是对params里的W1、b1、W2、b2统一做params[key] - learning_rate * grads[key]这是最朴素的SGD。10到15个epoch后测试准确率通常能达到94%到96%具体取决于随机种子和数据洗牌顺序。如果发现test_acc卡在90%以下不涨先别急着改网络按下面三个参数逐一排查。4.2 三个必调超参数learning_rate、batch_size、hidden_size超参数对收敛的影响排在第一位。下面是这个网络结构里的典型范围和现象超参数经验范围现象与调整方向learning_rate0.005 到 0.5损失震荡或变大时调小收敛过慢时调大batch_size32 到 256偏小梯度噪声大、曲线起伏大偏大每epoch迭代少hidden_size128 到 512准确率上不去再增大已过拟合时反而要减小learning_rate在MNIST这种简单任务上可以大胆用0.1到0.2因为损失面相对平滑。如果学习率开到0.5以上前几个batch就会看到loss突然变成inf或nan这是权重更新步长太大直接把数值推出可表示范围。此时把learning_rate折半同时检查输入像素是否真的在[0,1]。batch_size64时每个epoch有938个更新步改成256后只剩235步。训练时间缩短但每个batch的梯度估计更偏向“平均”噪声小却更新次数少。当损失曲线呈锯齿状上下跳动时把batch_size翻倍通常能缓解如果训练损失下降太慢则减小batch_size试试。hidden_size从256提到512参数量翻倍训练时间增加准确率提升通常不超过0.5%。隐藏层神经元数量已经是一个相对成熟的区间没必要为了“看起来更大”盲目扩层要先看验证集上的实际收益。4.3 防止过拟合L2正则化、Dropout和早停手写数字集的训练集和测试集来自同一数据源风格接近过拟合不会像真实业务数据那样严重但依然存在训练准确率能到99%以上测试集却只有94%到95%这是典型的记忆训练集而不是学习泛化。最常见的处理是L2正则化也叫权重衰减。在原有梯度上加一项lambda * W让大权重受到惩罚权重矩阵中的每个元素在更新时都会被向零方向拉一点l2_lambda 0.001 def add_l2_grads(grads, params, l2_lambda): for key in [W1, W2]: grads[key] l2_lambda * params[key] return grads注意L2项只加在权重W上不加在偏置b上。偏置不参与特征的线性放大对过拟合的贡献极小。lambda从1e-3开始试如果开到0.1以上会让模型欠拟合训练loss也很难降到合理水平。Dropout在numpy里实现也直接训练时对隐藏层激活a1按概率随机保留节点并把保留下来的激活值除以保留概率做补偿测试时使用完整网络。手动实现时还要记得只在训练时开启否则测试时同一张图的预测结果每次都会不同。这个任务里Dropout不是必需我在样本量更小的自定义手写数据集上才会用。早停是最直接的策略每训练完一个epoch记录验证集loss连续3个epoch不再下降就停止并回滚到验证loss最小的那次参数。MNIST训练只需几十秒早停的价值在于节省反复调参的时间以及避免最后拿到的模型是训练尾部的过拟合参数。5. 模型验证混淆矩阵、错误样本可视化与小数据收敛试验5.1 用混淆矩阵和classification_report定位识别短板在测试集上计算整体准确率之后还需要看模型在哪些数字上更容易出错。sklearn的confusion_matrix可以直接用from sklearn.metrics import confusion_matrix, classification_report y_pred_test np.argmax(forward(x_test_flat, params)[probs], axis1) cm confusion_matrix(y_test, y_pred_test) print(classification_report(y_test, y_pred_test, digits3))classification_report会列出每个类别的precision、recall、f1-score。MNIST上最常见的是“4”和“9”互相错、“3”被认成“8”、“7”被认成“1”。这些混淆通常对应真实手写体的笔顺数字4如果顶部不封口看起来就像97如果不带横杠和1的写法相近。看到这类现象说明模型确实学到了笔画形状的统计规律不是随机蒙对。5.2 小数据收敛试验用128张图快速验证反向传播正确写反向传播最容易出的问题不是公式记错而是某处维度转置写反。等跑完整个6万张图才发现梯度方向不对回头排查的成本很高。一个可靠的验证办法是用128张图跑长迭代观察loss能否降到接近0sample_x x_train_flat[:128] sample_y y_train_onehot[:128] params_small init_params(seed0) lr_small 0.1 for step in range(500): cache forward(sample_x, params_small) probs cache[probs] loss -np.mean(np.log(probs[np.arange(128), sample_y.argmax(axis1)] 1e-12)) grads backward(sample_x, sample_y, cache, params_small) for key in params_small: params_small[key] - lr_small * grads[key] if step % 50 0: print(step, loss)128个样本足够让一个两层网络在500步内把训练损失压到0.01以下训练集正确率接近100%。如果loss训练很久后仍然卡在2.30附近说明softmax输出的概率始终保持约0.1的均匀分布梯度没有真正作用到参数上问题九成出在反向传播的dz2符号或维度上。如果loss能降到1以下但速度很慢可能是学习率太小或权重初始化尺度过小。这个试验耗时不到一分钟每次重写网络结构后都应该先跑一遍再上完整数据。本文还有配套的精品资源点击获取

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

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

免费获取报价