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

发布时间:2026/10/26 7:58:04
从零手写前馈神经网络:NumPy实现与反向传播详解

1. 从零搭建前馈神经网络的核心设计思路

1.1 为什么第二篇要死磕“前馈”而不是直接上CNN

很多人学神经网络有个通病:一上来就冲着卷积神经网络、LSTM、Transformer去,觉得前馈网络太基础、没意思。但我自己踩过的坑告诉我,前馈神经网络(Feedforward Neural Network)是所有复杂结构的“母体”。你把前馈网络的每一个环节吃透了,后面学CNN无非是加了个卷积层做特征提取,学RNN无非是加了个时间维度的循环连接,底层的那套“前向传播算输出、反向传播调权重”的逻辑完全一样。

这篇内容的核心目标很明确:不依赖任何深度学习框架,只用Python的基础语法和NumPy,从零手写一个完整的前馈神经网络,包括初始化权重、前向传播、损失计算、反向传播、参数更新这五个核心模块。写完之后,你会对“神经网络到底在干什么”有一个肌肉记忆级别的理解,而不是停留在“调包侠”的层面。

适合谁来参考?如果你已经看过第一篇的内容,对神经元、激活函数、权重矩阵这些概念有了初步印象,但还不太清楚它们是怎么串起来工作的,那这篇就是为你准备的。如果你完全零基础,建议先把Python的基本语法和NumPy的数组操作过一遍,不然看代码会比较吃力。

1.2 整体架构:三层结构够用,但每一层都要写透

我这次搭的网络结构是输入层 → 一个隐藏层 → 输出层,也就是最经典的三层前馈网络。为什么只用一个隐藏层?因为对于大多数入门级的分类和回归任务,单隐藏层已经能跑出不错的效果,而且代码量可控,方便你逐行理解。等你把单隐藏层吃透了,想扩展到多层,无非是把前向传播和反向传播的链条拉长,逻辑是一样的。

具体来说,我设定的网络结构是这样的:

  • 输入层:接收特征向量,维度根据数据集来定。比如用经典的鸢尾花数据集,输入维度就是4。
  • 隐藏层:设置若干个神经元,用ReLU作为激活函数。ReLU的好处是计算简单、不容易出现梯度消失,而且求导极其方便——大于0的部分导数就是1,小于等于0的部分导数就是0。
  • 输出层:如果是三分类任务,输出维度就是3,用Softmax把输出转成概率分布;如果是二分类,输出维度是1,用Sigmoid。

这个结构的选择背后有明确的考量。ReLU + Softmax的组合在分类任务中是最稳妥的起点,ReLU负责在隐藏层提供非线性表达能力,Softmax负责把原始得分转成可解释的概率。你可能会问为什么不用Sigmoid做隐藏层的激活函数,原因很简单:Sigmoid在输入较大或较小时导数趋近于0,反向传播时梯度会一层层衰减,导致前面的层几乎学不到东西,这就是经典的“梯度消失”问题。ReLU在正区间导数恒为1,梯度可以畅通无阻地传回去,训练效率高得多。

1.3 权重初始化:别小看这一步,初始化不好训练直接废

权重初始化是很多人容易忽略的环节,但它对训练能否收敛影响极大。我试过两种初始化方式,这里都讲一下。

第一种是小随机数初始化,也就是用np.random.randn生成标准正态分布的随机数,再乘以一个很小的系数(比如0.01)。这种方式简单直接,但如果网络层数多了,后面的层输出会越来越小,梯度也会越来越小。

第二种是He初始化,专门为ReLU激活函数设计的。具体做法是生成的随机数乘以sqrt(2 / 输入维度)。为什么是2?因为ReLU把一半的神经元输出置零了,相当于有效神经元数量减半,所以方差要乘以2来补偿。实测下来,He初始化在ReLU网络中的收敛速度和最终精度都明显优于简单的小随机数初始化。

偏置项我统一初始化为0,这是常规做法,没什么争议。偏置的作用是给神经元一个可学习的“阈值”,让它即使输入全为0也能有非零输出,增加了模型的灵活性。

2. 前向传播与反向传播的代码级拆解

2.1 前向传播:数据从输入流到输出的完整路径

前向传播的逻辑其实很直观:输入数据经过第一层的权重矩阵做线性变换,加上偏置,再过激活函数,得到隐藏层的输出;隐藏层的输出再经过第二层的权重矩阵做线性变换,加上偏置,得到输出层的原始得分;最后经过Softmax转成概率。

