从零手写多层感知机:深入理解神经网络前向传播与反向传播原理
2026/8/6 7:20:48 网站建设 项目流程

1. 项目概述:从零构建你的第一个多层感知机

最近几年,无论是刷短视频还是逛技术论坛,总能看到“AI”、“神经网络”这些词。很多人觉得这东西高深莫测,是实验室里的玩意儿。但说实话,它的核心思想,尤其是我们今天要聊的多层感知机,远没有想象中那么复杂。你可以把它理解成一个超级升级版的“连连看”或者“分类器”。比如,给你一堆猫和狗的图片,让你写个程序自动区分,用传统的“如果耳朵是尖的就是猫”这种规则,很快就会遇到各种特例而崩溃。MLP要做的,就是通过大量例子,自己“学习”出一套判断规则。

这个项目,就是带你用Python,从最基础的数学公式开始,亲手搭建一个能真正运行、能学习、能做出预测的多层感知机。我们不用任何现成的深度学习框架,比如TensorFlow或PyTorch。为什么?因为用框架就像开自动挡汽车,方便快捷,但你不知道引擎盖下发生了什么。而自己手写,就像亲手组装一台发动机,每一个螺丝、每一次点火你都清清楚楚。这对于理解神经网络如何工作、反向传播如何更新权重、梯度下降如何寻找最优解,是无可替代的。你会发现,那些听起来玄乎的“黑箱”,其内核无非是一些矩阵乘法和导数计算。

我们将构建一个结构完整的MLP,包含输入层、至少一个隐藏层和输出层,实现前向传播计算预测值,并通过反向传播算法来训练它。为了直观验证,我们会用一个经典的线性不可分数据集——比如异或问题,或者月亮数据集——来测试我们的模型。完成后,你将不仅获得一段可以运行的代码,更能获得对神经网络工作原理的深刻直觉。无论你是想入门机器学习的学生,还是希望夯实基础的数据科学从业者,这个“造轮子”的过程都将让你受益匪浅。

2. 核心原理与设计思路拆解

2.1 多层感知机到底是什么?

简单来说,多层感知机是一种前馈人工神经网络。它之所以叫“多层”,是因为在输入和输出之间,至少有一个“隐藏层”。而“感知机”这个古老的名字,则源于其最基本的计算单元——神经元,也叫感知机。

一个神经元做的事情非常单纯:它接收来自其他神经元的一堆输入信号,每个信号都有一个权重。神经元把这些加权输入加起来,再加上一个偏置,然后通过一个非线性函数“加工”一下,产生一个输出。这个非线性函数,我们称之为激活函数。没有它,无论堆叠多少层,整个网络都只能表达线性关系,能力大打折扣。正是激活函数给网络注入了非线性,使得MLP能够拟合任意复杂的函数,成为“通用近似器”。

所以,一个MLP可以看作是多层神经元的连接。每一层的神经元接收前一层所有神经元的输出作为输入,计算自己的输出,再传递给下一层。信息从输入层开始,逐层向前传递,直到输出层,这个过程就叫前向传播

2.2 为什么必须要有反向传播?

前向传播让我们得到了一个预测值,但这个预测值很可能与真实值相差甚远。如何让网络变得更“聪明”?答案就是调整每个连接的权重和每个神经元的偏置。调整的依据,就是预测的误差。

反向传播算法,就是计算这个误差如何由输出层“反向”分摊到网络每一层、每一个参数上的过程。它的核心是链式求导法则。我们定义一个损失函数来衡量预测值与真实值的差距,比如均方误差。我们的目标是最小化这个损失函数。

反向传播告诉我们,损失函数对某个权重或偏置的偏导数(也就是梯度)是多少。有了梯度,我们就知道该参数应该朝哪个方向、以多大的幅度调整,才能使损失下降。这个沿着梯度反方向调整参数的过程,就是梯度下降

注意:这里容易混淆“反向传播”和“梯度下降”。反向传播是计算方法,用于高效计算所有参数的梯度。梯度下降是优化策略,利用计算出的梯度来更新参数。两者协同工作,缺一不可。

2.3 我们的网络架构设计

