☰
从零实现两层神经网络:梯度下降与反向传播全解析
2026/10/14 5:56:17 网站建设 项目流程

说实话,我见过太多把神经网络当成神秘黑盒的入门者。调个框架、改改层数、把学习率从0.01换到0.001,跑通了就当学会了。等到面试或者真刀真枪做项目,被问到“梯度到底怎么算出来的”“为什么学习率设大了损失会炸掉”“反向传播的误差是怎么一层层传回去的”,立刻露馅。

这篇文章想把“神经网络的学习”这件事彻底拆开讲清楚,也就是学习算法的实现:从损失函数如何评价模型好坏,到梯度下降如何迭代参数,再到反向传播如何把误差从输出层一路传回输入层,最后用一份完整的 Python 代码把两层网络从零搭起来跑通。适合正在入门深度学习、想亲手实现而不是只调框架、以及被各种库封装搞得很迷茫的读者。我保证,读完你能亲手动笔写出一个能训练的网络,而不是只会model.fit()。

1. 学习算法到底在做什么:一次说清“训练”的本质

1.1 从猜数字游戏看参数更新的闭环

想象你在猜一个数字,目标值是 42。你第一轮猜 10,别人告诉你“低了”;第二轮猜 80,别人说“高了”。这个过程里你做了什么?你根据反馈信息调整了猜测,让下一次猜测更接近目标。

神经网络的学习算法本质上就是把这个游戏自动化、高维化。网络里有一堆参数(权重 W 和偏置 b),初始时是随机值,所以输出大概率是错的。学习算法要做的事情就是:让网络在数据上跑一遍,算出当前输出和真实标签之间的差距(损失),然后根据这个差距决定每个参数应该往哪个方向调、调多少。如此反复迭代,损失逐步下降,参数的组合越来越能拟合训练数据。

这里有一个关键点:所有学习算法都是在“参数空间”里搜索,而不是在“数据空间”里搜索。网络的结构(层数、激活函数)是设计者预先定死的,学习的过程只改变参数的值。理解了这一点,后面学反向传播就顺了——反向传播干的事,就是高效算出“每个参数对最终损失有多大影响”。

1.2 损失函数:误差的度量方式

要“调参数变好”,首先得定义什么叫“好”。这就是损失函数(Loss Function)的职责。

拿最常见的回归问题举例,均方误差(MSE)定义为:

L = (1 / N) * Σ (y_pred - y_true)^2

它衡量的是预测值和真实值之间距离的平方平均。平方的存在有讲究:误差越大,惩罚越重,这样模型会更倾向于优先修正那些错得离谱的样本。

而分类问题更常用交叉熵(Cross-Entropy)。以二分类为例:

L = -(1 / N) * Σ [y * log(y_pred) + (1 - y) * log(1 - y_pred)]

为什么分类不用 MSE?这是新手最容易忽略的问题。一部分原因在数学上:MSE 配合 sigmoid 激活函数时,如果网络的输出接近 0 或 1(即 sigmoid 进入饱和区),梯度会趋近于零,导致学习极其缓慢。而交叉熵配合 sigmoid,梯度化简之后刚好是y_pred - y,这个误差信号即使在输出饱和时依然强有力。这一点在 3.3 节我会详细推导。

1.3 梯度:参数往哪个方向调才有效

有了损失函数,我们知道了“错多少”,但还不知道“怎么调”。这里就需要微积分登场了。

损失函数 L 是每个参数(比如某个 w)的函数。L 对 w 的偏导数 ∂L/∂w 的含义是:如果 w 增加一个微小量,损失会往哪个方向变化、变化有多剧烈。如果偏导数为正,说明 w 增大时损失变大,那我们就应该减小 w;反之,如果偏导数为负,就增大 w。统一写出来就是当年的参数更新公式:

w ← w - α * (∂L / ∂w)

这里的 α 是学习率,控制每一步调整的步长。这个公式就是几乎所有神经网络学习算法的基础骨架,后面无论多么花哨的优化器(Momentum、Adam),底层都是在这个公式上做文章。

所以整个学习算法最核心的技术难点就剩一个:如何高效、准确地计算 ∂L/∂w。这正是反向传播算法要解决的问题。