用矩阵运算来表达,假设输入是X(形状为batch_size × input_dim),第一层的权重是W1(形状为input_dim × hidden_dim),偏置是b1(形状为hidden_dim),那么隐藏层的线性输出就是Z1 = X @ W1 + b1,经过ReLU激活后得到A1 = relu(Z1)。第二层的权重是W2(形状为hidden_dim × output_dim),偏置是b2(形状为output_dim),输出层的线性输出就是Z2 = A1 @ W2 + b2,经过Softmax后得到概率分布A2 = softmax(Z2)。

这里有个细节值得注意:偏置b1和b2的形状是一维数组,而Z1和Z2是二维矩阵。NumPy的广播机制会自动把一维数组扩展到每一行,所以X @ W1 + b1是合法的。但如果你自己实现的时候不小心把偏置搞成了二维的列向量,广播的方向就可能出错,导致结果形状不对。我建议在写代码时养成习惯,每一步都打印一下张量的形状,确认无误再往下走。

Softmax的实现也有个小坑。直接按照公式exp(x) / sum(exp(x))来算,如果x的数值比较大,exp(x)会溢出变成无穷大。解决办法是先减去每行的最大值,即exp(x - max(x)) / sum(exp(x - max(x))),这样数值稳定性会好很多。这个技巧在实际工程中非常常用,一定要记住。

2.2 损失函数:交叉熵为什么是分类任务的首选

分类任务最常用的损失函数是交叉熵损失(Cross-Entropy Loss)。它的公式是L = -sum(y_true * log(y_pred)),其中y_true是真实标签的独热编码,y_pred是模型输出的概率分布。

为什么用交叉熵而不是均方误差?从直观上理解,交叉熵衡量的是两个概率分布之间的差异。当模型预测的概率分布和真实分布越接近,交叉熵就越小。更重要的是,交叉熵配合Softmax的导数形式非常简洁:输出层的梯度就是y_pred - y_true,这个形式极其优雅,计算量小,而且梯度的大小直接反映了预测偏离真实值的程度,预测越离谱梯度越大,收敛越快。

如果用均方误差配合Softmax,导数会多出一个Softmax的导数项,形式复杂不说,还容易在预测值接近0或1时出现梯度消失。所以分类任务默认用交叉熵,这是经过大量实践验证的最优选择。

2.3 反向传播:链式法则的工程化实现

反向传播是整个神经网络中最核心也最容易出错的部分。它的本质就是链式法则:从损失函数开始,逐层往回求梯度,每一层的梯度等于后一层的梯度乘以本层的局部梯度。

具体到我们的三层网络,反向传播的流程是这样的:

首先算输出层的梯度。对于Softmax + 交叉熵的组合,输出层的误差项dZ2 = A2 - Y,其中A2是预测概率,Y是真实标签的独热编码。这个形式前面已经说过了,非常简洁。

然后算第二层权重的梯度:dW2 = A1.T @ dZ2,偏置的梯度:db2 = sum(dZ2, axis=0)。这里的sum是对batch维度求和,因为偏置对每个样本都加了同一个值,所以梯度要把所有样本的贡献累加起来。

接着把误差传回隐藏层:dA1 = dZ2 @ W2.T,然后过ReLU的导数:dZ1 = dA1 * (Z1 > 0)。ReLU的导数就是大于0为1,小于等于0为0,用(Z1 > 0)这个布尔数组直接相乘就行,非常方便。

最后算第一层的权重梯度:dW1 = X.T @ dZ1,偏置梯度:db1 = sum(dZ1, axis=0)。

整个反向传播的过程就是不断地做矩阵乘法和逐元素乘法,把梯度从输出层一层层传回输入层。每一步的矩阵维度必须严格匹配,这是排查反向传播错误的第一原则。如果你发现梯度形状不对,百分之百是某一步的转置或者乘法顺序搞错了。

2.4 参数更新:梯度下降的变体与学习率的选择

参数更新最基础的方式是批量梯度下降(Batch Gradient Descent),也就是用整个训练集的梯度来更新一次参数。但实际中更常用的是小批量梯度下降(Mini-batch Gradient Descent),每次只用一小部分样本计算梯度并更新参数。这样做的好处是更新频率高、收敛快,而且引入的噪声有助于跳出局部最优。