为了平衡教学清晰度和模型能力,我们设计一个具有以下结构的MLP:

  • 输入层:节点数由数据特征决定。例如,对于二维数据(如我们用来测试的月亮数据集),输入层就是2个神经元。
  • 隐藏层:我们设计一个隐藏层,包含10个神经元。这个数量是经验性的起点,足够处理我们示例任务的复杂度,又不至于让计算和解释过于复杂。隐藏层使用ReLU激活函数,因为它能有效缓解梯度消失问题,且计算高效。
  • 输出层:根据任务决定。对于二分类任务,我们使用1个神经元,配合Sigmoid激活函数,输出一个0到1之间的概率值。对于多分类,则使用多个神经元配合Softmax函数。
  • 损失函数:对于二分类任务,使用交叉熵损失比均方误差更合适,因为它与Sigmoid输出结合时,梯度形式更优,能避免学习速度过慢的问题。

整个训练过程就是一个循环:前向传播计算预测和损失 -> 反向传播计算所有参数的梯度 -> 使用梯度下降更新参数。重复这个过程数百或数千次,直到损失收敛到较低水平。

3. 核心组件实现与数学推导

3.1 激活函数:网络的非线性之源

激活函数是神经网络的灵魂。没有它,网络就是一堆线性变换的叠加,最终仍然等价于一个线性模型,无法解决异或这类简单非线性问题。

1. Sigmoid函数这是历史上最早使用的激活函数之一。公式为:σ(z) = 1 / (1 + e^{-z})。它将任何实数压缩到(0, 1)区间,输出可以直观理解为概率。在输出层用于二分类非常合适。

  • 导数σ'(z) = σ(z) * (1 - σ(z))。这个导数最大值为0.25,当输入值很大或很小时,导数会趋近于0。
  • 问题:正是这个特性导致了“梯度消失”。在深层网络反向传播时,梯度是连乘的,多个小于1的小数连乘会迅速变得极小,导致靠近输入层的参数几乎得不到更新,学习停滞。

2. ReLU函数现在隐藏层的绝对主力。公式为:ReLU(z) = max(0, z)。非常简单:输入大于0则原样输出,小于0则输出0。

  • 导数ReLU'(z) = 1 if z > 0 else 0。同样简单。
  • 优势
    • 计算极其高效:就是比较和赋值操作。
    • 缓解梯度消失:对于正数输入,导数为1,在反向传播中完美保持梯度大小。
    • 带来稀疏性:让一部分神经元输出为0,增加了网络的稀疏表达,某种程度上起到了正则化的效果。
  • 缺点:“死亡ReLU”问题。如果一个神经元在大部分训练数据上输入都为负,那么它的梯度将永远为0,权重再也无法更新,该神经元就“死亡”了。实践中,合理的权重初始化和调整学习率可以很大程度上缓解此问题。

在我们的MLP中,隐藏层将使用ReLU,输出层使用Sigmoid。

3.2 前向传播的矩阵化实现

理解单个神经元计算后,我们需要用矩阵运算来实现整个层的前向传播,这是高效利用Python NumPy库的关键。

假设第l层有n_l个神经元,前一層l-1n_{l-1}个神经元。

  • 该层的权重矩阵W^l形状为(n_{l-1}, n_l)这里有一个关键点:每一列对应本层一个神经元的全部输入权重。所以W^l的列数等于本层神经元数。
  • 偏置向量b^l形状为(1, n_l)
  • 前一层的输出(即本层输入)A^{l-1}形状为(m, n_{l-1}),其中m是样本数量。

那么,第l层的线性加权和Z^l计算为:Z^l = A^{l-1} · W^l + b^l注意,这里的+ b^l利用了NumPy的广播机制,将偏置加到每一个样本上。 然后,通过激活函数g得到该层输出:A^l = g(Z^l)

对于我们的网络(输入层l=0,隐藏层l=1,输出层l=2):

  • Z1 = X · W1 + b1->A1 = ReLU(Z1)
  • Z2 = A1 · W2 + b2->A2 = Sigmoid(Z2)

A2就是网络的最终预测输出。

3.3 反向传播的梯度推导(核心)

这是最需要耐心理解的部分。我们以二分类交叉熵损失为例。对于单个样本,损失L = -[y * log(a) + (1-y) * log(1-a)],其中y是真实标签,a是输出层Sigmoid后的预测值A2

我们的目标是求出损失L对每一个参数(W1, b1, W2, b2)的偏导数。

