python神经网络编程入门(七)——跑通MNIST手写数字识别,验证训练成果与测试准确率。

📅 2026/7/12 18:06:26
python神经网络编程入门(七)——跑通MNIST手写数字识别,验证训练成果与测试准确率。
在上一篇第六篇中我们手把手从零实现了一个三层神经网络类NeuralNetwork它具备了初始化、前向查询query和反向训练train的能力。但当时我们只用一个简单的 2-2-2 网络演示了误差下降这显然不够过瘾——我们的终极目标可是识别手写数字啊今天我们就来把那个神经网络真正派上用场用大名鼎鼎的MNIST 数据集训练它让它学会分辨“0”到“9”十个手写数字。这篇文章会详细解释数据加载、预处理、训练循环、测试评估并且每一行代码都附上详细注释和设计原因确保你不仅能跑通还能理解为什么这样做。一、MNIST 数据集长什么样MNISTModified National Institute of Standards and Technology是机器学习界的“Hello World”它包含训练集60,000 张手写数字图像测试集10,000 张手写数字图像每张图像是 28×28 的灰度图像素值范围 0黑色~ 255白色标签就是图像中的数字0~9。为了方便我们直接使用数据以 CSV 格式提供参考 MNIST in CSV 每行第一个数是标签后面跟着 784 个像素值28×28 展开成一行。比如一行开头是5,0,0,0, ...就表示这是一张手写数字“5”的图像。建议一开始不要直接使用全部 6 万张图片先用小数据集如 100 张调试代码等确认无误再用全集——这能节省大量时间。二、下载数据别被原始格式吓到MNIST 官方数据是二进制格式不直接友好。幸好热心人已经把数据转成了CSV文件每行就是一张图用逗号分隔。你可以从这里下载训练集60000 张mnist_train.csv测试集10000 张mnist_test.csv 为了让你快速上手我也准备了两个迷你版mnist_train_100.csv100张和mnist_test_10.csv10张。练习阶段用迷你版跑得快不心疼。下载后把文件放在你的 Python 项目文件夹里比如建一个mnist_data目录。三、揭开数据的面纱一行 CSV 里藏着什么我们用 Python 读一下训练集的第一行看看它长什么样。# 打开文件只读 with open(mnist_data/mnist_test.csv, r) as f: first_line f.readline() # 只读第一行 # 按逗号拆分成列表 values first_line.split(,) print(这行一共有 {} 个元素。.format(len(values))) print(第一个元素是标签, values[0]) print(后面几个像素值, values[1:6])输出大概是结构非常清晰第一个数字 这张图片的真实答案标签这里是5。后面 784 个数字 28×28 784 个像素的灰度值范围是 0黑色到 255白色。784 个数字排成一行眼睛看瞎也看不出是什么。所以我们需要把它还原成图像。四、把数字变成图像——让你的眼睛“看见”数据我们用matplotlib把那一串数字重塑成 28 行 28 列的矩阵然后画出来。import numpy as np import matplotlib.pyplot as plt from matplotlib import font_manager # 加载微软雅黑字体 font_path msyh.ttf # 请确保该文件存在 prop font_manager.FontProperties(fnamefont_path) with open(mnist_data/mnist_test.csv, r) as f: first_line f.readline() all_values first_line.split(,) image_array np.asarray(all_values[1:], dtypefloat).reshape((28, 28)) plt.imshow(image_array, cmapGreys, interpolationNone) plt.title(标签是 all_values[0], fontpropertiesprop) # 使用自定义字体 plt.show()运行效果你会看到一个大大的、歪歪扭扭的7出现在窗口里。这就是数据的“真身”——一堆数字只是它的“二进制编码”画出来才是它的模样。五、喂给神经网络之前必须做的“调味”工作神经网络就像一位挑剔的美食家食材必须处理得恰到好处否则它要么尝不出味道梯度消失要么被辣哭梯度爆炸。5.1 为什么要缩放输入我们的像素值是 0~255而神经网络的激活函数Sigmoid的输出范围是 0~1输入太大或太小都会让梯度变得极其微小导致学习几乎停滞。所以我们必须把输入缩放到一个合适的区间。我们选择的区间是0.01 到 1.0不是 0~1因为 0 会导致权重更新失败数学上叫“死神经元”。具体做法# 先归一化到 0~1再乘以 0.99再加上 0.01 scaled_input (np.asfarray(all_values[1:]) / 255.0 * 0.99) 0.01这样最小值从 0 变成 0.01最大值从 255 变成 1.0。5.2 输出目标该怎么设计我们的网络有 10 个输出节点分别代表数字 0 到 9。我们希望当输入是“5”时只有第 6 个节点索引为5兴奋其他都沉默。但 Sigmoid 永远不能输出精确的 0 或 1所以我们用0.01 代表“不激活”用0.99 代表“激活”。构造目标数组的代码targets np.zeros(10) 0.01 # 所有位置先放 0.01 targets[int(all_values[0])] 0.99 # 正确标签的位置改为 0.99如果标签是 5那么targets就是[0.01, 0.01, 0.01, 0.01, 0.01, 0.99, 0.01, 0.01, 0.01, 0.01]独热编码简单来说就是把分类标签转换成一串只有一位是“1”其余全是“0”的二进制向量。用一个单选题来类比最合适选项 A、B、C、D编码后分别是A →[1, 0, 0, 0]B →[0, 1, 0, 0]C →[0, 0, 1, 0]D →[0, 0, 0, 1]代码读取的是手写数字标签0~9共10 个类别。如果不做处理直接写数字5机器学习模型可能会误以为5比4大或比6小引入了数学上的顺序关系。但用独热编码表示5就变成了[0, 0, 0, 0, 0, 1, 0, 0, 0, 0]索引 5 的位置是 1这样每个数字都是独立的、平等的“身份”模型就知道它们只是不同的类别彼此之间没有大小远近的数学关系。为什么非要这么做避免“大小陷阱”类别之间没有高低之分比如猫、狗、鸟用 1、2、3 会让模型误以为鸟 狗。符合线性算法要求很多算法如逻辑回归、神经网络吃的是数值向量独热编码把“类别差别”转化为“空间距离”便于计算。它的缺点是什么如果类别特别多比如上万个英文单词独热编码的向量就会变得极其稀疏且庞大比如[0,0,...,1,...,0]非常消耗内存和算力。这时通常会改用“嵌入向量”Embedding来解决。六、组装你的神经网络代码回顾上一篇我们已经实现了NeuralNetwork类这里直接拿来用。它的结构是输入层784 个节点每个像素一个隐藏层100 个节点为什么是100后文解释输出层10 个节点对应0~9初始化参数学习率设为0.3权重用随机小值激活函数用 Sigmoid。# 导入之前写的神经网络类 from neural_network import NeuralNetwork input_nodes 784 hidden_nodes 100 output_nodes 10 learning_rate 0.3 n NeuralNetwork(input_nodes, hidden_nodes, output_nodes, learning_rate)七、开始训练用 100 张图片教它认数字上一节我们已经搭好了神经网络的结构也做好了数据预处理的“调味”工作。现在终于到了最激动人心的时刻——让网络真正开始学习不过我们不会一上来就用全部 60000 张图片。原因很简单调试阶段跑得太慢会消磨热情。就像学做菜我们会先炒一小盘试试咸淡而不是直接开个流水席。所以我们先用一个迷你训练集100 张图片来训练这样代码跑起来几乎瞬间完成方便我们确认每一环节都没问题。7.1 灵活控制训练数据量为了以后方便扩展到全量数据用参数num_train来控制我们究竟读入多少条记录。这样你只需要改一个数字就能从“尝鲜模式”切换到“火力全开模式”。实现方式很简单先用readlines()把整个大文件全部读进内存然后用 Python 的切片语法[:num_train]只取前 N 条。因为 MNIST 的 CSV 文件已经事先打乱了顺序所以前 N 条就相当于一个随机子集分布是均匀的。# 我们想要使用的训练样本数量可自由修改 num_train 100 # 改成 60000 就是全量训练 num_test 10 # 测试集也用同样的方式控制 # 加载训练集只取前 num_train 条 with open(mnist_data/mnist_train.csv, r) as f: all_train_records f.readlines() training_data all_train_records[:num_train] # 加载测试集只取前 num_test 条 with open(mnist_data/mnist_test.csv, r) as f: all_test_records f.readlines() test_data all_test_records[:num_test]7.2 训练循环让网络“看”完每一张图接下来就是核心的训练循环了。我们用for record in training_data:逐条处理这 100 张图片每张都做三件事拆分数据用split(,)把一行文本拆成列表第一个元素是标签后面 784 个是像素。处理输入将像素值缩放到0.01~1.0变成网络能“消化”的形式。构造目标生成一个长度为 10 的数组正确标签的位置放0.99其余放0.01告诉网络“正确答案长这样”。然后调用n.train(inputs, targets)神经网络就会根据当前预测和目标的差距自动调整内部的权重这就是反向传播的魔力。代码非常简洁但每一行都承载着关键逻辑# 对每一条记录进行训练 for record in training_data: all_values record.split(,) # 输入缩放 inputs (np.asfarray(all_values[1:]) / 255.0 * 0.99) 0.01 # 构建目标数组10个输出节点 targets np.zeros(10) 0.01 targets[int(all_values[0])] 0.99 # 喂给网络它内部会自己计算误差并更新权重 n.train(inputs, targets)这短短几行代码就是神经网络“学习”的全部过程。训练完成后网络已经“看”过了这 100 张图片并据此调整了它的内部权重——就像老师用 100 道例题教会学生解题思路一样。7.3 训练时发生了什么给读者的大脑补课你可能好奇n.train()内部到底做了什么其实它干了三件大事前向传播输入从输入层进入经过隐藏层的加权求和与激活最后在输出层得到 10 个数字每个数字代表网络对“该图片是几”的置信度。计算误差把输出层的结果和我们的目标数组targets做差得到“预测偏差”。反向传播这个偏差会从输出层反向传回隐藏层再传回输入层同时每一层都根据偏差来微调自己的权重——正是这一步让网络“学会”了。7.4 用真实测试集评估——你的网络到底有多强现在我们用真正的测试集——10,000 张全新的手写数字图片——来给网络来一场“大考”。这 10,000 张图片从未在训练中出现过所以测试结果能真实反映网络的泛化能力也就是它举一反三的本事。with open(mnist_data/mnist_test.csv, r) as f: all_test f.readlines() if num_test is None: test_data all_test else: test_data all_test[:num_test] test_start time.time() scorecard [] # 1 表示正确0 表示错误 for record in test_data: all_values record.split(,) correct_label int(all_values[0]) # 真实标签 inputs (np.asarray(all_values[1:], dtypefloat) / 255.0 * 0.99) 0.01 outputs n.query(inputs) # 网络预测结果 predicted np.argmax(outputs) # 取出最大输出对应的数字 if predicted correct_label: scorecard.append(1) else: scorecard.append(0) test_end time.time() accuracy sum(scorecard) / len(scorecard) * 100 print(f测试完成耗时 {test_end - test_start:.2f} 秒) print(f准确率{accuracy:.2f}%共测试 {len(scorecard)} 张图片)运行结果示例100 张训练 10000 张测试如果你用num_train100测试全部 10000 张大概会得到类似这样的输出如果你想用 10000 张训练效果会怎样只需把num_train 10000训练时间会变长但准确率会大幅提升超过90%。你可以自己试试看同时我也加载不同数量的训练集进行准确率对比如图所示结合上一期的完整代码如下import numpy as np import scipy.special class NeuralNetwork: 三层神经网络类输入层 → 隐藏层 → 输出层 包含初始化、前向查询、反向训练三大核心功能 def __init__(self, input_nodes, hidden_nodes, output_nodes, learning_rate): 【建造师】初始化网络的结构和参数 参数: input_nodes: 输入层节点数 hidden_nodes: 隐藏层节点数 output_nodes: 输出层节点数 learning_rate: 学习率控制每次调整的步子大小 # ----- 1. 记录网络结构 ----- # 这些数字决定了网络的身材以后在 query 和 train 中都会用到 self.inodes input_nodes # 输入层节点个数 self.hnodes hidden_nodes # 隐藏层节点个数 self.onodes output_nodes # 输出层节点个数 self.lr learning_rate # 学习率控制调整幅度 # ----- 2. 创建权重矩阵网络的核心记忆----- # # 权重的形状说明 # wih: (隐藏层节点数, 输入层节点数) # who: (输出层节点数, 隐藏层节点数) # # 为什么要这样设计 # 矩阵乘法时行数 目标层节点数列数 源层节点数 # 这样 wih × 输入 就能得到 隐藏层输入 # 这样 who × 隐藏层输出 就能得到 输出层输入 # 输入层 → 隐藏层的权重矩阵 # 用正态分布随机初始化均值0标准差 1/sqrt(隐藏层节点数) # 这个经验规则可以防止权重太大导致网络饱和 self.wih np.random.normal( 0.0, # 均值 pow(self.hnodes, -0.5), # 标准差 1/√隐藏节点数 (self.hnodes, self.inodes) # 矩阵形状 ) # 隐藏层 → 输出层的权重矩阵 self.who np.random.normal( 0.0, pow(self.onodes, -0.5), # 标准差 1/√输出节点数 (self.onodes, self.hnodes) ) # ----- 3. 定义激活函数 ----- # S型函数Sigmoid将任意实数压缩到0~1之间 # 使用 scipy.special.expit 实现它数值稳定性更好 # lambda 是一种快捷定义函数的方式相当于 # def activation_function(x): # return scipy.special.expit(x) self.activation_function lambda x: scipy.special.expit(x) # 初始化完成 # 现在这个网络有了骨架结构、肌肉权重和神经激活函数 # 但它还没学过任何东西就像一张白纸 def query(self, inputs_list): 【问路员】给定输入让网络计算输出 参数: inputs_list: 输入数据列表例如 [0.5, 0.3] 返回: final_outputs: 网络的输出列向量例如 [[0.62], [0.35]] # ----- 1. 把输入转换成列向量 ----- # np.array(inputs_list, ndmin2) 把 [0.5, 0.3] 变成 [[0.5, 0.3]] # .T 转置后变成 [[0.5], [0.3]]即列向量 # 为什么要转置因为矩阵乘法要求维度匹配 # wih 的形状是 (隐藏节点数, 输入节点数) # 输入列向量的形状是 (输入节点数, 1) # 两者相乘得到 (隐藏节点数, 1)正好是隐藏层的输入 inputs np.array(inputs_list, ndmin2).T # ----- 2. 计算隐藏层的输入信号 ----- # 公式: hidden_inputs wih × inputs # 这一步把输入和权重结合起来算出每个隐藏节点收到的信号 hidden_inputs np.dot(self.wih, inputs) # ----- 3. 计算隐藏层的输出信号 ----- # 把加权和信号通过S函数挤压成0~1之间的值 # 这就是隐藏层神经元的激活过程 hidden_outputs self.activation_function(hidden_inputs) # ----- 4. 计算输出层的输入信号 ----- # 公式: final_inputs who × hidden_outputs # 把隐藏层的输出再跟输出层的权重结合 final_inputs np.dot(self.who, hidden_outputs) # ----- 5. 计算输出层的输出信号 ----- # 再次经过S函数得到最终输出 final_outputs self.activation_function(final_inputs) # 返回结果 return final_outputs # 查询完成 # 整个过程就是输入 → 加权 → 激活 → 加权 → 激活 → 输出 # 信号从输入层一直流到了输出层 def train(self, inputs_list, targets_list): 【教练员】用一组训练样本训练网络调整权重 参数: inputs_list: 输入数据列表例如 [0.5, 0.3] targets_list: 目标输出列表正确答案例如 [0.9, 0.1] 训练过程分为两个阶段 1. 前向传播和 query 一样算出当前网络的输出 2. 反向传播用误差来调整权重梯度下降 # 第一阶段前向传播和 query 完全一样 # 把输入转成列向量 inputs np.array(inputs_list, ndmin2).T # 把目标输出也转成列向量 targets np.array(targets_list, ndmin2).T # 计算隐藏层的输入和输出 hidden_inputs np.dot(self.wih, inputs) hidden_outputs self.activation_function(hidden_inputs) # 计算输出层的输入和输出 final_inputs np.dot(self.who, hidden_outputs) final_outputs self.activation_function(final_inputs) # 前向传播结束现在我们知道了网络在当前权重下的答案 # 第二阶段反向传播 # ----- 1. 计算输出层误差 ----- # 误差 正确答案 - 网络的答案 output_errors targets - final_outputs # ----- 2. 计算隐藏层误差反向传播 ----- # 把输出层的误差甩回隐藏层 # 公式: hidden_errors who^T × output_errors # who^T 是权重矩阵的转置行和列互换 # 这个操作实现了按权重比例分配误差 hidden_errors np.dot(self.who.T, output_errors) # ----- 3. 更新隐藏层→输出层的权重 ----- # 这是整个网络最核心的一步 # 公式: Δwho -α × output_errors × S(final_inputs) × hidden_outputs^T # # 分解一下 # output_errors : 输出层的误差 # final_outputs * (1 - final_outputs) : S函数的斜率导数 # np.transpose(hidden_outputs) : 隐藏层输出的转置 # self.lr : 学习率控制步子大小 # # 负号去哪了因为我们直接用误差的反方向调整 # 这里 output_errors targets - outputs已经包含了方向信息 self.who self.lr * np.dot( (output_errors * final_outputs * (1.0 - final_outputs)), np.transpose(hidden_outputs) ) # ----- 4. 更新输入层→隐藏层的权重 ----- # 跟上面的公式完全对称只是换成了隐藏层的误差和输入 # 公式: Δwih -α × hidden_errors × S(hidden_inputs) × inputs^T self.wih self.lr * np.dot( (hidden_errors * hidden_outputs * (1.0 - hidden_outputs)), np.transpose(inputs) ) # 训练完成 # 两行代码更新了网络里所有的权重 # 这就是梯度下降的魔力——用矩阵运算一次性调整所有参数 import time # 用来计时 if __name__ __main__: # ---- 你可以自由调整这两个参数 ---- num_train 10000 # 训练样本数想用全部就改成 60000 num_test None # None 表示用全部测试集或者写成 1000 只取前 1000 张 # ---- 加载训练集 ---- print(正在加载训练数据...) with open(mnist_data/mnist_train.csv, r) as f: all_train f.readlines() if num_train is None: training_data all_train else: training_data all_train[:num_train] # ---- 加载测试集 ---- print(正在加载测试数据...) with open(mnist_data/mnist_test.csv, r) as f: all_test f.readlines() if num_test is None: test_data all_test else: test_data all_test[:num_test] print(f训练样本数{len(training_data)}测试样本数{len(test_data)}) # ---- 初始化神经网络结构同前 ---- input_nodes 784 hidden_nodes 100 output_nodes 10 learning_rate 0.3 n NeuralNetwork(input_nodes, hidden_nodes, output_nodes, learning_rate) # ---- 开始训练 ---- print(开始训练...) train_start time.time() for record in training_data: all_values record.split(,) inputs (np.asarray(all_values[1:], dtypefloat) / 255.0 * 0.99) 0.01 targets np.zeros(10) 0.01 targets[int(all_values[0])] 0.99 n.train(inputs, targets) train_end time.time() print(f训练完成耗时 {train_end - train_start:.2f} 秒) # ---- 在测试集上评估 ---- print(开始在测试集上评估...) test_start time.time() scorecard [] # 1 表示正确0 表示错误 for record in test_data: all_values record.split(,) correct_label int(all_values[0]) # 真实标签 inputs (np.asarray(all_values[1:], dtypefloat) / 255.0 * 0.99) 0.01 outputs n.query(inputs) # 网络预测结果 predicted np.argmax(outputs) # 取出最大输出对应的数字 if predicted correct_label: scorecard.append(1) else: scorecard.append(0) test_end time.time() accuracy sum(scorecard) / len(scorecard) * 100 print(f测试完成耗时 {test_end - test_start:.2f} 秒) print(f准确率{accuracy:.2f}%共测试 {len(scorecard)} 张图片)八、关于隐藏层节点数——为什么选 100这是一个被问烂的问题“隐藏层到底设多少个节点最好”答案是没有标准答案只有经验法则。我们选 100是基于两个考虑小于输入层784迫使网络压缩信息提取出最关键的特征防止它死记硬背每个像素过拟合。大于输出层10保证网络有足够的“容量”去表达复杂模式如果太少比如 10 个它可能学不会区分所有数字。当然你可以试 50、200、300……会发现准确率有变化。这就是超参数调优的乐趣。我们在后续文章中会专门讲如何用实验找到最适合的值。九、总结与下一步今天我们一起认识了 MNIST 数据集知道了它长什么样。学会了读取 CSV、分离标签和像素、将像素还原成图像。理解了为什么要缩放输入0.01~1.0以及如何构建输出目标0.01/0.99。用我们之前写好的神经网络训练了 100 张图。了解了隐藏层节点数的选择思路并用测试集评估了泛化能力。