学习率的选择是个经验活。设太大,损失会震荡甚至发散;设太小,收敛速度慢得让人抓狂。我的经验是从0.01开始试,如果损失下降太慢就调到0.1,如果损失震荡就降到0.001。另外,配合学习率衰减策略效果更好,比如每训练若干个epoch就把学习率乘以0.9,让模型在后期精细调整。

更新公式很简单:W = W - learning_rate * dW,b = b - learning_rate * db。就这一行代码,反复执行,网络的参数就会逐渐逼近最优解。

3. 完整实操流程与关键环节实现

3.1 环境准备与依赖安装

在开始写代码之前,先把环境搭好。我用的Python版本是3.8以上,NumPy是必须的,用来做矩阵运算。如果你还没装NumPy,在命令行里执行pip install numpy就行。另外建议装个Matplotlib,用来画损失曲线,直观地看训练效果。

pip install numpy matplotlib

如果你用的是Anaconda,这些库基本都自带了,不用额外装。环境搭好之后,新建一个.py文件,导入必要的库:

import numpy as np import matplotlib.pyplot as plt

就这两行,不需要任何深度学习框架。从零手写的意义就在于,你清楚地知道每一行代码在做什么,没有任何黑盒。

3.2 数据准备:用鸢尾花数据集做三分类

我选的是经典的鸢尾花数据集,150个样本,4个特征,3个类别。这个数据集小、干净、类别平衡,非常适合用来验证网络实现是否正确。你可以从scikit-learn里直接加载,也可以手动下载CSV文件读取。

from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.preprocessing import OneHotEncoder, StandardScaler iris = load_iris() X = iris.data y = iris.target.reshape(-1, 1) encoder = OneHotEncoder(sparse=False) y_onehot = encoder.fit_transform(y) scaler = StandardScaler() X_scaled = scaler.fit_transform(X) X_train, X_test, y_train, y_test = train_test_split( X_scaled, y_onehot, test_size=0.2, random_state=42 )

这里做了两件重要的事:特征标准化和标签独热编码。特征标准化是把每个特征减去均值再除以标准差,让所有特征在同一量级上。如果不做这一步,数值范围大的特征会主导权重的更新,导致训练效果差。独热编码是把类别标签转成向量形式,比如类别2转成[0, 0, 1],这样才能和Softmax的输出做交叉熵计算。

3.3 网络类的完整实现

我把整个网络封装成一个类,包含初始化、前向传播、反向传播、训练、预测这几个方法。下面逐段说明。

class NeuralNetwork: def __init__(self, input_dim, hidden_dim, output_dim): self.W1 = np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim) self.b1 = np.zeros(hidden_dim) self.W2 = np.random.randn(hidden_dim, output_dim) * np.sqrt(2.0 / hidden_dim) self.b2 = np.zeros(output_dim)

初始化部分用了He初始化,权重乘以sqrt(2 / 输入维度),偏置全部置零。这个初始化方式在ReLU网络中表现最好,前面已经解释过原因。

def forward(self, X): self.Z1 = X @ self.W1 + self.b1 self.A1 = np.maximum(0, self.Z1) self.Z2 = self.A1 @ self.W2 + self.b2 self.A2 = self.softmax(self.Z2) return self.A2 def softmax(self, Z): exp_Z = np.exp(Z - np.max(Z, axis=1, keepdims=True)) return exp_Z / np.sum(exp_Z, axis=1, keepdims=True)

前向传播就是三步:线性变换加ReLU,再线性变换加Softmax。Softmax里减最大值是为了数值稳定,这个细节在实际跑数据时非常关键,不减的话遇到大数值直接溢出。

def backward(self, X, y_true): m = X.shape[0] dZ2 = self.A2 - y_true dW2 = self.A1.T @ dZ2 / m db2 = np.sum(dZ2, axis=0) / m dA1 = dZ2 @ self.W2.T dZ1 = dA1 * (self.Z1 > 0) dW1 = X.T @ dZ1 / m db1 = np.sum(dZ1, axis=0) / m return dW1, db1, dW2, db2

反向传播严格按照链式法则来,每一步都除以m做平均,这样学习率不会因为batch大小不同而需要重新调整。(self.Z1 > 0)是ReLU的导数,大于0为1,小于等于0为0,直接和dA1逐元素相乘。

def update_params(self, dW1, db1, dW2, db2, lr): self.W1 -= lr * dW1 self.b1 -= lr * db1 self.W2 -= lr * dW2 self.b2 -= lr * db2

