手把手教你用深度神经网络实现手写数字识别

在机器学习领域,手写数字识别是一个经典的入门项目。通过这个项目,我们可以深入理解神经网络的工作原理,从数据处理到模型训练的完整流程。本文将基于NumPy从零实现一个手写数字识别系统,使用MNIST数据集,这是一个包含手写数字0-9的标准数据集,非常适合初学者练习。

项目概述与流程

本项目实现了深度学习模型开发的完整生命周期,具体流程如下:

步骤 功能说明
1 导入依赖库并设置数据文件路径
2 解码图像文件为NumPy数组
3 解码标签文件为类别标签
4 封装数据加载函数
5 数据归一化与模型参数初始化
6 前向传播计算与损失函数评估
7 反向传播与参数更新
8 定义辅助功能函数
9 训练模型并测试准确率

1. 环境准备与数据路径设置

首先需要导入必要的库并设置MNIST数据集的文件路径。MNIST数据集以二进制格式存储,需要使用struct库解析,而numpy用于数值计算和矩阵操作。

import numpy as np
import struct

# 设置MNIST数据集文件路径(注意路径中不能包含空格)
train_images_idx3_ubyte_file = './MNIST/train-images.idx3-ubyte'
train_labels_idx1_ubyte_file = './MNIST/train-labels.idx1-ubyte'
test_images_idx3_ubyte_file = './MNIST/t10k-images.idx3-ubyte'
test_labels_idx1_ubyte_file = './MNIST/t10k-labels.idx1-ubyte'

注意事项:文件路径中不能包含空格,否则会导致解析错误。如果数据集未下载,需要先从MNIST官网获取并放入指定目录。

2. 图像数据解码函数实现

解析图像文件格式

MNIST图像文件采用.idx3-ubyte格式,包含文件头和像素数据:

  • 文件头:4个整数(魔数、图像数量、行数、列数)
  • 像素数据:每个像素为1字节(0-255),按行优先排列

以下是完整的解码函数实现:

def decode_idx3_ubyte(images_idx3_ubyte_file):
    # 读取二进制文件内容
    bin_data = open(images_idx3_ubyte_file, 'rb').read()
    offset = 0
    
    # 解析文件头信息(>IIII表示大端序,4个无符号整数)
    magic_number, num_images, num_rows, num_cols = struct.unpack_from('>IIII', bin_data, offset)
    print(f"魔数: {magic_number}, 图像数量: {num_images}, 尺寸: {num_rows}x{num_cols}")
    
    # 计算单个图像的像素总数
    image_size = num_rows * num_cols
    offset += struct.calcsize('>IIII')  # 移动指针到像素数据起始位置
    
    # 构造像素数据解析格式字符串(例如784B表示28x28图像的784个字节)
    fmt_image = '>' + str(image_size) + 'B'
    
    # 初始化存储图像的三维数组 (num_images, num_rows, num_cols)
    images = np.empty((num_images, num_rows, num_cols))
    
    # 遍历解析每个图像的像素数据
    for i in range(num_images):
        if (i + 1) % 10000 == 0:
            print(f"已解析 {i + 1} 张图像")
        # 读取单个图像的像素数据并重塑为28x28数组
        images[i] = np.array(struct.unpack_from(fmt_image, bin_data, offset)).reshape((num_rows, num_cols))
        offset += struct.calcsize(fmt_image)  # 移动指针到下一张图像
    
    return images

核心原理:通过struct.unpack_from按指定格式解析二进制数据,文件头的魔数(magic number)用于校验文件格式是否正确(MNIST图像文件的魔数为2051)。

3. 标签数据解码函数实现

标签文件采用.idx1-ubyte格式,结构更简单:

  • 文件头:2个整数(魔数、标签数量)
  • 标签数据:每个标签为1字节(0-9的数字)
def decode_idx1_ubyte(labels_idx1_ubyte_file):
    bin_data = open(labels_idx1_ubyte_file, 'rb').read()
    offset = 0
    
    # 解析标签文件头(魔数和标签数量)
    magic_number, num_images = struct.unpack_from('>II', bin_data, offset)
    print(f"魔数: {magic_number}, 标签数量: {num_images}")
    
    offset += struct.calcsize('>II')
    fmt_label = '>B'  # 单个字节的标签格式
    
    # 初始化一维标签数组
    labels = np.empty(num_images)
    
    # 遍历解析每个标签
    for i in range(num_images):
        if (i + 1) % 10000 == 0:
            print(f"已解析 {i + 1} 个标签")
        # 提取单个字节的标签值(0-9)
        labels[i] = struct.unpack_from(fmt_label, bin_data, offset)[0]
        offset += struct.calcsize(fmt_label)
    
    return labels

注意事项:原始代码中可能存在重复定义该函数的情况,需删除重复定义以避免冲突。

4. 封装数据加载接口

为了方便调用,将解码函数封装为统一的数据加载接口:

def load_train_images(images_idx3_ubyte_file=train_images_idx3_ubyte_file):
    return decode_idx3_ubyte(images_idx3_ubyte_file)