1. 输出层梯度计算首先计算损失对输出层线性输出Z2的梯度dZ2。经过推导(应用链式法则),可以得到一个非常简洁的结果:dZ2 = A2 - Y这里Y是真实标签组成的向量。这个简洁的形式正是交叉熵损失配合Sigmoid激活函数带来的福利。 有了dZ2,我们可以计算损失对输出层参数的梯度:

  • dW2 = (A1.T) · dZ2 / m(除以m是考虑所有样本的平均梯度)
  • db2 = np.sum(dZ2, axis=0, keepdims=True) / m

2. 隐藏层梯度计算接下来,误差要继续反向传播到隐藏层。计算损失对隐藏层线性输出Z1的梯度dZ1dA1 = dZ2 · (W2.T)(这是误差从第二层传递到第一层输出A1)dZ1 = dA1 * ReLU_derivative(Z1)(这里乘以的是ReLU的导数,*是逐元素乘法) 其中ReLU_derivative(Z1)Z1>0时为1,否则为0。 然后,计算损失对隐藏层参数的梯度:

  • dW1 = (X.T) · dZ1 / m
  • db1 = np.sum(dZ1, axis=0, keepdims=True) / m

3.4 参数初始化:训练成功的第一步

权重不能初始化为零。如果所有权重相同,那么在反向传播时,同一层内的所有神经元将获得完全相同的梯度更新,导致它们永远学习到相同的特征,失去了多层网络的意义。这是一种“对称性”破坏问题。

常用的初始化方法有:

  • Xavier初始化:适用于Sigmoid、Tanh等S型激活函数。从均值为0,方差为Var(W) = 1 / n_in的正态分布中采样,其中n_in是输入该层的神经元数量。
  • He初始化:专为ReLU及其变体设计。从均值为0,方差为Var(W) = 2 / n_in的正态分布中采样。更大的方差补偿了ReLU函数将一半神经元输出置零所带来的信号减弱。

在我们的网络中,隐藏层使用ReLU,因此W1应采用He初始化。输出层使用Sigmoid,理论上Xavier更合适,但实践中使用一个较小的随机初始化(如乘以0.01)也常能工作。偏置b通常初始化为0。

实操心得:初始化虽然是小步骤,但对训练收敛速度和最终效果影响巨大。如果训练初期损失居高不下或出现NaN,首先应检查初始化方法是否与激活函数匹配。对于我们的手写MLP,严格使用He初始化隐藏层权重,是避免早期训练失败的关键。

4. 手把手代码实现与训练

现在,我们将上述所有原理转化为可执行的Python代码。我们将使用NumPy进行所有数值计算。

4.1 环境准备与数据生成

首先,确保安装了NumPy和Matplotlib(用于可视化)。

pip install numpy matplotlib

我们使用sklearnmake_moons函数生成一个非线性可分的二分类数据集作为我们的“试金石”。如果你没有安装scikit-learn,可以使用pip install scikit-learn安装,或者我们也可以自己用NumPy模拟一个类似的数据集。这里为了自包含,我们假设使用一个简单的替代方案。

import numpy as np import matplotlib.pyplot as plt # 生成模拟数据:一个简单的二分类圆形数据集 def generate_circle_data(n_samples=300, noise=0.1): np.random.seed(42) n = n_samples // 2 # 类别0:内圈 radius0 = np.random.rand(n) * 2 angle0 = np.random.rand(n) * 2 * np.pi X0 = np.column_stack([radius0 * np.cos(angle0), radius0 * np.sin(angle0)]) + np.random.randn(n, 2) * noise y0 = np.zeros(n) # 类别1:外圈 radius1 = np.random.rand(n) * 3 + 2.5 angle1 = np.random.rand(n) * 2 * np.pi X1 = np.column_stack([radius1 * np.cos(angle1), radius1 * np.sin(angle1)]) + np.random.randn(n, 2) * noise y1 = np.ones(n) X = np.vstack([X0, X1]) y = np.hstack([y0, y1]) # 打乱数据 indices = np.arange(n_samples) np.random.shuffle(indices) return X[indices], y[indices].reshape(-1, 1) # 将y reshape为列向量 X, Y = generate_circle_data() print(f"数据形状:X: {X.shape}, Y: {Y.shape}") # 可视化数据 plt.scatter(X[:, 0], X[:, 1], c=Y.flatten(), cmap=plt.cm.Spectral, edgecolors='k') plt.title("训练数据(圆形分布)") plt.show()

4.2 激活函数与损失函数的实现