参数更新就是简单的梯度下降,用学习率乘以梯度,从当前参数中减去。

def compute_loss(self, y_pred, y_true): m = y_true.shape[0] log_likelihood = -np.log(y_pred[range(m), np.argmax(y_true, axis=1)]) return np.sum(log_likelihood) / m

损失函数用的是交叉熵,只取真实类别对应的预测概率的负对数,然后求平均。这个实现比用sum(y_true * log(y_pred))更高效,因为独热编码里只有一个位置是1,其他都是0,乘了也是白乘。

3.4 训练循环与超参数设置

训练循环的逻辑是:每个epoch里做一次前向传播、算损失、反向传播、更新参数,然后打印损失。我设了1000个epoch,学习率0.1,隐藏层16个神经元。

input_dim = 4 hidden_dim = 16 output_dim = 3 lr = 0.1 epochs = 1000 nn = NeuralNetwork(input_dim, hidden_dim, output_dim) losses = [] for epoch in range(epochs): y_pred = nn.forward(X_train) loss = nn.compute_loss(y_pred, y_train) losses.append(loss) dW1, db1, dW2, db2 = nn.backward(X_train, y_train) nn.update_params(dW1, db1, dW2, db2, lr) if epoch % 100 == 0: print(f"Epoch {epoch}, Loss: {loss:.4f}")

跑下来损失会从初始的1.1左右逐渐降到0.05以下,训练集准确率能到98%以上。测试集上一般也能到95%以上,说明网络没有严重过拟合。

3.5 结果可视化与模型评估

训练完之后,画一条损失曲线,直观地看收敛过程:

plt.plot(losses) plt.xlabel("Epoch") plt.ylabel("Loss") plt.title("Training Loss Curve") plt.show()

评估模型的时候,把预测概率取argmax得到类别,再和真实标签比较:

y_pred_test = nn.forward(X_test) predictions = np.argmax(y_pred_test, axis=1) true_labels = np.argmax(y_test, axis=1) accuracy = np.mean(predictions == true_labels) print(f"Test Accuracy: {accuracy:.4f}")

这套流程跑通之后,你就有了一个完全从零实现的前馈神经网络。每一行代码都是你自己写的,每一个梯度都是你自己推的,这种理解深度是调包永远给不了的。

4. 常见问题与排查技巧实录

4.1 损失不下降或震荡怎么办

这是新手最常遇到的问题。我总结了几种典型情况和对应的排查思路。

现象可能原因解决办法
损失一直不降学习率太小把学习率调大10倍试试
损失震荡剧烈学习率太大把学习率调小10倍
损失降到某个值就不动了陷入局部最优换初始化方式,或加动量
损失变成NaN数值溢出检查Softmax是否减了最大值
损失先降后升过拟合加正则化或减少训练轮数

我自己的经验是,先检查学习率,再检查数据预处理。很多时候损失不下降不是网络结构的问题,而是输入数据没有标准化,导致梯度更新方向混乱。另外,如果损失变成NaN,九成是Softmax溢出或者学习率太大导致梯度爆炸。

4.2 梯度形状不匹配的排查方法

反向传播写错的时候,最常见的报错就是矩阵维度不匹配。我的排查方法是在每一步后面打印形状:

print("dZ2 shape:", dZ2.shape) print("W2 shape:", self.W2.shape) print("dA1 shape:", dA1.shape)

对照前向传播时的形状,反向传播的每一步应该和对应的前向步骤形状一致。比如dW2的形状应该和W2一样,db2的形状应该和b2一样。如果不一样,说明转置或者乘法顺序有问题。

还有一个隐蔽的坑:偏置的梯度要对batch维度求和。如果你忘了求和,db2的形状会是(batch_size, output_dim),和b2的(output_dim,)不匹配,更新的时候广播会出错。这个错误不会报异常,但训练效果会很差,因为偏置被错误地更新了。

4.3 过拟合的识别与应对

过拟合的典型表现是训练集准确率很高但测试集准确率明显偏低。我跑鸢尾花数据集的时候,如果隐藏层神经元设得太多(比如128个),训练集能到100%但测试集只有90%左右,这就是过拟合了。

应对过拟合有几种常用手段。减少隐藏层神经元数量是最直接的,把16个减到8个往往就能缓解。加L2正则化也有效,在损失函数里加上权重的平方和乘以一个系数,迫使权重保持较小值。早停法也很实用,在验证集损失开始上升时就停止训练,不要等到训练集损失降到最低。

