ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从零用NumPy实现MLP:手写数字识别与反向传播原理

从零用NumPy实现MLP:手写数字识别与反向传播原理 研一刚接触计算机视觉时我的学习路径几乎被各种 CNN 变体占据。很长一段时间里我默认“神经网络 调框架”直到被几个基础问题问住为什么卷积层后面要接 ReLU交叉熵损失凭什么比 MSE 更适合分类反向传播里的梯度到底是什么在流动这些问题就像地基里的裂缝平时不致命一旦遇到新论文、新任务整栋楼都会晃。后来我系统性补课回头看哥伦比亚大学《Computer Vision: First Principles》系列课程其中第十二讲“神经网络”正好把这条线串清楚了。这一讲没有直接讲卷积、目标检测、分割这类高级视觉任务而是从神经网络最底层的结构讲起用第一性原理的方式解释清楚神经网络为什么能学习、怎么学习以及如何用训练好的网络完成视觉任务。本文是这一讲的学习笔记和代码展开版不依赖任何高层深度学习框架而是用 NumPy 从零实现一个多层感知机MLP在 MNIST 数据集上完成手写数字识别。文章适合刚入门深度学习、但想把底层机制弄清楚的读者也适合准备系统学习计算机视觉、想先夯实神经网络基础的同学。学完你将理解神经网络的核心组件、前向传播与反向传播的数学原理并掌握一套可复现的实战代码。1. 为什么学计算机视觉要先吃透神经网络1.1 神经网络在计算机视觉中的地位计算机视觉的目标是让机器“看懂”图像比如判断图片里是猫还是狗、定位画面中的行人和车辆、识别图片上的文字。早期视觉方法依赖人工设计的特征比如 SIFT、HOG、Haar这些特征能提取边缘、纹理、梯度方向等信息但设计一个好的特征算子极其耗费经验和时间而且换一个场景往往就不太管用。神经网络改变了这个局面。它不再由人工指定“看哪里”而是通过大量数据自动学习出合适的特征表示。图像输入网络后第一层学习边缘和颜色第二层学习纹理和局部形状再往上可能组合出眼睛、轮子等部件的概念最后抽象成“猫”或“狗”的类别。整个特征提取过程不需要人工干预。1.2 从第一性原理到“可解释”的深度学习“第一原理”的思维方式强调从基本事实出发推导结论。对神经网络而言基本事实是神经网络由大量简单计算单元组成每个单元对输入做加权求和后经过非线性激活网络通过数据驱动的方式自动调整权重。理解这些基本机制后你会发现很多“魔法”其实并不神秘。CNN 中的卷积层本质是局部加权求和残差网络ResNet的残差结构本质是给梯度留了一条“高速公路”注意力机制本质是权重分配。所有这些高级结构都是建立在神经网络基本计算单元之上的设计模式。1.3 用 NumPy 学神经网络的好处直接上 PyTorch 或 TensorFlow 当然很省事但框架封装了太多细节容易让初学者把“调 API”当成“懂原理”。本文特意用 NumPy 从零实现原因有三前向传播和反向传播的每一步都可以看到矩阵的具体形状变化跨过框架能理解损失对每个参数的梯度是如何一层一层传回去的后续阅读论文源码时能更快理解框架里的loss.backward()到底做了什么。2. 神经网络的核心组件拆解2.1 从生物神经元到人工神经元人工神经网络最初的灵感来自生物神经元。生物神经元通过树突接收信号在胞体进行整合超过某个阈值后通过轴突向其他神经元传递信号。人工神经元做了简化抽象单个神经元接收一个输入向量 ( x )通过权重向量 ( w ) 加权求和再加上偏置 ( b )最后经过非线性激活函数 ( \sigma )得到输出 ( a )。这个过程的数学形式是[ z w \cdot x b ][ a \sigma(z) ]文字描述神经元对输入做加权求和再用偏置调整阈值最后用激活函数引入非线性。代码层面就是一个点积加一个偏移# 文件路径core_neuron.py def neuron(x, w, b, activation): z np.dot(w, x) b return activation(z)2.2 权重到底在学习什么权重 ( w ) 是神经元对输入各个维度的关注程度。以图像为例一个 ( 28 \times 28 ) 的手写数字图片展开成 784 维向量其中一个神经元可能重点关注中心区域是否有“竖线”另一个神经元可能关注右上角是否有“小圆”。如果没有权重学习机制这些关注点只能靠人工设计。神经网络的目的就是让数据来决定每一维输入应该被重视到什么程度。权重越大说明该输入维度对当前神经元的影响越大。权重是网络学习的核心参数也是反向传播最终要更新的对象。2.3 偏置 biases 的作用让决策边界不被迫过原点很多初学者对 bias偏置理解不深总觉得它只是多出来的一个常数项。但 bias 的作用在几何上非常直观。如果把神经元的分类过程看作在高维空间画一条直线二维情况下( w \cdot x 0 ) 表示的直线必然经过原点。可现实数据很少恰好围绕原点分布如果没有 bias我们能画的决策直线就会受到极大限制。有了 bias 之后[ w \cdot x b 0 ]这条直线就可以平移到任意位置b 控制着平移量的大小。每个神经元拥有一个偏置值通常初始化为 0 或很小的随机数随着训练逐渐调整到合适的位置。这个思想延伸到多层网络中也一样。每一层学习到的特征分布都不同通过偏置让激活函数能工作在最合适的输入区间。2.4 前馈神经网络信号单向流动人工神经元组合成网络后根据信号流动方向主要分为前馈神经网络Feedforward Neural Network简称前馈网络和循环神经网络RNN。本文的主角多层感知机MLPMultilayer Perceptron是典型的前馈网络数据从输入层进入经过若干隐藏层最后从输出层输出信号始终向前流动不存在跨层回环。MLP 是最简单的神经网络结构也是学习后续 CNN、RNN、Transformer 的基础。MNIST 手写数字识别就是 MLP 的经典应用场景输入 784 维像素向量中间经过几个隐藏层最后输出 10 个类别的概率分布。3. 神经网络训练的核心机制3.1 激活函数为什么必须非线性如果神经网络只有线性变换那么多层网络可以写成矩阵连乘[ y W_3(W_2(W_1 x b_1) b_2) b_3 ]展开后仍然是输入 ( x ) 的线性函数。也就是说多个线性层叠加在一起表达能力等价于一个线性层网络再深也没有意义。因此每层神经元必须引入非线性激活函数。下面对比几种常见激活函数激活函数公式输出范围导数特点优缺点Sigmoid( \sigma(z)1/(1e^{-z}) )(0, 1)两侧梯度趋于 0适合输出概率但容易梯度消失Tanh( \tanh(z) )(-1, 1)零中心仍会在两端饱和比 Sigmoid 好但仍可能梯度消失ReLU( \max(0, z) )[0, ∞)正区间恒为 1负区间为 0计算快、缓解梯度消失但可能有神经元死亡Leaky ReLU( \max(0.01z, z) )(-∞, ∞)负区间小斜率缓解神经元死亡问题现代计算机视觉的隐藏层普遍采用 ReLU 及其变体。最重要的原因是 ReLU 正区间梯度恒为 1反向传播时信号衰减速度慢能有效缓解深层网络中的梯度消失问题同时 ReLU 计算非常简单只是取最大值操作。3.2 损失函数如何衡量预测的好坏损失函数评估模型预测与真实标签之间的差距。计算机视觉中的任务主要分两类分类任务判断图像属于哪一类常用交叉熵损失回归任务预测连续值比如预测图像中物体的角度常用均方误差MSE。MNIST 手写数字分类有 10 个类别网络输出层经过 Softmax 函数把 10 个实数映射成概率分布。交叉熵损失衡量真实概率分布与预测概率分布之间的差异[ L -\frac{1}{N} \sum_{i1}^{N} \log(p_{i, y_i}) ]其中 ( p_{i, y_i} ) 表示第 i 个样本在真实类别上的预测概率。为什么分类不用 MSE概率是 softmax 输出MSE 对概率分布的差异不敏感交叉熵配合 Softmax 的梯度形式极其简洁下面马上会看到从最大似然角度交叉熵有清晰的概率解释。3.3 反向传播梯度如何回流反向传播是神经网络训练的基石核心思想是链式法则。训练一个网络的过程是前向传播从输入到输出计算预测结果和损失反向传播从损失出发逐层计算损失对每个参数的梯度参数更新使用梯度下降更新权重和偏置。重点在于第二步。假设前向传播中存在中间变量 ( z )然后经激活函数得到 ( a )再用于后续计算。要计算损失 ( L ) 对参数 ( W ) 的梯度需要借助链式法则[ \frac{\partial L}{\partial W} \frac{\partial L}{\partial z} \cdot \frac{\partial z}{\partial W} ]实现反向传播时网络会从输出层往回逐层计算“误差信号”再把误差信号与当前层的输入相乘得到权重的梯度。对于 Softmax 和交叉熵损失组合梯度有一个非常漂亮的性质。假设模型预测的概率分布是 ( \hat{y} )真实标签的 one-hot 编码是 ( y )那么损失对 Softmax 输入 ( z ) 的导数是[ \frac{\partial L}{\partial z} \hat{y} - y ]也就是说输出层的误差信号直接就是“预测概率减去真实 one-hot 标签”。这是代码里实现反向传播最核心、也最容易出错的地方。4. 完整实战用 NumPy 从零实现 MLP 手写数字识别4.1 创建项目结构完整的项目结构如下mlp-from-scratch/ ├── data/ │ ├── train-images.idx3-ubyte │ ├── train-labels.idx1-ubyte │ ├── t10k-images.idx3-ubyte │ └── t10k-labels.idx1-ubyte ├── utils.py ├── nn.py └── train.py其中 data 目录存放 MNIST 数据集原始文件。MNIST 数据集可以从常见的公共数据集源下载注意需要将四个 IDX 文件解压到 data 目录。4.2 数据读取工具 utils.pyMNIST 原始格式是 IDX 二进制文件需要按字节解析。第一个 4 字节是 magic number用于区分图片文件和标签文件第二个 4 字节是样本数量图片文件还有两个 4 字节的尺寸信息随后是像素数据。# 文件路径mlp-from-scratch/utils.py import struct import numpy as np def read_idx(filepath): 读取 MNIST 的 IDX 格式文件。 根据 magic number 自动判断是图片文件还是标签文件。 with open(filepath, rb) as f: magic struct.unpack(I, f.read(4))[0] num struct.unpack(I, f.read(4))[0] if magic 2051: # 图片文件 rows struct.unpack(I, f.read(4))[0] cols struct.unpack(I, f.read(4))[0] data np.frombuffer(f.read(), dtypenp.uint8) data data.reshape(num, rows, cols) elif magic 2049: # 标签文件 data np.frombuffer(f.read(), dtypenp.uint8) else: raise ValueError(未知的 magic number: {}.format(hex(magic))) return data def load_mnist(data_dirdata): 加载 MNIST 数据集并返回训练集和测试集。 X_train read_idx(data_dir /train-images.idx3-ubyte) y_train read_idx(data_dir /train-labels.idx1-ubyte) X_test read_idx(data_dir /t10k-images.idx3-ubyte) y_test read_idx(data_dir /t10k-labels.idx1-ubyte) # 将 28x28 图片展平成 784 维向量并归一化到 [0, 1] X_train X_train.reshape(X_train.shape[0], -1) / 255.0 X_test X_test.reshape(X_test.shape[0], -1) / 255.0 return X_train, y_train, X_test, y_test归一化到 [0, 1] 非常重要。原始像素值范围是 0 到 255如果不缩放到较小的数值范围神经网络前期的加权求和很容易得到很大的值导致激活函数进入饱和区梯度更新缓慢。4.3 构建 MLP 网络 nn.py本小节使用 NumPy 实现一个包含两个隐藏层的 MLP。网络结构设计如下输入层784 个节点对应 28×28 像素隐藏层一128 个节点使用 ReLU 激活隐藏层二64 个节点使用 ReLU 激活输出层10 个节点使用 Softmax 激活。权重初始化使用标准差为 0.01 的随机正态分布偏置初始化为全 0。# 文件路径mlp-from-scratch/nn.py import numpy as np def relu(z): return np.maximum(0, z) def relu_derivative(z): return (z 0).astype(np.float64) def softmax(z): 数值稳定的 Softmax。 先减去每行的最大值避免 exp 溢出。 z 的形状是 (batch_size, num_classes) z_shifted z - np.max(z, axis1, keepdimsTrue) exp_z np.exp(z_shifted) return exp_z / np.sum(exp_z, axis1, keepdimsTrue) class MLP: def __init__(self, input_size, hidden_sizes, output_size): 初始化网络结构和参数。 input_size: 输入维度MNIST 是 784 hidden_sizes: 隐藏层维度列表例如 [128, 64] output_size: 输出类别数MNIST 是 10 layer_sizes [input_size] list(hidden_sizes) [output_size] self.weights [] self.biases [] for i in range(len(layer_sizes) - 1): # 小随机初始化可以避免神经元一开始就饱和 w np.random.randn(layer_sizes[i], layer_sizes[i 1]) * 0.01 b np.zeros((1, layer_sizes[i 1])) self.weights.append(w) self.biases.append(b) self.layer_sizes layer_sizes def forward(self, X): 前向传播返回输出层概率。 同时缓存每一层的输入 A 和线性输出 Z供反向传播使用。 self.A [X] # 存储每一层的激活值A[0] 是输入层 self.Z [] # 存储每一层线性变换的结果 layer_num len(self.weights) for i in range(layer_num): Z self.A[-1] self.weights[i] self.biases[i] self.Z.append(Z) if i layer_num - 1: A softmax(Z) else: A relu(Z) self.A.append(A) return self.A[-1] def predict(self, X): 返回预测类别形状为 (n_samples,) prob self.forward(X) return np.argmax(prob, axis1) def backward(self, X, y, output): 反向传播返回所有参数的梯度。 这里的关键是 Softmax CrossEntropy 的组合梯度 dL/dz output - y_onehot 然后逐层利用链式法则回传。 m X.shape[0] # 构造 one-hot 标签 y_onehot np.zeros_like(output) y_onehot[np.arange(m), y] 1 # 输出层误差信号 dZ output - y_onehot dW_list [] db_list [] # 从最后一层开始往前遍历 for i in range(len(self.weights) - 1, -1, -1): A_prev self.A[i] # 损失对权重和偏置的梯度 dW (A_prev.T dZ) / m db np.sum(dZ, axis0, keepdimsTrue) / m dW_list.append(dW) db_list.append(db) # 如果不是第一层继续向前回传误差 if i 0: dA dZ self.weights[i].T dZ dA * relu_derivative(self.Z[i - 1]) # 因为是从后往前计算的需要反转后按顺序返回 dW_list.reverse() db_list.reverse() return dW_list, db_list def update_parameters(self, dW_list, db_list, lr): 使用梯度下降更新参数 for i in range(len(self.weights)): self.weights[i] - lr * dW_list[i] self.biases[i] - lr * db_list[i] def train_step(self, X_batch, y_batch, lr): 在单个 batch 上执行一次前向、反向、更新 output self.forward(X_batch) # 计算交叉熵损失 log_probs -np.log(output[np.arange(X_batch.shape[0]), y_batch] 1e-12) loss np.mean(log_probs) dW_list, db_list self.backward(X_batch, y_batch, output) self.update_parameters(dW_list, db_list, lr) return loss def accuracy(model, X, y): preds model.predict(X) return np.mean(preds y)有几个实现细节值得说明前向传播时用self.A和self.Z缓存了每层结果。反向传播需要用到这些中间值尤其是 ReLU 的导数计算依赖前向传播时的 ( z )Softmax 和交叉熵组合的梯度是output - y_onehot这是一个非常简洁的结果也正是很多框架里logits和labels直接相减的来源梯度计算中除以m是为了使用平均梯度这样梯度大小不受 batch size 影响 1e-12是为了防止log(0)导致数值溢出。4.4 训练主程序 train.py训练过程包括三个关键点每个 epoch 前随机打乱训练数据增强随机性数据分成小批次batch每个批次计算一次梯度并更新参数记录损失和准确率变化判断模型收敛情况。# 文件路径mlp-from-scratch/train.py import numpy as np from utils import load_mnist from nn import MLP, accuracy def train(model, X_train, y_train, epochs10, batch_size64, lr0.1): n X_train.shape[0] history [] for epoch in range(epochs): # 每个 epoch 重新打乱数据 indices np.random.permutation(n) epoch_loss 0.0 num_batches 0 for start in range(0, n, batch_size): end min(start batch_size, n) batch_idx indices[start:end] batch_loss model.train_step( X_train[batch_idx], y_train[batch_idx], lrlr ) epoch_loss batch_loss num_batches 1 avg_loss epoch_loss / num_batches train_acc accuracy(model, X_train, y_train) history.append((avg_loss, train_acc)) print(fEpoch {epoch 1}/{epochs}, Loss: {avg_loss:.4f}, fTrain Acc: {train_acc:.4f}) return history if __name__ __main__: # 固定随机种子保证可复现 np.random.seed(42) print(加载 MNIST 数据集...) X_train, y_train, X_test, y_test load_mnist(data_dirdata) print(f训练集大小: {X_train.shape[0]}, 测试集大小: {X_test.shape[0]}) # 从训练集中切分出一部分作为验证集 val_size 5000 X_val, y_val X_train[:val_size], y_train[:val_size] X_train, y_train X_train[val_size:], y_train[val_size:] model MLP(input_size784, hidden_sizes[128, 64], output_size10) print(模型结构:, model.layer_sizes) print(开始训练...) history train(model, X_train, y_train, epochs10, batch_size64, lr0.1) # 验证集评估 val_acc accuracy(model, X_val, y_val) print(f验证集准确率: {val_acc:.4f}) # 测试集评估 test_acc accuracy(model, X_test, y_test) print(f测试集准确率: {test_acc:.4f})4.5 运行与预期结果在项目根目录下执行python train.py运行后输出类似加载 MNIST 数据集... 训练集大小: 60000, 测试集大小: 10000 模型结构: [784, 128, 64, 10] 开始训练... Epoch 1/10, Loss: 1.8731, Train Acc: 0.6845 Epoch 2/10, Loss: 1.2360, Train Acc: 0.8421 Epoch 3/10, Loss: 0.9554, Train Acc: 0.8871 ... Epoch 10/10, Loss: 0.5812, Train Acc: 0.9362 验证集准确率: 0.9380 测试集准确率: 0.9340实际输出会因随机种子和数据版本略有波动但整体趋势一致损失逐步下降训练准确率逐步上升最终测试准确率在 0.93 左右。对一个不包含卷积结构、完全用 NumPy 实现的 MLP 来说0.93 的准确率已经是一个很好结果。它验证了核心逻辑的正确性。如果希望继续提升可以从增加网络宽度、加深层数、调整学习率、加入正则化等方向入手。5. 常见问题与排查思路自己从零实现神经网络时遇到 bug 的概率远高于调框架。下面整理了一些高频问题和排查思路。问题现象常见原因解决思路Loss 一直不下降学习率过大导致震荡或学习率过小导致收敛极慢尝试多个学习率从 0.1、0.01、0.001 中逐步筛选Loss 初始就是 2.3 左右很长时间不变化相当于模型在“乱猜”梯度可能没有有效回传检查权重是否初始化为全 0检查 ReLU 导数后回传是否被清零训练准确率低于 0.5数据未归一化、标签错位确认像素是否除以 255确认 label 从 0 开始而非 1训练准确率高、验证准确率低过拟合增加数据量、加入 L2 正则化、使用 Dropout、早停Softmax 结果出现 NaNexp 运算溢出使用数值稳定的 Softmax先减去每行最大值再求 exp反向传播梯度失真前向传播未缓存中间变量或 ReLU 导数位置错误打印每层梯度均值确认形状是否与权重一致训练过程中 Loss 出现剧烈波动batch size 太小导致梯度噪声大增大 batch size或适当降低学习率其中“Loss 初始为 2.3”这个现象尤为常见。原因是 10 分类任务初始 Softmax 概率接近均匀分布每个类别的概率约为 0.1交叉熵损失就是 ( -\log(0.1) \approx 2.3026 )。如果你的 Loss 初始值远高于 2.3说明网络初始化或者数据预处理可能有问题如果 Loss 一直停在 2.3 附近不动说明梯度没有正常流动。排查时最有效的方法是“梯度验证”。随机取一个很小的网络用解析梯度与数值梯度对比[ \frac{\partial L}{\partial w} \approx \frac{L(w h) - L(w - h)}{2h} ]如果两者的误差在 ( 10^{-5} ) 量级说明反向传播实现正确。6. 工程实践建议6.1 数据预处理是第一步也是最容易被忽略的一步图像数据通常需要归一化到 [0, 1] 或使用均值-标准差标准化。对神经网络来说输入分布在数量级上差距过大会导致梯度更新不稳定。MNIST 这种简单数据集除以 255 就够了但更复杂的图像数据集通常需要更复杂的预处理流程比如随机裁剪、旋转、色彩抖动等数据增强。6.2 权重初始化是训练收敛的关键全零初始化会让同层所有神经元得到相同的梯度导致“对称性破缺”问题网络永远不会分化出不同的特征。本文中使用标准差为 0.01 的随机初始化对较小的 MLP 是可行的。实际工程中更推荐适用更广的 Xavier 初始化或 He 初始化它们会根据上一层和下一层的维度调整初始分布让信号在网络中传播时保持合理范围。6.3 Batch Size 和学习率要配套调整batch size 过小时梯度波动大训练不稳定batch size 过大时训练稳定但每一步的计算开销大且可能收敛到较差的局部最优。实际项目通常从 32、64、128 中选择。值得注意的是调整 batch size 后常常也需要相应调整学习率二者共同影响梯度更新的步伐。6.4 固定随机种子训练过程涉及数据打乱和随机初始化如果不固定随机种子实验结果可能每次都不一样问题定位会非常痛苦。代码中np.random.seed(42)就是为了保证可复现。实际项目中建议把随机种子作为配置项写入配置文件中。6.5 保存模型权重和训练日志训练阶段应该定期保存模型权重最优的模型通常出现在验证集准确率最高的时候而不是训练最后一轮。训练日志可以记录每个 epoch 的损失、训练准确率、验证准确率方便可视化分析训练过程。6.6 从 NumPy 到深度学习框架的思维迁移理解 NumPy 实现后迁移到 PyTorch 会很自然。PyTorch 中的nn.Linear对应这里的权重矩阵和偏置nn.ReLU对应这里的 ReLU 层nn.CrossEntropyLoss内部已经完成了 Softmax 和交叉熵的组合。当你理解底层机制后框架代码就不再是“天书”而是对底层逻辑的高效封装。7. 总结与后续学习路线用 NumPy 从零写完一个完整可运行的神经网络最大的收获并不是“我实现了一个模型”而是真正理解了神经网络训练中最核心的三件事线性变换加非线性激活构成了特征提取的基本单元交叉熵损失配合 Softmax 给出了简洁优雅的输出层梯度反向传播通过链式法则把损失信号从输出层一层层传回输入层让每个参数都能得到合理的梯度。这套原理正是后续学习卷积神经网络CNN、目标检测、图像分割等计算机视觉知识的地基。CNN 中的卷积操作本质上是在局部区域做加权求和池化操作是下采样而训练过程依然完全遵循“前向传播→反向传播→参数更新”这一套框架。建议下一步用 PyTorch 重建同一个 MLP对比框架 API 和手写实现的对应关系然后学习 CNN 的核心思想理解卷积核如何共享参数、如何提取局部特征深入学习时再逐步接触 Batch Normalization、Dropout、残差连接等工程技巧。如果本文对你有帮助建议自己动手运行一遍代码把 MNIST 换成 F-MNIST 或者其他小规模数据集试试观察不同网络结构下模型的准确率变化。对“第一性原理”的理解永远是在亲手实践、踩坑和调试中完成的。
返回列表