python神经网络编程入门(七)——跑通MNIST手写数字识别,验证训练成果与测试准确率。
📌 本文属于《Python神经网络入门:零基础保姆级路线图》专栏
上一篇:python神经网络编程入门(六)——从零开始,敲出你的第一个神经网络
下一篇:python神经网络编程入门(八)——全连接网络的“死穴“—— 为什么图像任务必须换思路,CNN?完整目录 & 更新记录:
《Python神经网络入门:零基础保姆级路线图(附全系列免费源码)》
在上一篇(第六篇)中,我们手把手从零实现了一个三层神经网络类 NeuralNetwork,它具备了初始化、前向查询(query)和反向训练(train)的能力。但当时我们只用一个简单的 2-2-2 网络演示了误差下降,这显然不够过瘾——我们的终极目标可是识别手写数字啊!
今天,我们就来把那个神经网络真正派上用场,用大名鼎鼎的 MNIST 数据集训练它,让它学会分辨“0”到“9”十个手写数字。这篇文章会详细解释数据加载、预处理、训练循环、测试评估,并且每一行代码都附上详细注释和设计原因,确保你不仅能跑通,还能理解为什么这样做。
一、MNIST 数据集长什么样?
MNIST(Modified 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.csv(100张)和mnist_test_10.csv(10张)。练习阶段用迷你版,跑得快,不心疼。
下载后,把文件放在你的 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(fname=font_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:], dtype=float).reshape((28, 28))
plt.imshow(image_array, cmap='Greys', interpolation='None')
plt.title("标签是 " + all_values[0], fontproperties=prop) # 使用自定义字体
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:], dtype=float) / 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_train=100,测试全部 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, ndmin=2) 把 [0.5, 0.3] 变成 [[0.5, 0.3]]
# .T 转置后变成 [[0.5], [0.3]],即列向量
# 为什么要转置?因为矩阵乘法要求维度匹配:
# wih 的形状是 (隐藏节点数, 输入节点数)
# 输入列向量的形状是 (输入节点数, 1)
# 两者相乘得到 (隐藏节点数, 1),正好是隐藏层的输入
inputs = np.array(inputs_list, ndmin=2).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, ndmin=2).T
# 把目标输出也转成列向量
targets = np.array(targets_list, ndmin=2).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:], dtype=float) / 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:], dtype=float) / 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 张图。
-
了解了隐藏层节点数的选择思路,并用测试集评估了泛化能力。
更多推荐



所有评论(0)