def sigmoid(z): """Sigmoid激活函数""" # 防止数值溢出,对输入进行裁剪 z = np.clip(z, -50, 50) return 1 / (1 + np.exp(-z)) def relu(z): """ReLU激活函数""" return np.maximum(0, z) def sigmoid_derivative(a): """Sigmoid函数的导数,输入是经过Sigmoid激活后的值a""" return a * (1 - a) def relu_derivative(z): """ReLU函数的导数""" return (z > 0).astype(float) # z>0时为1.0,否则为0.0 def binary_cross_entropy_loss(y_pred, y_true): """二分类交叉熵损失""" m = y_true.shape[0] # 防止log(0)出现数值问题,对预测值进行微小的裁剪 y_pred_clipped = np.clip(y_pred, 1e-15, 1 - 1e-15) loss = -np.mean(y_true * np.log(y_pred_clipped) + (1 - y_true) * np.log(1 - y_pred_clipped)) return loss

4.3 网络初始化

def initialize_parameters(input_size, hidden_size, output_size): """ 初始化网络参数 He初始化用于ReLU层,小随机数初始化用于Sigmoid输出层 """ np.random.seed(1) # 固定随机种子,确保结果可复现 W1 = np.random.randn(input_size, hidden_size) * np.sqrt(2. / input_size) # He初始化 b1 = np.zeros((1, hidden_size)) W2 = np.random.randn(hidden_size, output_size) * 0.01 # 小随机初始化 b2 = np.zeros((1, output_size)) parameters = {'W1': W1, 'b1': b1, 'W2': W2, 'b2': b2} return parameters

4.4 前向传播

def forward_propagation(X, parameters): """ 执行前向传播 """ W1, b1, W2, b2 = parameters['W1'], parameters['b1'], parameters['W2'], parameters['b2'] # 隐藏层 Z1 = np.dot(X, W1) + b1 A1 = relu(Z1) # 输出层 Z2 = np.dot(A1, W2) + b2 A2 = sigmoid(Z2) cache = {'Z1': Z1, 'A1': A1, 'Z2': Z2, 'A2': A2} return A2, cache

4.5 反向传播

这是核心中的核心,严格对应我们之前的数学推导。

def backward_propagation(X, Y, cache, parameters): """ 执行反向传播,计算梯度 """ m = X.shape[0] # 样本数量 A1, A2, Z1 = cache['A1'], cache['A2'], cache['Z1'] W2 = parameters['W2'] # 输出层梯度 dZ2 = A2 - Y # 交叉熵损失 + Sigmoid的简化形式 dW2 = (1/m) * np.dot(A1.T, dZ2) db2 = (1/m) * np.sum(dZ2, axis=0, keepdims=True) # 隐藏层梯度 dA1 = np.dot(dZ2, W2.T) dZ1 = dA1 * relu_derivative(Z1) # 逐元素乘法 dW1 = (1/m) * np.dot(X.T, dZ1) db1 = (1/m) * np.sum(dZ1, axis=0, keepdims=True) gradients = {'dW1': dW1, 'db1': db1, 'dW2': dW2, 'db2': db2} return gradients

4.6 参数更新

使用最基本的梯度下降法。

def update_parameters(parameters, gradients, learning_rate): """ 使用梯度下降更新参数 """ parameters['W1'] -= learning_rate * gradients['dW1'] parameters['b1'] -= learning_rate * gradients['db1'] parameters['W2'] -= learning_rate * gradients['dW2'] parameters['b2'] -= learning_rate * gradients['db2'] return parameters

4.7 整合训练循环

现在,我们把所有部分组装起来,形成一个完整的训练流程。

def train_mlp(X, Y, hidden_size=10, learning_rate=0.01, epochs=10000, print_interval=1000): """ 训练多层感知机 """ np.random.seed(3) input_size = X.shape[1] output_size = Y.shape[1] parameters = initialize_parameters(input_size, hidden_size, output_size) losses = [] for i in range(epochs): # 前向传播 A2, cache = forward_propagation(X, parameters) # 计算损失 loss = binary_cross_entropy_loss(A2, Y) losses.append(loss) # 反向传播 gradients = backward_propagation(X, Y, cache, parameters) # 更新参数 parameters = update_parameters(parameters, gradients, learning_rate) # 打印进度 if i % print_interval == 0: print(f"Epoch {i}, Loss: {loss:.6f}") # 绘制损失曲线 plt.plot(losses) plt.xlabel('Epoch') plt.ylabel('Loss') plt.title('Training Loss over Epochs') plt.show() return parameters, losses