我个人的习惯是先用较小的网络跑一遍,看效果如何,再逐步增加复杂度。从简到繁永远比从繁到简容易,因为简单的网络训练快、调试容易,能快速验证你的实现是否正确。

4.4 学习率调参的实操心得

学习率是神经网络里最重要的超参数,没有之一。我试过用固定学习率、步进衰减、指数衰减、余弦退火这几种策略,分享一下实际感受。

固定学习率最简单,但需要手动调到一个合适的值。对于鸢尾花这种小数据集,0.1到0.5之间都行。步进衰减是每隔几十个epoch把学习率乘以0.5或0.9,适合训练轮数较多的情况。指数衰减是每个epoch都乘一个衰减系数,曲线更平滑。余弦退火是让学习率按余弦曲线从大到小变化,在训练后期能精细调整。

我的建议是先用固定学习率把网络跑通,确认实现无误后再尝试衰减策略。如果固定学习率下损失能稳定下降,说明你的反向传播是对的,这时候再调衰减策略才有意义。如果固定学习率下损失就震荡或发散,先别急着换策略,回去检查反向传播的梯度计算。

4.5 从单隐藏层扩展到多层的注意事项

把单隐藏层扩展到多层,前向传播和反向传播的链条会变长,但逻辑完全一样。需要注意的是每一层的权重初始化都要用He初始化,不能只初始化第一层。另外,层数越多,梯度消失的风险越大,可以考虑用Batch Normalization来缓解,但那又是另一个话题了。

我建议先把单隐藏层写熟,能徒手推导出每一层的梯度,再去尝试多层。不要贪多,把一层写透比写十层但每层都稀里糊涂强得多。等你对单隐藏层的每一个矩阵维度、每一个导数形式都了然于胸,扩展到多层就是复制粘贴加循环的事。

5. 从手写实现到框架使用的过渡建议

5.1 什么时候该切换到PyTorch或TensorFlow

手写实现的价值在于理解原理,但实际项目中不可能什么都从零写。当你需要搭建卷积神经网络、循环神经网络、Transformer这些复杂结构时,手写的代码量和调试成本会急剧上升。这时候切换到PyTorch或TensorFlow是明智的选择。

我的建议是手写实现跑通一个完整的前馈网络之后,就可以开始学PyTorch了。你会发现PyTorch的nn.Linear、nn.ReLU、nn.CrossEntropyLoss这些模块,本质上就是你手写的那几行代码的封装。有了手写的经验,你看框架的源码会非常亲切,知道每一层在做什么,调参的时候心里有数。

5.2 手写经验在实际调参中的价值

很多人用框架调包,遇到损失不下降就瞎试,换优化器、换学习率、换网络结构,完全是碰运气。但如果你手写过反向传播,你会知道损失不下降首先要看梯度是不是传对了,然后看学习率是不是合适,最后才考虑网络结构。这种排查问题的顺序感,是手写经验带来的最大价值。

另外,手写实现让你对计算量有直观感受。你知道一次前向传播要做多少次矩阵乘法,一次反向传播要做多少次转置和乘法,所以你能估算出模型训练需要多少时间、多少显存。这种直觉在实际工程中非常有用,能帮你快速判断一个方案是否可行。

5.3 后续学习路径的规划

前馈网络写完之后,下一步可以往两个方向走。一个是深入优化算法,学Momentum、RMSProp、Adam这些优化器,理解它们是怎么在梯度下降的基础上做改进的。另一个是扩展网络结构,学卷积神经网络做图像、学循环神经网络做序列、学注意力机制做翻译。

不管往哪个方向走,反向传播的链式法则都是核心。你手写的前馈网络反向传播,和CNN、RNN的反向传播在本质上是同一套逻辑,只是局部梯度的计算方式不同。把前馈网络的每一行代码都理解透,后面学什么都会快很多。

我个人在实际操作中的体会是,手写一遍胜过看十遍教程。你看别人的代码觉得懂了,但自己写的时候会发现各种细节问题,比如矩阵转置、广播、数值稳定性,这些只有亲手写过才会真正记住。所以如果你还没动手写,建议现在就打开编辑器,把上面的代码敲一遍,跑通它,然后试着改改隐藏层大小、学习率、激活函数,观察结果的变化。这种实验带来的理解,是任何教程都给不了的。