2. 梯度下降的三种形态与实操选型

2.1 批量梯度下降:稳定但笨重

确定了用梯度去更新参数,下一个问题就是:计算梯度时到底用多少数据?

最直觉的做法是把所有训练样本都过一遍,把每个样本的梯度累加起来求平均,得到一个整体梯度方向,再更新一次参数。这叫批量梯度下降(Batch Gradient Descent,BGD)。

BGD 的优点很明显:梯度方向准确,是全体样本的“集体意见”,每一步都朝着损失函数真正下降最快的方向走,收敛曲线很平滑。

但缺点同样致命:如果训练集有 100 万条数据,每更新一次参数就要把所有数据完整前向、反向各算一遍。一次迭代的计算量可能是 10 万次普通预测的量级,训练要跑到收敛得迭代几千上万轮,时间成本完全无法接受。而且它容易陷入局部最优点——整个数据集的梯度方向一旦在某个局部极小附近归零,模型就卡死在那里了。

2.2 随机梯度下降:快但爱摇摆

为了克服 BGD 的速度问题,有人提出每次只随机抽一个样本来计算梯度、更新参数,这就是随机梯度下降(Stochastic Gradient Descent,SGD)。

SGD 单次更新代价极小,而且计算量跟样本总数无关,迭代几万次也花不了多少时间。更妙的一点是它的随机性:单个样本的梯度方向带有噪声,这种噪声反而有可能把参数从局部最小值附近“晃”出来,帮助模型找到更好全局解。

但代价是更新方向极其不稳定。想象你问 100 个人“该往哪走”,A 说东,B 说西,C 说南……你听一个人的话走一步,很可能来回震荡。SGD 的损失曲线就是这样,不是平滑下降,而是剧烈抖动,而且最后不容易精确收敛到最优点。

2.3 Mini-batch:兼顾速度与稳定

实际工程里几乎没有人在严格用上面两种,大家用的都是折中方案——Mini-batch 梯度下降。每次随机抽一小批样本(一般是 32 个或 64 个),在这批样本上计算平均梯度,再更新参数。

为什么这个折中最香?两个原因。第一个是计算效率:矩阵运算在 GPU / 向量化环境下,处理 64 个样本和处理 1 个样本的时间差远没有 64 倍那么大,批量计算能充分榨干硬件性能,同时梯度质量远好于单个样本。第二个是稳定性与速度的平衡:64 个样本的梯度已经足够平滑,不会像 SGD 那样疯狂抖动,同时又比 BGD 敏捷得多,训练几十万个样本也能在合理时间收敛。

三种方式的对比我直接给你列一张表,方便以后选型:

方式每次用的数据量梯度质量单次更新速度收敛稳定性工程常用度
BGD全部数据最高最慢最稳定基本不用
SGD1 个样本最低(噪声大)最快最差(剧烈震荡)少见
Mini-batch32~128 个较高快较好绝对主流

补充一句:batch size 也不是越大越好。我之前在某个模拟项目里试过把 batch size 从 32 加到 512,训练速度表面提升了,但损失曲线的收敛速度反而变慢了——因为太大的 batch 让梯度方向过于“平均”,丧失了部分随机性带来的逃离局部极小的能力,而且大 batch 吃内存。一般 32、64 是默认安全区,特殊需求再调。

3. 反向传播算法:从输出层一路倒推误差

3.1 计算图:把网络拆成积木

反向传播(Backpropagation)的核心思想是嵌套的链式法则。要把这条链理顺,最好先用“计算图”的方式看网络。

一个两层网络的前向计算可以写成:

z1 = x @ W1 + b1 a1 = sigmoid(z1) z2 = a1 @ W2 + b2 a2 = sigmoid(z2)

每一行都是一块积木,后一块积木的输入是前一块的输出。看这个链条,你会发现:最终损失 L 到某个参数(比如 W1)之间隔了好几层函数。数学上,L 对 W1 的梯度必须通过链式法则一路乘过去:

∂L/∂W1 = ∂L/∂a2 * ∂a2/∂z2 * ∂z2/∂a1 * ∂a1/∂z1 * ∂z1/∂W1