4.8 执行训练与预测

# 主程序 if __name__ == "__main__": # 1. 准备数据 X, Y = generate_circle_data(n_samples=400, noise=0.15) # 2. 训练模型 print("开始训练MLP...") trained_params, loss_history = train_mlp(X, Y, hidden_size=10, learning_rate=0.1, # 学习率可以调大一些 epochs=5000, print_interval=500) print("训练完成!") # 3. 进行预测 predictions, _ = forward_propagation(X, trained_params) # 将概率转换为类别 (0或1) y_pred = (predictions > 0.5).astype(int) # 4. 计算准确率 accuracy = np.mean(y_pred == Y) print(f"训练集准确率: {accuracy * 100:.2f}%") # 5. 可视化决策边界 def plot_decision_boundary(model, X, y): # 设置网格范围 x_min, x_max = X[:, 0].min() - 0.5, X[:, 0].max() + 0.5 y_min, y_max = X[:, 1].min() - 0.5, X[:, 1].max() + 0.5 h = 0.01 # 网格步长 xx, yy = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h)) # 预测网格上每个点的类别 Z, _ = model(np.c_[xx.ravel(), yy.ravel()], trained_params) Z = (Z > 0.5).astype(int) Z = Z.reshape(xx.shape) # 绘制等高线和散点 plt.contourf(xx, yy, Z, alpha=0.3, cmap=plt.cm.Spectral) plt.scatter(X[:, 0], X[:, 1], c=y.flatten(), cmap=plt.cm.Spectral, edgecolors='k') plt.title(f"MLP决策边界 (准确率: {accuracy*100:.1f}%)") plt.show() # 定义一个包装函数,使其接口适配plot_decision_boundary def model_predict(X_input, params): A2, _ = forward_propagation(X_input, params) return A2 plot_decision_boundary(model_predict, X, Y)

运行这段代码,你会看到损失曲线稳步下降,最终在训练集上达到很高的准确率(通常超过95%),并且决策边界的可视化图会显示出一个复杂的非线性边界,成功地将两个类别的数据点分开。这证明我们手写的MLP确实具备了学习非线性关系的能力。

5. 关键参数调优与常见问题排查

模型能跑起来只是第一步,要想让它跑得好、跑得稳,还需要理解并调整几个关键旋钮。

5.1 学习率:训练速度与稳定性的平衡杆

学习率可能是最重要的超参数。它决定了每次参数更新沿着梯度反方向迈出的步子有多大。

  • 学习率太大:步子迈得太大,可能会在损失函数的“山谷”两侧来回横跳,甚至直接越过最低点,导致损失值震荡不降反升,最终发散为NaN。
    • 现象:损失曲线剧烈震荡,数值突然变得极大或出现NaN。
    • 解决:立即降低学习率,通常以10倍为单位尝试,如从0.1降到0.01。
  • 学习率太小:步子太小,虽然稳定,但到达最低点需要非常多的步数,训练速度极慢,也可能陷入局部极小点出不来。
    • 现象:损失下降得非常缓慢,训练了很久损失值依然很高。
    • 解决:适当提高学习率,或使用学习率衰减策略。

实操心得:一个实用的策略是从一个较大的学习率开始,如果训练发散,就逐步调小。对于我们的手写MLP,学习率在0.01到0.1之间通常是个不错的起点。也可以实现一个简单的学习率衰减,例如每个epoch将学习率乘以0.999,让训练后期步伐更精细。

5.2 隐藏层大小与深度:模型容量的抉择

  • 隐藏层神经元数量:这决定了模型的“宽度”和容量。神经元太少,模型可能“学力不足”,无法捕捉数据中的复杂模式,导致欠拟合。神经元太多,则模型能力过强,容易记住训练数据中的噪声而非一般规律,导致过拟合。
    • 欠拟合迹象:训练集和验证集上的损失/准确率都很差。
    • 过拟合迹象:训练集损失很低、准确率很高,但验证集损失很高、准确率很低。
    • 策略:从一个适中的数量开始(如我们用的10个),观察模型在验证集上的表现。如果欠拟合,增加神经元数量或增加层数。如果过拟合,则减少神经元数量、增加正则化或获取更多数据。
  • 隐藏层层数:我们只使用了一层。增加层数可以构建更深的网络,理论上能学习更抽象、更复杂的特征。但对于我们当前的简单数据集,一层隐藏层已经足够。增加层数会显著增加训练难度(梯度消失/爆炸)、训练时间和过拟合风险。