def load_train_labels(labels_idx1_ubyte_file=train_labels_idx1_ubyte_file):
    return decode_idx1_ubyte(labels_idx1_ubyte_file)

def load_test_images(images_idx3_ubyte_file=test_images_idx3_ubyte_file):
    return decode_idx3_ubyte(images_idx3_ubyte_file)

def load_test_labels(labels_idx1_ubyte_file=test_labels_idx1_ubyte_file):
    return decode_idx1_ubyte(labels_idx1_ubyte_file)

通过这种方式,我们可以直接调用load_train_images()load_train_labels()获取训练数据,测试数据同理。

5. 数据预处理与参数初始化

数据归一化处理

将像素值从0-255缩放到0-1区间,避免数值过大导致梯度爆炸或收敛缓慢:

def normalize_data(image):
    # 线性归一化:(x - min) / (max - min)
    a_max = np.max(image)
    a_min = np.min(image)
    image = (image - a_min) / (a_max - a_min)
    return image

神经网络参数初始化

使用Xavier初始化方法(均匀分布),避免梯度消失或爆炸:

def initialize_with_zeros(n_x, n_h, n_y):
    """
    初始化两层神经网络的权重和偏置
    n_x: 输入层维度(784)
    n_h: 隐藏层维度(自定义)
    n_y: 输出层维度(10,对应0-9十个数字)
    """
    np.random.seed(2)  # 固定随机种子确保结果可复现
    
    # Xavier初始化:权重范围为[-sqrt(6/(n_x+n_h)), sqrt(6/(n_x+n_h))]
    W1 = np.random.uniform(
        -np.sqrt(6) / np.sqrt(n_x + n_h),
        np.sqrt(6) / np.sqrt(n_x + n_h),
        size=(n_h, n_x)
    )
    b1 = np.zeros((n_h, 1))  # 偏置初始化为0
    
    W2 = np.random.uniform(
        -np.sqrt(6) / np.sqrt(n_y + n_h),
        np.sqrt(6) / np.sqrt(n_y + n_h),
        size=(n_y, n_h)
    )
    b2 = np.zeros((n_y, 1))
    
    parameters = {"W1": W1, "b1": b1, "W2": W2, "b2": b2}
    return parameters

Xavier初始化原理:通过控制权重的方差,使得信号在网络中前向传播和反向传播时方差保持一致,提升训练稳定性。

6. 前向传播与损失函数计算

前向传播流程

实现两层神经网络的前向传播(输入层→隐藏层→输出层):

def forward_propagation(X, parameters):
    """
    X: 输入数据,形状为(n_x, m),m为样本数
    parameters: 包含W1, b1, W2, b2的参数字典
    """
    W1 = parameters["W1"]
    b1 = parameters["b1"]
    W2 = parameters["W2"]
    b2 = parameters["b2"]
    
    # 隐藏层计算:Z1 = W1*X + b1,激活函数为tanh
    Z1 = np.dot(W1, X) + b1
    A1 = np.tanh(Z1)
    
    # 输出层计算:Z2 = W2*A1 + b2,激活函数为sigmoid
    Z2 = np.dot(W2, A1) + b2
    A2 = 1 / (1 + np.exp(-Z2))  # sigmoid激活函数
    
    # 缓存中间变量用于反向传播
    cache = {"Z1": Z1, "A1": A1, "Z2": Z2, "A2": A2}
    return A2, cache

交叉熵损失函数

使用交叉熵评估模型预测与真实标签的差异:

def costloss(A2, Y, parameters):
    """
    A2: 模型输出,形状为(10, m)
    Y: 真实标签(one-hot编码),形状为(10, m)
    m: 样本数量
    """
    m = Y.shape[1]
    
    # 计算交叉熵(添加1e-9避免log(0)报错)
    logprobs = np.multiply(np.log(A2 + 1e-9), Y) + np.multiply(np.log(1 - A2 + 1e-9), (1 - Y))
    cost = -np.sum(logprobs) / m
    
    return cost

交叉熵物理意义:衡量两个概率分布的差异,在分类问题中,预测概率与真实标签的交叉熵越小,说明预测越准确。

7. 反向传播与参数更新

反向传播算法

基于链式法则计算梯度,核心是从输出层到输入层逐层推导梯度:

def back_propagation(parameters, cache, X, Y):
    """
    parameters: 模型参数
    cache: 前向传播缓存的中间变量
    X: 输入数据
    Y: 真实标签
    """
    m = X.shape[1]  # 样本数量
    
    W1 = parameters["W1"]
    W2 = parameters["W2"]
    A1 = cache["A1"]
    A2 = cache["A2"]
    
    # 输出层梯度计算
    dZ2 = A2 - Y  # 对sigmoid激活函数的梯度
    dW2 = np.dot(dZ2, A1.T) / m
    db2 = np.sum(dZ2, axis=1, keepdims=True) / m
    
    # 隐藏层梯度计算(tanh导数为1 - tanh^2)
    dZ1 = np.dot(W2.T, dZ2) * (1 - np.power(A1, 2))
    dW1 = np.dot(dZ1, X.T) / m
    db1 = np.sum(dZ1, axis=1, keepdims=True) / m
    
    grads = {"dW1": dW1, "db1": db1, "dW2": dW2, "db2": db2}
    return grads

