资讯中心

从零实现神经网络:用NumPy手写前向传播与反向传播

📅 2026/8/12 10:05:40
从零实现神经网络:用NumPy手写前向传播与反向传播
1. 项目概述从零搭建一个可运行的神经网络如果你对“神经网络”这个词既感到好奇又有点望而生畏觉得它背后是深奥的数学和复杂的框架那今天这篇分享或许能改变你的看法。我的目标很简单不依赖任何深度学习框架比如TensorFlow或PyTorch只用最基础的Python和NumPy库从数学原理开始亲手搭建一个能真正学习、能做出预测的神经网络。这个过程就像在搭乐高我们得先理解每一块积木神经元、权重、激活函数的形状和作用再把它们按照特定的结构前向传播、反向传播组装起来最终让这个结构具备“智能”。这个项目适合所有对机器学习有初步兴趣并且具备基本Python编程能力的朋友。无论你是想夯实基础的学生还是希望理解模型“黑箱”背后机制的开发者通过亲手实现一遍你会对梯度下降、损失函数、链式法则这些核心概念有刻骨铭心的理解。这远比调用几行model.fit()来得深刻。我们将实现一个经典的多层感知机MLP用它来学习一个简单的模式比如异或问题并附上每一行都有详细注释的完整代码。你会发现神经网络的本质其实就是一连串精心设计的矩阵运算和梯度计算。2. 核心原理拆解神经网络的“发动机”是如何工作的在动手写代码之前我们必须把核心的数学原理捋清楚。一个最简单的神经网络主要由三部分构成输入层、隐藏层至少一层、输出层。信息从输入层流入经过隐藏层变换最终从输出层产生结果这个过程叫前向传播。而让网络能够学习的关键则是反向传播算法它负责根据预测结果和真实值之间的误差来调整网络内部的参数。2.1 前向传播信息是如何流动的前向传播就是一次计算预测值的过程。我们以单隐藏层网络为例假设输入数据X是一个矩阵隐藏层有n_hidden个神经元输出层有n_output个神经元。输入到隐藏层输入数据X首先与权重矩阵W1相乘再加上偏置向量b1得到隐藏层的加权输入Z1 X · W1 b1。这里的W1的维度是(n_input, n_hidden)它决定了每个输入特征对每个隐藏神经元的重要性。激活函数引入非线性直接使用Z1是线性的多个线性变换叠加依然是线性变换无法拟合复杂模式。因此我们需要一个非线性激活函数比如Sigmoid或ReLU。我们计算隐藏层的激活值A1 activation_function(Z1)。非线性激活函数是神经网络能够逼近任意函数的基础。隐藏层到输出层将A1视为新的输入与输出层权重W2相乘并加上偏置b2得到输出层的加权输入Z2 A1 · W2 b2。输出层激活对于不同的任务输出层的激活函数不同。比如二分类问题常用Sigmoid将输出压缩到0-1之间表示概率多分类用Softmax回归问题则可能不用激活函数线性输出。我们得到最终的预测输出A2 output_activation(Z2)。注意权重矩阵的初始化至关重要。不能全部初始化为0否则所有神经元在反向传播时会获得相同的梯度导致对称失效无法学习。通常采用小的随机数比如np.random.randn(*shape) * 0.01。2.2 反向传播与梯度下降网络是如何学习的网络做出了预测A2但一开始肯定不准。我们需要一个标准来衡量“不准”的程度这就是损失函数Loss Function。例如对于二分类的交叉熵损失为L - (y * log(A2) (1-y) * log(1-A2))的平均值。我们的目标就是通过调整W1, b1, W2, b2让损失L最小化。梯度下降告诉我们要最小化一个函数就沿着它梯度的反方向更新参数。梯度指向函数增长最快的方向反方向就是下降最快的方向。反向传播就是高效计算损失函数对于网络中每一个参数W, b的梯度偏导数的算法。它巧妙地利用了链式法则从输出层开始逐层向后反向传播误差并计算梯度。以输出层权重W2的梯度计算为例计算损失L对网络输出A2的梯度dA2。根据A2的激活函数如Sigmoid计算A2对其输入Z2的梯度dZ2 dA2 * activation_derivative(Z2)。根据Z2 A1 · W2 b2利用链式法则L对W2的梯度dW2就等于A1.T · dZ2这里A1.T是A1的转置。同理L对b2的梯度db2就是dZ2在样本维度上的求和因为偏置对每个样本的贡献一样。得到梯度dW2, db2后我们就可以用最朴素的梯度下降法更新参数W2 W2 - learning_rate * dW2。learning_rate学习率是一个超参数控制每次更新的步长太小则学习慢太大可能无法收敛甚至发散。隐藏层参数W1, b1的梯度计算过程类似只是误差信号需要从dZ2继续通过W2反向传播到A1再传播到Z1最终得到dW1和db1。这个过程就像一层层地将输出层的“责任”误差分配回前面的层。3. 关键组件实现激活函数、损失函数与初始化理解了原理我们就可以用代码把这些核心组件实现出来。这些函数将是我们神经网络类的基石。3.1 激活函数及其导数激活函数给网络引入了非线性。这里我们实现两个最常用的import numpy as np def sigmoid(x): Sigmoid激活函数将输入压缩到(0,1)区间。 return 1 / (1 np.exp(-x)) def sigmoid_derivative(x): Sigmoid函数的导数用于反向传播。 注意这里的输入x是sigmoid函数的输出值即asigmoid(z)。 根据求导公式da/dz a * (1 - a)。 return x * (1 - x) def relu(x): ReLU激活函数解决Sigmoid的梯度消失问题加速收敛。 return np.maximum(0, x) def relu_derivative(x): ReLU函数的导数输入大于0时为1否则为0。 return (x 0).astype(float)实操心得在反向传播计算梯度时我们通常已经得到了该层的激活值输出A。对于Sigmoid直接用A * (1 - A)计算导数效率最高无需重复计算sigmoid(z)。对于ReLU导数计算更简单但需要注意“死亡ReLU”问题即某些神经元可能永远不被激活。有时使用Leaky ReLUnp.maximum(0.01*x, x)可以缓解。3.2 损失函数及其导数损失函数衡量预测值与真实值的差距。我们实现均方误差MSE常用于回归和交叉熵损失用于分类def mse_loss(y_true, y_pred): 均方误差损失适用于回归问题。 return np.mean((y_true - y_pred) ** 2) def mse_loss_derivative(y_true, y_pred): MSE损失对预测值y_pred的导数。 return -2 * (y_true - y_pred) / y_true.size def binary_cross_entropy_loss(y_true, y_pred): 二分类交叉熵损失。 为了防止log(0)导致数值问题对y_pred进行数值稳定处理。 # 将y_pred限制在[epsilon, 1-epsilon]区间避免log(0) epsilon 1e-15 y_pred np.clip(y_pred, epsilon, 1 - epsilon) return -np.mean(y_true * np.log(y_pred) (1 - y_true) * np.log(1 - y_pred)) def binary_cross_entropy_loss_derivative(y_true, y_pred): 二分类交叉熵损失对预测值y_pred的导数。 推导后形式非常简洁dL/dy_pred (y_pred - y_true) / (y_pred * (1 - y_pred)) / n_samples 但同样需要数值稳定处理。 epsilon 1e-15 y_pred np.clip(y_pred, epsilon, 1 - epsilon) return (y_pred - y_true) / (y_pred * (1 - y_pred)) / y_true.size注意事项交叉熵损失函数中的对数计算在y_pred接近0或1时会产生极大的数值趋于无穷导致程序崩溃。因此必须使用np.clip将预测值限制在一个很小的非零区间内如[1e-15, 1-1e-15]这是实现中的关键细节也是新手常踩的坑。3.3 参数初始化策略参数的初始值不能随意设置。全零初始化会导致对称性问题大的随机数可能导致梯度爆炸尤其是配合Sigmoid时。常见的策略有def initialize_parameters(n_input, n_hidden, n_output, initializationhe): 初始化网络参数。 Args: initialization: random简单小随机数, xavier适用于Sigmoid/Tanh, he适用于ReLU。 np.random.seed(42) # 设置随机种子确保结果可复现 parameters {} if initialization random: # 简单缩放的小随机数 parameters[W1] np.random.randn(n_input, n_hidden) * 0.01 parameters[b1] np.zeros((1, n_hidden)) parameters[W2] np.random.randn(n_hidden, n_output) * 0.01 parameters[b2] np.zeros((1, n_output)) elif initialization xavier: # Xavier/Glorot初始化方差为 1/n_input scale np.sqrt(1.0 / n_input) parameters[W1] np.random.randn(n_input, n_hidden) * scale parameters[b1] np.zeros((1, n_hidden)) scale np.sqrt(1.0 / n_hidden) parameters[W2] np.random.randn(n_hidden, n_output) * scale parameters[b2] np.zeros((1, n_output)) elif initialization he: # He初始化方差为 2/n_input专为ReLU设计 scale np.sqrt(2.0 / n_input) parameters[W1] np.random.randn(n_input, n_hidden) * scale parameters[b1] np.zeros((1, n_hidden)) scale np.sqrt(2.0 / n_hidden) parameters[W2] np.random.randn(n_hidden, n_output) * scale parameters[b2] np.zeros((1, n_output)) return parameters为什么初始化这么重要以Sigmoid函数为例其导数在输入值很大或很小时会接近0梯度饱和区。如果初始权重过大线性变换后的Z很容易落入饱和区导致梯度极小参数几乎无法更新这就是“梯度消失”。He和Xavier初始化通过控制初始权重的方差使得各层激活值的分布保持在合理的范围内从而保障训练初期梯度的稳定流动。4. 神经网络类的完整实现与训练流程现在我们将所有部分组装成一个完整的神经网络类。这个类将封装前向传播、反向传播和参数更新的所有逻辑。4.1 神经网络类结构设计我们设计一个NeuralNetwork类它至少包含以下方法__init__: 初始化网络结构各层神经元数、超参数学习率、迭代次数和参数。_forward_propagation: 执行一次前向传播并缓存中间结果Z, A供反向传播使用。_backward_propagation: 执行一次反向传播计算所有参数的梯度。_update_parameters: 使用梯度下降法更新参数。fit: 训练模型在多个周期epoch内循环执行前向、反向传播和参数更新。predict: 使用训练好的模型进行预测。calculate_loss: 计算当前模型在给定数据上的损失。下面是这个类的骨架和核心方法实现class NeuralNetwork: def __init__(self, n_input, n_hidden, n_output, learning_rate0.1, epochs10000, initializationhe): 初始化神经网络。 Args: n_input: 输入特征数 n_hidden: 隐藏层神经元数 n_output: 输出层神经元数二分类为1 learning_rate: 学习率 epochs: 训练迭代次数 initialization: 参数初始化方法 self.n_input n_input self.n_hidden n_hidden self.n_output n_output self.lr learning_rate self.epochs epochs # 初始化参数 self.params initialize_parameters(n_input, n_hidden, n_output, initialization) # 缓存用于存储前向传播的中间结果方便反向传播 self.cache {} # 记录训练过程中的损失用于可视化 self.loss_history [] def _forward_propagation(self, X): 前向传播并缓存中间结果。 # 从参数字典中取出参数 W1, b1 self.params[W1], self.params[b1] W2, b2 self.params[W2], self.params[b2] # 输入层 - 隐藏层 Z1 np.dot(X, W1) b1 # 线性变换 A1 relu(Z1) # 非线性激活这里隐藏层使用ReLU # A1 sigmoid(Z1) # 也可以使用Sigmoid # 隐藏层 - 输出层 Z2 np.dot(A1, W2) b2 A2 sigmoid(Z2) # 输出层使用Sigmoid将结果映射到(0,1)适用于二分类 # 将中间结果存入缓存 self.cache[Z1], self.cache[A1] Z1, A1 self.cache[Z2], self.cache[A2] Z2, A2 return A2 def _backward_propagation(self, X, y, A2): 反向传播计算损失关于所有参数的梯度。 m X.shape[0] # 样本数量 # 从缓存中取出前向传播的结果 A1 self.cache[A1] W2 self.params[W2] # 输出层的梯度计算 # 损失函数对A2的导数交叉熵损失 Sigmoid激活的组合导数有简化形式 # dL/dZ2 A2 - y 这是Sigmoid输出层配合交叉熵损失的一个优美性质 dZ2 A2 - y dW2 (1 / m) * np.dot(A1.T, dZ2) db2 (1 / m) * np.sum(dZ2, axis0, keepdimsTrue) # 隐藏层的梯度计算 dA1 np.dot(dZ2, W2.T) # 隐藏层使用的是ReLU其导数为1 (if Z10) else 0 dZ1 dA1 * relu_derivative(self.cache[Z1]) # 如果隐藏层用的是Sigmoid则应为dZ1 dA1 * sigmoid_derivative(A1) dW1 (1 / m) * np.dot(X.T, dZ1) db1 (1 / m) * np.sum(dZ1, axis0, keepdimsTrue) # 将梯度存入字典 grads {dW1: dW1, db1: db1, dW2: dW2, db2: db2} return grads def _update_parameters(self, grads): 使用梯度下降更新参数。 self.params[W1] - self.lr * grads[dW1] self.params[b1] - self.lr * grads[db1] self.params[W2] - self.lr * grads[dW2] self.params[b2] - self.lr * grads[db2] def fit(self, X, y, verboseFalse, print_every1000): 训练神经网络。 Args: X: 训练特征形状 (n_samples, n_features) y: 训练标签形状 (n_samples, n_output) verbose: 是否打印训练过程 print_every: 每隔多少轮打印一次损失 for i in range(self.epochs): # 前向传播 A2 self._forward_propagation(X) # 计算损失这里使用交叉熵损失 loss binary_cross_entropy_loss(y, A2) self.loss_history.append(loss) # 反向传播 grads self._backward_propagation(X, y, A2) # 更新参数 self._update_parameters(grads) # 打印进度 if verbose and i % print_every 0: print(fEpoch {i}, Loss: {loss:.6f}) def predict(self, X, threshold0.5): 使用训练好的模型进行预测并可根据阈值进行二分类。 A2 self._forward_propagation(X) # 将概率转换为类别0或1 predictions (A2 threshold).astype(int) return predictions, A2 # 同时返回概率值 def calculate_accuracy(self, X, y): 计算模型在给定数据上的准确率。 predictions, _ self.predict(X) accuracy np.mean(predictions y) return accuracy4.2 训练一个实例解决异或XOR问题异或问题是一个经典的线性不可分问题单层感知机无法解决但两层神经网络可以。这是一个完美的测试用例。# 1. 准备数据异或XOR问题的输入和输出 # 输入: (0,0)-0, (0,1)-1, (1,0)-1, (1,1)-0 X np.array([[0, 0], [0, 1], [1, 0], [1, 1]]) y np.array([[0], [1], [1], [0]]) # 注意保持形状 (4, 1) # 2. 创建并训练神经网络 nn NeuralNetwork(n_input2, n_hidden4, n_output1, learning_rate0.1, epochs10000) nn.fit(X, y, verboseTrue, print_every2500) # 3. 评估模型 print(\n训练完成) print(最终损失:, nn.loss_history[-1]) print(在训练集上的准确率:, nn.calculate_accuracy(X, y)) # 4. 查看预测结果 predictions, probabilities nn.predict(X) print(\n输入样本:) print(X) print(预测概率:) print(probabilities) print(预测类别 (阈值0.5):) print(predictions) print(真实标签:) print(y)运行这段代码你会看到损失随着训练轮数增加而稳步下降最终准确率达到100%。网络成功学会了异或的逻辑。你可以尝试调整隐藏层神经元数量n_hidden、学习率learning_rate和训练轮数epochs观察它们对训练速度和结果的影响。5. 高级话题与优化技巧实现了一个基础版本后我们可以探讨一些让神经网络更强大、更稳定的技术和概念。5.1 处理过拟合正则化与Dropout当模型在训练集上表现很好但在未见过的数据上表现很差时就发生了过拟合。以下是两种常用技术L2正则化在损失函数中增加一个惩罚项等于所有权重平方和的乘以一个系数λ。这倾向于让权重值变小、更分散从而简化模型。# 在损失计算中增加L2惩罚项 lambda_reg 0.01 # 正则化强度 mse np.mean((y_pred - y_true)**2) # 计算所有权重W1, W2的L2范数平方和 l2_penalty (lambda_reg / (2 * m)) * (np.sum(W1**2) np.sum(W2**2)) loss mse l2_penalty # 在反向传播计算梯度时需要加上正则化项的梯度 # 例如dW2 原本是 dW2 (1/m) * A1.T.dot(dZ2) # 加上L2正则化后dW2 (1/m) * A1.T.dot(dZ2) (lambda_reg / m) * W2Dropout在训练过程中随机“丢弃”即暂时忽略一部分神经元例如50%。这可以防止神经元之间产生复杂的协同适应迫使网络学习更鲁棒的特征。在预测时不使用Dropout但要将所有神经元的输出乘以Dropout的保留概率如0.5以保持输出的期望值不变。5.2 梯度下降优化器我们之前使用的是最基础的批量梯度下降Batch Gradient Descent它使用整个训练集计算梯度更新一次。这在大数据集上非常慢。更常用的变体有随机梯度下降SGD每次只用一个样本计算梯度并更新速度快但波动大。小批量梯度下降Mini-batch GD折中方案每次使用一个小批量如32、64个样本。这是工业界的标准做法。带动量的SGD在更新时不仅考虑当前梯度还加入上一次更新的方向有助于加速收敛并减少震荡。Adam结合了动量Momentum和自适应学习率RMSProp的优点是目前最流行、默认推荐的优化器。在我们的简单实现中可以尝试实现小批量梯度下降。需要在fit方法中增加数据打乱和分批的逻辑。5.3 超参数调优实战神经网络的性能极大地依赖于超参数的选择。没有银弹需要系统性地尝试网格搜索Grid Search为每个超参数如学习率、隐藏层大小、正则化强度设定一组候选值遍历所有组合。计算量大但彻底。随机搜索Random Search在超参数空间内随机采样。研究表明对于某些对性能影响差异大的参数随机搜索比网格搜索更高效。学习率衰减Learning Rate Decay训练初期使用较大的学习率快速下降后期使用较小的学习率精细调整。策略可以是每隔一定轮数将学习率乘以一个衰减因子如0.95或者根据验证集损失动态调整。一个简单的学习率衰减可以这样加入训练循环initial_lr 0.1 decay_rate 0.95 decay_steps 1000 for epoch in range(epochs): # 计算当前学习率 current_lr initial_lr * (decay_rate ** (epoch // decay_steps)) # ... 前向传播、反向传播 ... # 在更新参数时使用 current_lr self.params[W1] - current_lr * grads[dW1] # ...6. 从零实现到实际应用的思考亲手实现一遍之后你可能会问既然有TensorFlow、PyTorch这样成熟高效的框架为什么还要从零开始我的体会是这个过程的价值不在于造一个比框架更好的轮子而在于彻底理解车轮是如何转动的。当你以后在使用高级API遇到梯度消失、爆炸或者模型不收敛时你脑海中对反向传播链式法则的清晰图景能帮助你快速定位问题。当你在调整学习率、初始化方法时你能理解每一个选择背后的数学直觉。更重要的是它消除了对深度学习“黑箱”的恐惧让你明白再复杂的模型其基石也不过是微积分、线性代数和概率论。当然对于实际项目我们绝对应该使用成熟的框架。它们提供了自动微分无需我们手动推导梯度公式、GPU加速、丰富的预训练模型和便捷的数据管道。从零实现的经历会让你成为一个更自信、更深刻的使用者。你可以把这里的代码看作一个“教学用具”它的使命是让你理解原理。理解了原理之后就大胆地去用PyTorch的nn.Module和optim.Adam吧那才是生产力工具。最后如果你想挑战自己可以基于这个基础版本尝试以下扩展增加更多的隐藏层实现深度网络、实现不同的优化器如SGD with Momentum、添加Batch Normalization层、或者尝试用这个网络解决一个更实际的小数据集分类问题如鸢尾花数据集。每一个扩展都会让你对神经网络的理解更深一层。