这就是反向传播的由来——前向传播是按上面 5 行从上往下算;反向传播则是从 L 出发,逆着这条积木链,一层一层把局部导数往回传。名字里的“反向”指的就是这个方向。

3.2 链式法则的逐层传递

我现在把两层网络的完整梯度推导走一遍,这是全文最核心的部分,别跳过。

网络记号如下:输入 x,真实标签 y,隐藏层激活为 a1,输出层激活为 a2。损失用二分类交叉熵:

L = -[y * log(a2) + (1 - y) * log(1 - a2)]

第一步,计算输出层的误差信号 δ2。定义 δ2 = ∂L/∂z2,用链式法则展开:

δ2 = ∂L/∂a2 * ∂a2/∂z2

其中 ∂L/∂a2 = -(y/a2) + (1-y)/(1-a2) = (a2 - y) / (a2 * (1 - a2)),而 sigmoid 的导数是 a2 * (1 - a2)。两者相乘,分母刚好约掉:

δ2 = a2 - y

这个结果漂亮到令人感动——使用交叉熵损失 + sigmoid 激活时,输出层的误差信号就是“预测值减真实值”。不需要任何复杂的导数计算。

第二步,把误差往隐藏层传。δ1 = ∂L/∂z1,需要乘上 W2 的转置和 sigmoid 在隐藏层的导数:

δ1 = (δ2 @ W2.T) * a1 * (1 - a1)

这里的直觉是:输出层的误差 δ2 通过权重 W2 分配回隐藏层——哪个隐藏神经元对某个输出神经元的影响越大(权重越大),它分摊到的误差就越多。然后再乘上隐藏层自身 sigmoid 的导数,得到隐藏层的误差信号。

第三步,有了 δ1 和 δ2,参数梯度就唾手可得:

∂L/∂W2 = a1.T @ δ2 / N ∂L/∂b2 = mean(δ2, axis=0) ∂L/∂W1 = x.T @ δ1 / N ∂L/∂b1 = mean(δ1, axis=0)

除以 N 是因为我们在 mini-batch 上求的是平均梯度,保证更新幅度不随 batch size 变化而剧烈波动。到这里,你就掌握了从零实现反向传播所需的全部数学。

3.3 关键简化:损失函数和激活函数的搭配逻辑

上面推导能化简到 δ2 = a2 - y,不是偶然,而是损失函数和激活函数“配对”的结果。为什么分类任务经常用交叉熵 + softmax/sigmoid,而不是 MSE + sigmoid?我可以把两个组合的 δ2 拿出来对比:

  • 交叉熵 + sigmoid:δ2 = a2 - y
  • MSE + sigmoid:δ2 = (a2 - y) * a2 * (1 - a2)

第二种多乘了一个 a2(1-a2)。当模型预测很自信时(a2 接近 0 或 1),这个因子会趋近于 0,导致误差信号消失,学习停滞。而交叉熵组合天然消除了这个“饱和惩罚因子”,让误差信号始终和预测偏差成正比。

这不是唯一的搭配组合,但这是最经典的一种。理解了这个简化逻辑,以后你看到框架源码里各种 loss 和激活的配对,就不会觉得是在背公式了——它们是为了让反向传播的梯度表达式尽可能优雅且健壮。

4. 学习率、权重初始化与数据预处理:被低估的三件事

4.1 学习率:调参界的一票否决项

公式w ← w - α * ∂L/∂w里的 α 是神经网络最重要的超参数,没有之一。学习率太大了,参数每次跨越一大步,可能直接越过最优点,损失不降反升,甚至发散到 NaN;学习率太小了,参数像蜗牛一样挪动,训练几百轮损失还在高位徘徊。

我做过一个直观实验:同一个两层网络,同一个数据集,学习率从 0.01、0.1、1.0 三档,结果完全像三个不同的模型。0.1 的表现最好,0.01 的模型欠拟合,1.0 的模型损失曲线直接冲上天然后报数值错误。所以调参时第一个该检查的就是学习率。