5.3 迭代次数:何时停止训练?

我们固定了epoch数量。更专业的做法是监控验证集损失,实现早停

  • 早停:在训练过程中,每隔一定epoch就在一个独立的验证集上评估模型性能。当验证集损失连续多个epoch不再下降甚至开始上升时,就停止训练,并回滚到验证集损失最低的那个epoch的模型参数。这是防止过拟合最简单有效的方法之一。
  • 实现思路:将数据分为训练集、验证集和测试集。在训练循环中,定期在验证集上计算损失。保存验证集损失最低时的模型参数副本。当验证集损失在连续N个epoch内未创新低,则终止训练,并加载保存的最佳参数。

5.4 常见问题排查速查表

在实现和训练过程中,你几乎一定会遇到下面这些问题。这里提供一个快速诊断指南。

问题现象可能原因排查步骤与解决方案
损失值为NaN1. 学习率过高。
2. 数据未归一化,值域过大。
3. 在计算对数时(如交叉熵损失)输入了0或负数。
1.首先降低学习率,这是最常见原因。
2. 对输入特征X进行标准化(减均值除以标准差)。
3. 在损失函数中对预测值进行数值裁剪,如np.clip(a, 1e-15, 1-1e-15)
损失值居高不下,不下降1. 学习率过低。
2. 权重初始化不当(如全零初始化)。
3. 网络结构过于简单(欠拟合)。
4. 梯度计算有bug。
1. 尝试增大学习率。
2.检查初始化代码,确保权重是随机的小数。
3. 增加隐藏层神经元数量。
4.使用梯度检查:用数值方法近似计算梯度,与你的反向传播结果对比。
损失震荡剧烈1. 学习率过高。
2. 批量大小太小(我们使用的是全批量梯度下降,所以不是此原因)。
1. 降低学习率。
2. 如果使用了小批量,尝试增大批量大小。
训练集准确率高,测试集准确率低过拟合。1. 获取更多训练数据。
2. 减少网络规模(隐藏单元数)。
3. 在损失函数中加入L2正则化项。
4. 实现早停。
梯度爆炸(损失激增)1. 学习率过高。
2. 深层网络中,梯度连乘变得极大。
1. 降低学习率。
2. 使用梯度裁剪:设定一个阈值,如果梯度范数超过它,就按比例缩小。

5.5 梯度检查:验证反向传播正确性的金标准

当你怀疑反向传播代码有bug时,梯度检查是最可靠的验证方法。其原理是用导数的定义来近似计算梯度,与你反向传播计算的结果进行对比。

def gradient_check(parameters, gradients, X, Y, epsilon=1e-7): """ 执行梯度检查 """ param_keys = ['W1', 'b1', 'W2', 'b2'] for key in param_keys: param = parameters[key] grad = gradients['d'+key] # 初始化数值梯度 num_grad = np.zeros_like(param) it = np.nditer(param, flags=['multi_index'], op_flags=['readwrite']) while not it.finished: idx = it.multi_index original_value = param[idx] # 计算 J(theta + epsilon) param[idx] = original_value + epsilon A2_plus, _ = forward_propagation(X, parameters) J_plus = binary_cross_entropy_loss(A2_plus, Y) # 计算 J(theta - epsilon) param[idx] = original_value - epsilon A2_minus, _ = forward_propagation(X, parameters) J_minus = binary_cross_entropy_loss(A2_minus, Y) # 恢复原值 param[idx] = original_value # 计算数值梯度 num_grad[idx] = (J_plus - J_minus) / (2 * epsilon) it.iternext() # 计算差异 numerator = np.linalg.norm(grad - num_grad) denominator = np.linalg.norm(grad) + np.linalg.norm(num_grad) difference = numerator / denominator if difference > 1e-7: print(f"梯度检查警告!参数 {key} 的梯度差异较大: {difference}") else: print(f"参数 {key} 的梯度检查通过。差异: {difference}")

在训练开始前,用一小部分数据运行一次梯度检查。如果差异在1e-7量级,说明你的反向传播实现基本正确。这是一个非常耗时的操作,仅在调试时使用,正式训练前务必关闭。

6. 功能扩展与优化方向

我们的基础MLP已经可以工作,但工业级或研究级的神经网络包含更多技巧。以下是几个关键的优化方向,你可以尝试将它们加入到你的代码中。