梯度下降更新参数

根据计算的梯度更新权重和偏置:

def update_parameters(parameters, grads, learning_rate):
    """
    parameters: 旧参数
    grads: 梯度
    learning_rate: 学习率
    """
    W1 = parameters["W1"] - learning_rate * grads["dW1"]
    b1 = parameters["b1"] - learning_rate * grads["db1"]
    W2 = parameters["W2"] - learning_rate * grads["dW2"]
    b2 = parameters["b2"] - learning_rate * grads["db2"]
    
    parameters = {"W1": W1, "b1": b1, "W2": W2, "b2": b2}
    return parameters

8. 辅助功能函数

激活函数与数据转换

def sigmoid(x):
    """sigmoid激活函数"""
    return 1 / (1 + np.exp(-x))

def image2vector(image):
    """将28x28图像展平为784维向量"""
    return np.reshape(image, (784, 1))

def softmax(x):
    """softmax函数(本项目未使用,用于多分类概率归一化)"""
    exps = np.exp(x - np.max(x))  # 减去最大值避免数值溢出
    return exps / np.sum(exps)

9. 主程序:训练与测试模型

完整训练与测试流程

if __name__ == '__main__':
    # 1. 加载训练数据和测试数据
    train_images = load_train_images()
    train_labels = load_train_labels()
    test_images = load_test_images()
    test_labels = load_test_labels()
    
    # 2. 设置神经网络结构参数
    n_x = 28 * 28  # 输入层维度:28x28=784
    n_h = 32       # 隐藏层维度(可自定义)
    n_y = 10       # 输出层维度:0-9共10个类别
    
    # 3. 初始化模型参数
    parameters = initialize_with_zeros(n_x, n_h, n_y)
    
    # 4. 训练模型(50000次迭代)
    for i in range(50000):
        # 取出单张训练图像和标签
        img_train = train_images[i].copy()
        label_train = np.zeros((10, 1))  # 初始化one-hot标签
        
        # 动态调整学习率(迭代1000次后逐步衰减)
        learning_rate = 0.001
        if i > 1000:
            learning_rate *= 0.999
        
        # 将标签转换为one-hot编码
        label_train[int(train_labels[i])] = 1
        
        # 数据预处理:归一化并展平图像
        img_vector = normalize_data(image2vector(img_train))
        
        # 前向传播
        A2, cache = forward_propagation(img_vector, parameters)
        
        # 计算损失
        cost1 = costloss(A2, label_train, parameters)
        
        # 反向传播计算梯度
        grads = back_propagation(parameters, cache, img_vector, label_train)
        
        # 更新模型参数
        parameters = update_parameters(parameters, grads, learning_rate)
        
        # 每100次迭代打印一次损失
        if i % 100 == 0:
            print(f"迭代 {i} 次后的损失: {cost1:.6f}")
    
    # 5. 测试模型准确率
    predict_right_num = 0
    for i in range(10000):  # 测试10000张图像
        img_test = test_images[i].copy()
        img_vector = normalize_data(image2vector(img_test))
        label_true = test_labels[i]
        
        # 前向传播获取预测结果
        A2, _ = forward_propagation(img_vector, parameters)
        predict_value = np.argmax(A2)  # 取概率最大的类别作为预测结果
        
        # 统计正确预测数量
        if predict_value == int(label_true):
            predict_right_num += 1
    
    # 输出测试准确率
    print(f"测试准确率: {predict_right_num / 10000 * 100:.2f}%")

项目优化与扩展方向

  1. 增加隐藏层数量:当前模型为两层神经网络,可尝试增加隐藏层(如3层或4层)提升模型表达能力。

  2. 调整网络参数

    • 隐藏层神经元数量(如从32调整为64或128)
    • 学习率初始值及衰减策略
    • 迭代次数(如增加到100000次)
  3. 改进激活函数:可尝试ReLU(f(x) = max(0, x))替代tanh,缓解梯度消失问题。

  4. 使用批量梯度下降:当前实现为单样本迭代,可修改为批量训练(如batch_size=32),提升训练稳定性。

  5. 添加正则化:加入L1/L2正则化或Dropout,减少过拟合风险。

总结

通过这个项目,我们完整实现了深度神经网络的核心流程:从数据加载与预处理,到网络构建、前向传播、损失计算、反向传播和参数更新,最终完成模型测试。这是理解神经网络工作原理的最佳实践之一,掌握这个流程后,你可以将其扩展到更复杂的图像分类任务中。

手写数字识别的本质是模式识别问题,神经网络通过学习像素值的权重组合,能够捕捉手写数字的关键特征(如笔画形状、拐角位置等)。虽然本项目使用纯NumPy实现,计算效率不如深度学习框架,但它能帮助我们深入理解底层原理,为后续使用PyTorch/TensorFlow等框架打下坚实基础。

Logo

技术共进,成长同行——讯飞AI开发者社区

更多推荐