如果害怕手动调,可以用自适应优化器,比如 Momentum 和 Adam。它们本质上是在“梯度下降”的骨架上加了历史梯度的惯性(Momentum)和一阶二阶矩估计(Adam)。我的经验是:先用 Adam 拿一个不错的基线,再把 Adam 换成 SGD + Momentum 微调争取更好效果,这是业界很常用的路线。

4.2 权重初始化为什么不能全零

新手最容易犯的错误就是全零初始化。想想看:如果 W1 和 W2 全部初始化为 0,那么前向传播时所有隐藏神经元的输入 z1 完全相同(都等于 b1),激活值 a1 也完全相同;反向传播时梯度也完全一样。于是所有隐藏神经元在训练过程中始终保持同步更新、同生共死——它们变成了一堆“重复的神经元”,网络的实际表达能力大打折扣,这就是对称性问题。

更好的做法是随机初始化,比如用服从小方差正态分布的随机数:W = np.random.randn(n_in, n_out) * scale。scale 的大小也有讲究。太大会让神经元输入落到 sigmoid 的饱和区,梯度消失;太小会让信号传着传着就衰减没了。标准做法是 Xavier/Glorot 初始化:scale = sqrt(1 / fan_in),其中 fan_in 是输入神经元个数;ReLU 系列激活则推荐 He 初始化:scale = sqrt(2 / fan_in)。

顺带提一句:偏置 b 一般可以初始化为 0,因为对称性问题主要出在权重上,偏置不参与“神经元之间”的信息传递。这个细节很多人忽略了,但它在实践里确实能省不少事。

4.3 输入归一化的隐形作用

这是另一个容易被忽略但影响巨大的细节:输入特征的尺度。

假设一个数据集有两个特征,一个是 0~1 的数值,另一个是 0~100000 的数值。损失函数对第二个特征相关权重的梯度会天然大得多(因为该特征变化范围大,稍微动一下权重损失就剧烈变化),导致参数更新时“厚此薄彼”,训练效率极低,过程还容易震荡。

解决办法是标准化(Standardization):对每个特征减去均值、除以标准差,让所有特征落在相近的尺度上。这样做之后,损失函数的等高线从“又长又扁的椭圆”变成“接近正圆”,梯度下降就能走直线快速到达最优点,而不是像走迷宫一样绕来绕去。

实践中给模型做数据预处理,优先级顺序是:先检查缺失值和异常值,再标准化/归一化,最后才考虑特征工程。很多框架教程跳过了这一步,导致读者照抄代码却复现不了效果,其实问题往往出在这里。

5. 从零实现一个两层网络:完整代码

5.1 网络结构与前向传播

理论讲了一堆,接下来动手。我们用 Python + NumPy 实现一个两层全连接网络:输入层 → 隐藏层(sigmoid)→ 输出层(sigmoid),用于二分类。代码刻意不用任何深度学习框架,所有矩阵运算手写,这样你能看到每个步骤的本质。

import numpy as np class TwoLayerNet: def __init__(self, input_size, hidden_size, output_size): # Xavier 初始化 self.params = {} self.params['W1'] = np.random.randn(input_size, hidden_size) / np.sqrt(input_size) self.params['b1'] = np.zeros(hidden_size) self.params['W2'] = np.random.randn(hidden_size, output_size) / np.sqrt(hidden_size) self.params['b2'] = np.zeros(output_size) def sigmoid(self, x): # 数值稳定的 sigmoid return 1.0 / (1.0 + np.exp(-np.clip(x, -500, 500))) def forward(self, x): W1, b1 = self.params['W1'], self.params['b1'] W2, b2 = self.params['W2'], self.params['b2'] self.z1 = np.dot(x, W1) + b1 self.a1 = self.sigmoid(self.z1) self.z2 = np.dot(self.a1, W2) + b2 self.a2 = self.sigmoid(self.z2) return self.a2

前向传播就是第 3.1 节里那五行公式的代码化。注意sigmoid里面我加了个np.clip(x, -500, 500),这是为了防止 x 绝对值太大时np.exp(-x)溢出。这种细节在理论推导时没人提,但实际跑数据很容易踩到。

5.2 反向传播与梯度更新

