📌 本文属于《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 文件,每行就是一张图,用逗号分隔。你可以从这里下载:

💡 为了让你快速上手,我也准备了两个迷你版: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. 避免“大小陷阱”:类别之间没有高低之分(比如猫、狗、鸟),用 1、2、3 会让模型误以为鸟 > 狗。

  2. 符合线性算法要求:很多算法(如逻辑回归、神经网络)吃的是数值向量,独热编码把“类别差别”转化为“空间距离”,便于计算。

它的缺点是什么?

如果类别特别多(比如上万个英文单词),独热编码的向量就会变得极其稀疏且庞大(比如 [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 张图片,每张都做三件事:

  1. 拆分数据:用 split(',') 把一行文本拆成列表,第一个元素是标签,后面 784 个是像素。

  2. 处理输入:将像素值缩放到 0.01~1.0,变成网络能“消化”的形式。

  3. 构造目标:生成一个长度为 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() 内部到底做了什么?其实它干了三件大事:

  1. 前向传播:输入从输入层进入,经过隐藏层的加权求和与激活,最后在输出层得到 10 个数字(每个数字代表网络对“该图片是几”的置信度)。

  2. 计算误差:把输出层的结果和我们的目标数组 targets 做差,得到“预测偏差”。

  3. 反向传播:这个偏差会从输出层反向传回隐藏层,再传回输入层,同时每一层都根据偏差来微调自己的权重——正是这一步让网络“学会”了。

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……会发现准确率有变化。这就是超参数调优的乐趣。我们在后续文章中会专门讲如何用实验找到最适合的值。

九、总结与下一步

今天我们一起:

  1. 认识了 MNIST 数据集,知道了它长什么样。

  2. 学会了读取 CSV、分离标签和像素、将像素还原成图像。

  3. 理解了为什么要缩放输入(0.01~1.0)以及如何构建输出目标(0.01/0.99)。

  4. 用我们之前写好的神经网络训练了 100 张图。

  5. 了解了隐藏层节点数的选择思路,并用测试集评估了泛化能力。


Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