人工智能

深入浅出反向传播:用 NumPy 手写神经网络

调包跑通模型很容易,但"梯度到底是怎么算出来的"往往说不清楚。本文不依赖任何框架,从计算图与链式法则出发,用几十行 NumPy 实现前向与反向传播,把深度学习最核心的一步拆开看。

神经网络的训练本质上是一件事:求出损失函数对每个参数的偏导,然后沿着负梯度方向挪一小步。 框架帮你自动算了这些偏导,但”为什么这么算”才是真正值钱的部分。这篇文章就用手写的方式把它讲透。

反向传播不是某种玄学算法,它就是链式法则在计算图上的高效应用——前向算值,反向算梯度。

1. 计算图与链式法则

把每个运算看成图上的一个节点,数据沿边单向前进,这就是计算图。前向传播时我们只需要数值; 反向传播时,我们从损失出发,沿边逆向把”损失对该节点的敏感度”一路传回去。

设 z = W·x + b,a = σ(z),L = loss(a, y)。 由链式法则,损失对 W 的梯度可以拆成三段相乘:

∂L/∂W = ∂L/∂a · ∂a/∂z · ∂z/∂W (1)

其中 ∂L/∂a · ∂a/∂z 这一项只和”当前层之后”有关,可以整块作为上游传来的梯度; 而 ∂z/∂W = x 只和本层输入有关。这种局部梯度 × 上游梯度的结构, 正是反向传播能逐层复用的原因。

2. 前向传播

我们搭一个两层网络:输入 2 维 → 隐藏层 8 维(ReLU)→ 输出 1 维(Sigmoid,二分类)。 特意把每层的中间结果缓存下来,反向时要用。

import numpy as np

def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-z))

def forward(X, params):
    W1, b1, W2, b2 = params
    z1 = X @ W1 + b1            # (N,8)
    a1 = np.maximum(0, z1)       # ReLU
    z2 = a1 @ W2 + b2           # (N,1)
    a2 = sigmoid(z2)            # 输出概率
    cache = (X, z1, a1, z2, a2)
    return a2, cache

交叉熵损失配合 Sigmoid 有个著名的化简结果:输出层梯度就是 a2 - y, 干净得不像话。这既不是巧合也不是调参技巧,而是对数损失与 Sigmoid 导数互相抵消的结果。

3. 反向传播:梯度怎么流回来

反向的过程就是从输出往输入倒着走一遍,每步都用”上游梯度 × 局部梯度”。 ReLU 的局部梯度是 0 或 1(靠 z1 > 0 判断),矩阵乘法的局部梯度则是另一个乘子。

def backward(cache, y, params):
    W1, b1, W2, b2 = params
    X, z1, a1, z2, a2 = cache
    N = X.shape[0]

    # 输出层:交叉熵 + Sigmoid 化简为 (a2 - y)
    dz2 = (a2 - y) / N

    # 第二层参数梯度
    dW2 = a1.T @ dz2
    db2 = dz2.sum(axis=0)

    # 梯度穿过第二层回到隐藏层
    da1 = dz2 @ W2.T
    dz1 = da1 * (z1 > 0)      # ReLU 导数:正区间为 1

    # 第一层参数梯度
    dW1 = X.T @ dz1
    db1 = dz1.sum(axis=0)
    return dW1, db1, dW2, db2

注意所有梯度都除以了 N:因为我们用的是整批样本的平均损失, 梯度自然也要取平均,否则学习率会随 batch 大小变化而”失效”。

4. 训练循环与参数更新

有了前向和反向,训练就是一个反复执行的循环:前向算损失 → 反向算梯度 → 沿负梯度更新参数。

rng = np.random.default_rng(0)
W1 = rng.normal(0, 0.5, (2, 8))
b1 = np.zeros(8)
W2 = rng.normal(0, 0.5, (8, 1))
b2 = np.zeros(1)
params = [W1, b1, W2, b2]

lr = 0.5
for epoch in range(500):
    a2, cache = forward(X, params)
    # 交叉熵损失
    loss = -np.mean(y*np.log(a2+1e-9) + (1-y)*np.log(1-a2+1e-9))
    grads = backward(cache, y, params)
    # 梯度下降:沿负梯度方向更新
    for p, g in zip(params, grads):
        p -= lr * g
    if epoch % 100 == 0:
        print(epoch, f"{loss:.4f}")

训练中加 1e-9 是为了防止 log(0) 产生 -inf,这是数值实现里最朴素也最必要的保护。

5. 完整实现要点

6. 总结

反向传播的全部内容就是一条链式法则加上一张计算图。前向缓存中间结果, 反向用”局部梯度 × 上游梯度”逐层回传,最后用结果更新参数。

手写一遍之后再看框架,loss.backward() 和 optimizer.step() 就不再是黑盒了。 这个理解在排查梯度消失、梯度爆炸、训练不收敛等问题时,价值远超调参技巧本身。