反向传播就是照着 3.2 节推导的公式逐行实现。关键是把中间变量(z1、a1、z2、a2)存下来,因为计算梯度时要用到它们:

def backward(self, x, y): N = x.shape[0] # 输出层误差:交叉熵 + sigmoid 的简化结果 delta2 = self.a2 - y # 隐藏层误差:误差通过 W2 加权传回,乘隐藏层 sigmoid 导数 delta1 = np.dot(delta2, self.params['W2'].T) * self.a1 * (1 - self.a1) grads = {} grads['W2'] = np.dot(self.a1.T, delta2) / N grads['b2'] = np.sum(delta2, axis=0) / N grads['W1'] = np.dot(x.T, delta1) / N grads['b1'] = np.sum(delta1, axis=0) / N return grads def update(self, grads, lr=0.1): for key in self.params: self.params[key] -= lr * grads[key]

update就是梯度下降的核心公式w ← w - α * ∂L/∂w。对于偏置的梯度,因为偏置是向量而不是矩阵,需要对所有样本的 delta 求和再平均,也就是np.sum(delta, axis=0) / N。别看这个细节小,写错会导致偏置永远不更新,模型完全学不动。

5.3 训练循环与损失曲线可视化

有了前向、反向、更新三件套,训练循环就很简单了。我生成一份可分二分类数据集来验证实现:

def make_dataset(n=2000, seed=42): rng = np.random.default_rng(seed) x = rng.uniform(-1, 1, (n, 2)) # 圆形决策边界:半径小于 0.7 的归为正类 y = (np.linalg.norm(x, axis=1) < 0.7).astype(int).reshape(-1, 1) return x, y x_train, y_train = make_dataset() net = TwoLayerNet(input_size=2, hidden_size=16, output_size=1) lr = 0.5 batch_size = 64 epochs = 200 losses = [] for epoch in range(epochs): # 每个 epoch 都打乱数据,避免样本顺序带来的偏差 perm = np.random.permutation(len(x_train)) x_shuffled, y_shuffled = x_train[perm], y_train[perm] for i in range(0, len(x_train), batch_size): x_batch = x_shuffled[i:i + batch_size] y_batch = y_shuffled[i:i + batch_size] out = net.forward(x_batch) loss = -np.mean(y_batch * np.log(out + 1e-8) + (1 - y_batch) * np.log(1 - out + 1e-8)) grads = net.backward(x_batch, y_batch) net.update(grads, lr=lr) if epoch % 20 == 0: print(f"epoch {epoch:3d}, loss = {loss:.6f}")

注意几个工程细节:数据在每个 epoch 开始时要打乱(shuffle),否则模型可能学到样本顺序的虚假模式;交叉熵里加了1e-8是为了防止log(0)导致数值错误。这些都是在真实训练里必须有的代码,但不是算法推导里会写的内容。

我实际跑了一下这个实现,200 个 epoch 后损失从约 0.65 降到 0.08 左右,分类准确率超过 97%。为了更直观,你可以用matplotlib把损失曲线画出来——理想情况下是一条平缓下降、最后趋平的曲线。看到自己的亲手实现的网络真的学会分类,这种成就感是直接调框架给不了的。

6. 训练过程的诊断与调参经验

6.1 损失曲线:最直观的体检报告

模型训练得怎么样,不要只看最终准确率,要学会看损失曲线的形状。曲线大致能分成几类:

  • 下降平滑、最终趋平:正常训练,可以适当调大学习率或增加 epoch 试试还能不能更好。
  • 前期下降,后期持续在一个高位不降:模型可能陷入局部极小,或者学习率偏小导致走不动。可以试试调大学习率、换优化器、或者调整网络结构。
  • 损失不降反升,或剧烈震荡:学习率过大的典型症状。先把学习率降一个数量级。
  • 损失降到底,但验证集效果差:过拟合信号,看 6.3 节。

我训练时习惯在代码里同时记录训练集和验证集的损失。如果两者开始分道扬镳——训练损失继续降、验证损失开始回升——就在那个点附近做早停(early stopping),这是最简单有效防止过拟合的手段之一。

6.2 数值梯度检查:反向传播的照妖镜