6.1 添加L2正则化

正则化通过在损失函数中增加一个惩罚项,来约束权重的大小,防止模型过拟合。L2正则化是最常用的一种。

修改损失函数: 新的损失函数为:J_reg = J + (lambda / (2*m)) * (sum(W1^2) + sum(W2^2))其中J是原始交叉熵损失,lambda是正则化强度超参数。

修改梯度计算: 正则化项也会影响梯度。对于权重W,其梯度需要加上(lambda / m) * W。偏置b通常不进行正则化。 因此,在backward_propagation函数计算出的dW1dW2上,需要加上这一项。

def backward_propagation_with_reg(X, Y, cache, parameters, lambd): """ 带L2正则化的反向传播 """ m = X.shape[0] A1, A2, Z1 = cache['A1'], cache['A2'], cache['Z1'] W1, W2 = parameters['W1'], parameters['W2'] dZ2 = A2 - Y dW2 = (1/m) * np.dot(A1.T, dZ2) + (lambd / m) * W2 # 添加正则化项 db2 = (1/m) * np.sum(dZ2, axis=0, keepdims=True) dA1 = np.dot(dZ2, W2.T) dZ1 = dA1 * relu_derivative(Z1) dW1 = (1/m) * np.dot(X.T, dZ1) + (lambd / m) * W1 # 添加正则化项 db1 = (1/m) * np.sum(dZ1, axis=0, keepdims=True) gradients = {'dW1': dW1, 'db1': db1, 'dW2': dW2, 'db2': db2} return gradients

6.2 实现小批量梯度下降

我们之前使用的是批量梯度下降,即每次迭代使用全部训练数据计算梯度。这对于小型数据集没问题,但数据量大时计算开销巨大。小批量梯度下降每次随机选取一小部分数据(如32、64、128个样本)来计算梯度并更新参数。这样做有两大好处:

  1. 计算高效:单次迭代速度快。
  2. 引入噪声:梯度的随机性有助于跳出局部极小点。

实现需要修改数据输入和训练循环,每次迭代前打乱数据并选取一个批次。

6.3 尝试不同的优化器

基础的梯度下降法存在一些缺点,比如在山谷沟壑中震荡、收敛慢。更先进的优化器如动量法、RMSprop、Adam能显著改善训练过程。

  • 动量法:不仅考虑当前梯度,还累积之前的梯度方向,使其在相关方向上加速,在不相关方向上减速,有效减少震荡。
  • Adam:结合了动量法和RMSprop的思想,自适应地调整每个参数的学习率,是目前最常用、默认效果往往不错的优化器。

实现这些优化器需要为每个参数维护额外的状态变量(如动量、速度),并在更新参数时使用更复杂的公式。虽然代码量会增加,但对于复杂网络的训练至关重要。

6.4 扩展到多分类任务

我们的网络目前是二分类输出。要处理多分类问题(如手写数字识别0-9),需要做以下改动:

  1. 输出层:神经元数量等于类别数C
  2. 激活函数:使用Softmax函数代替Sigmoid。Softmax将输出层的原始分数(logits)转换为一个概率分布,所有类别概率之和为1。
  3. 损失函数:使用多分类交叉熵损失
  4. 标签格式:真实标签Y需要转换为one-hot编码形式,即一个样本的标签是一个长度为C的向量,只有对应类别位置为1,其余为0。

反向传播的梯度公式也会相应变化,对于Softmax+交叉熵的组合,输出层的梯度dZ同样有一个非常简洁的形式:dZ = A - Y,其中Y是one-hot编码的标签矩阵。

从零开始构建一个多层感知机,就像亲手解开一个神秘的魔术。当你看到几行简单的矩阵乘法和导数计算,组合起来就能让一堆数字学会区分猫和狗、识别手写字符时,那种对原理的透彻理解所带来的满足感,是直接调用model.fit()无法比拟的。这个过程会让你对神经网络中的每一个超参数、每一次梯度更新的意义都变得异常敏感。虽然在实际工作中我们99%的时间都在使用成熟的框架,但这次“造轮子”的经历,无疑会让你在未来使用这些框架时,成为一个更自信、更清醒的从业者。当你下次再看到损失曲线出现异常时,你脑海中浮现的将不再是盲目的调参,而是权重初始化、梯度流、激活函数导数这些底层逻辑。这才是这个项目最大的价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询