手写反向传播有个致命风险:公式推导对了,但代码某个地方多了个括号、少了个转置,结果是模型彻底不收敛,而你还不知道错在哪。这里有个行业通用的验证方法——数值梯度检查。

原理就是导数的定义:∂L/∂w 可以近似为 (L(w+ε) - L(w-ε)) / 2ε。用这个数值近似去和反向传播算出来的解析梯度比较,如果两者误差在 1e-7 量级,说明你的反向传播实现大概率是对的。

def numerical_gradient(net, x, y, eps=1e-5): grads = {} for key in net.params: param = net.params[key] grad = np.zeros_like(param) it = np.nditer(param, flags=['multi_index']) while not it.finished: idx = it.multi_index old_val = param[idx] param[idx] = old_val + eps out_plus = net.forward(x) loss_plus = -np.mean(y * np.log(out_plus + 1e-8) + (1 - y) * np.log(1 - out_plus + 1e-8)) param[idx] = old_val - eps out_minus = net.forward(x) loss_minus = -np.mean(y * np.log(out_minus + 1e-8) + (1 - y) * np.log(1 - out_minus + 1e-8)) grad[idx] = (loss_plus - loss_minus) / (2 * eps) param[idx] = old_val grads[key] = grad return grads

这个检查在写新网络结构时建议先做一遍。数值梯度慢,所以一般只在网络很小、数据很少的条件下跑一次就够了。跑通了再上大数据,没跑通就回去查反向传播代码。

6.3 过拟合的早期信号与简单应对

训练集损失一直在降,验证集损失却开始反弹——这就是过拟合的典型症状。尤其当网络参数很多、训练数据很少时,这个现象来得很早。

我遇到过的最经典的案例:在某个小型模拟数据集上训练一个有大量隐藏单元的网络,训练 50 个 epoch 时训练损失降到 0.02,验证损失却从 0.3 涨到 0.45。当时的应对措施按优先级排列:先加了 L2 正则化(权重的平方惩罚项),缓解了一部分;再调小网络规模,隐藏单元从 128 减到 32;最后加了早停。三步下来验证损失回到了 0.22。

L2 正则化在参数更新时变成w ← (1 - α * λ) * w - α * ∂L/∂w,相当于每次更新前先把权重缩小一点点,逼着模型不要过度依赖某些大权重。这个改动实现起来只要一行代码,效果却是立竿见影的。但注意 λ 别设太大,不然模型会退化成把所有权重都推近 0 的傻瓜模型,欠拟合也很难受。

7. 踩坑记录与最终建议

训练循环看起来简单,但我在不同项目里反复踩过几个同样的坑,最后再集中说一遍。

第一个坑是忘记 shuffle 数据。有一段时间我发现验证效果总比训练效果差一截,后来才发现数据本身按类别排好了序,每次 batch 拿到的都是同一类样本,梯度方向被带偏。打乱之后一切正常。

第二个坑是 dtype 不一致。造数据时用了 Python 的 float 列表,输入网络时没有转成np.float64,训练到一半矩阵运算直接报 shape 不匹配。这个听起来低级,但几乎每个人都会遇到至少一次。建议在所有输入数据入口统一做np.asarray(x, dtype=np.float32)。

第三个坑是对“损失降不下去”的过度反应。新手经常看到损失卡在某个值就疯狂调参,实际上要先确认数据本身是不是可分、标签有没有噪声。我在一个练习里把标签造了 5% 的随机翻转,损失注定降不到零,纠结了半天才反应过来是数据问题。

第四个坑是学习率调参时用网格搜索一次调好几个变量。学习率、隐藏层大小、batch size、正则系数同时改,改完根本不知道是哪个起作用了。正确做法是控制变量:固定其他所有超参,一次只动一个。

如果让我给一个可直接照抄的学习起步路线,那就是:先手写一个两层网络跑通,用数值梯度检查确认正确性,画损失曲线观察训练过程,再逐步加上更复杂的结构、优化器、正则化。这个过程走完,你不仅会实现学习算法,更能建立起对“模型为什么能学会”的直觉——这种直觉,在后面用任何框架做真实项目时,都会持续地帮你避开那些文档里根本不会写的坑。

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

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

立即咨询