神经网络如何学习:反向传播与梯度下降

神经网络使用参数(包括权重和偏置)将输入转换为预测。架构规定要执行哪些计算,参数值则决定这些计算如何响应输入。训练根据样本调整参数,让网络学会识别手写数字、翻译文本等任务。

训练就是寻找有用参数值的过程。网络做出预测,用损失函数衡量误差,再计算损失随各个参数如何变化。反向传播负责计算这些导数,也称梯度。梯度下降等优化器利用梯度更新参数。

在最简单不过的例子上,它长这样——一个模型学着拟合一条直线。点几下 Step 看看:

step 0 · loss = 28.00

每次点击执行一次训练迭代:计算预测、衡量误差(红色虚线)、计算梯度并更新参数。在这里使用的学习率下,直线每一步都会更接近数据。

我们会从一个神经元开始,逐步把这些计算扩展到多层网络。交互组件展示数值如何变化,Python 代码则把这些变化与实现联系起来。

先看神经元和损失的例子,再学习导数与链式法则。如果你已经熟悉微积分,可以直接阅读跨越多层的链式法则。

神经网络就是一堆参数

我们使用全连接前馈网络:神经元按层排列,每个神经元接收前一层的所有输出,并把这些输入组合成一个输出。要理解这种网络,我们先从单个神经元开始。

下面的组件展示一个有 3 个输入的神经元。左侧设置输入值,右侧设置权重、偏置和激活函数。权重与偏置是在训练中学习的参数;激活函数则是设计网络时做出的选择。

w₁x₁ + w₂x₂ + w₃x₃ + b → activation(sum) → output
0.5·1 + 0.5·0 + 0.5·1 + 0.0 = 1.0 → perceptron(1.0) = 1
inputs
1.0
0.0
1.0
neuron parameters
0.5
0.5
0.5
0.0

在这个组件上你能看到,神经元把每个输入乘以对应的权重,连同偏置一起加起来, 再把结果送过一个激活函数。

写成公式就是:

output=f(w1x1+w2x2+w3x3+b)\text{output} = f(w_1 x_1 + w_2 x_2 + w_3 x_3 + b)

其中 xix_i 是输入,wiw_i 是对应的权重,bb 是偏置,ff 是激活函数。

试几种配置来建立直觉:

  • 权重控制输入的影响。 设置 x₁ = 1.0,其余输入为 0,再拖动 w₁,观察输出变化。当 w₁ = 0 时,无论这个输入取什么值,都不会影响结果。
  • 权重的符号改变其贡献。 设置 x₁ = 1.0、w₁ = -1.5,其余为 0。加权和为负,感知机激活输出 0。再把 x₁ 设为 -1.0,乘积就变为正数。负权重在输入为正时减小加权和,在输入为负时增大加权和。
  • 偏置平移决策边界。 所有输入为 0 时,加权和只由偏置决定。使用感知机激活时,正偏置使输出为 1;负偏置则要求输入的加权和先抵消它,输出才会变为 1。
  • 激活函数决定输出形式。 从 Perceptron(硬性的 0/1)切换到 Sigmoid,输出变为 0 到 1 之间的平滑值。ReLU 将正值原样保留,把负值截为零。

为什么需要激活函数? 没有非线性激活时,每层计算的都是仿射函数,即线性变换加上偏置。无论叠多少层,复合后仍然是一个仿射函数。非线性激活让网络能够表示更复杂的关系。例如,它能变换输入,使原本无法用直线分开的类别变得可分。Chris Olah 在 Neural Networks, Manifolds, and Topology 中直观展示了这种几何变化。

权重和偏置就是神经元的参数——它需要学出来的那些值。神经元为每个输入存一个权重——在上面的组件里,就是一个 3 元素的向量。权重决定每个输入有多重要,偏置把结果整体上移或下移。这些参数合起来定义了神经元对什么有反应。换一组权重和偏置,同一个神经元就会在输入中检测出完全不同的模式。

由于「相乘再求和」就是点积,这通常写成向量形式:

output=f(w⋅x+b)\text{output} = f(\mathbf{w} \cdot \mathbf{x} + b)

这里 x\mathbf{x} 是所有输入组成的向量(列表)——例如 [x1,x2,x3][x_1, x_2, x_3]—— 而 w\mathbf{w} 是所有权重组成的向量——例如 [w1,w2,w3][w_1, w_2, w_3]。 点积 w⋅x\mathbf{w} \cdot \mathbf{x} 把每一对相乘再把结果加起来:w1x1+w2x2+w3x3w_1 x_1 + w_2 x_2 + w_3 x_3。

在 Python 里,可能长这样:

import numpy as np

class Neuron:
    def __init__(self, n_inputs):
        self.w = np.random.randn(n_inputs)   # w — weight vector, e.g. [w₁, w₂, w₃]
        self.b = 0.0                         # b — bias

    def forward(self, x):                    # x — input vector, e.g. [x₁, x₂, x₃]
        z = np.dot(self.w, x) + self.b       # w · x + b — dot product + bias
        return max(0, z)                     # f(z) — activation function (ReLU)

# Create a neuron with 3 inputs and run it
neuron = Neuron(3)
output = neuron.forward(np.array([1.0, 0.5, 0.7]))

一层就是一堆神经元

把很多这样的神经元摞在一起,你就得到一层。再把几层拼起来,就得到一个神经网络。 这里有个小的——2 个输入、两个各含 3 个神经元的隐藏层、1 个输出。之所以叫「隐藏」,是因为你只看得到进去的输入和出来的输出——中间那些层是网络内部的,从外面看不见:

x₁x₂yinputlayer 1layer 2output

每个神经元为每个输入存储一个权重,再加上偏置并应用激活函数:

output=f(w1x1+w2x2+w3x3+b)\text{output} = f(w_1 x_1 + w_2 x_2 + w_3 x_3 + b)

一层通常把它们统一存在一个权重矩阵(WW)里——每个神经元占一行权重。 对上图那个网络,layer1.W 是一个 3×2 的矩阵——3 个神经元,每个 2 个权重,因为每个神经元接收 2 个输入:

layer1.W = [[ 0.4, -0.2],    ← neuron 0: weights for x₁, x₂
            [ 0.1,  0.7],    ← neuron 1: weights for x₁, x₂
            [-0.3,  0.5]]    ← neuron 2: weights for x₁, x₂

对单个神经元,我们有一个权重向量与输入的点积:f(w⋅x+b)f(\mathbf{w} \cdot \mathbf{x} + b)。对一整层,我们把所有权重向量堆成矩阵 WW,把所有偏置堆成向量 b\mathbf{b},于是同一个运算一次就作用到所有神经元上:

output=f(Wx+b)\text{output} = f(W\mathbf{x} + \mathbf{b})

计算 WxW\mathbf{x} 时,WW 的每一行都与输入做点积——那就是一个神经元的加权和。矩阵乘法一次运算就把它们全做完了。

single neuron (dot product)w₁w₂w₃w·x₁x₂x₃x=outone row → one output↓stack 3 neuronsfull layer (matrix multiply)0.4-0.2← n₀0.10.7← n₁-0.30.5← n₂W@x₁x₂x=w₀·xw₁·xw₂·x← neuron 0← neuron 1← neuron 2

想从几何角度理解这项计算,可以阅读权重矩阵究竟做了什么。文章跟踪四个输入点通过隐藏层的过程,展示权重矩阵、偏置和非线性激活如何改变它们的表示,让类别变得可分。

在 Python 里,长这样:

import numpy as np

class Layer:
    def __init__(self, n_inputs, n_neurons):
        # W is a matrix where each ROW is one neuron's weights.
        # Shape: (n_neurons, n_inputs) — so W[0] is neuron 0's weights,
        # W[1] is neuron 1's weights, etc.
        self.W = np.random.randn(n_neurons, n_inputs)
        self.b = np.zeros(n_neurons)  # b — bias vector, one per neuron

    def forward(self, x):
        # W @ x multiplies every neuron's weight row by the input,
        # computing all dot products at once
        return np.maximum(0, self.W @ x + self.b)  # ReLU activation

# Build the network from the diagram above
layer1 = Layer(2, 3)   # 2 inputs  → 3 neurons  (6 weights + 3 biases = 9)
layer2 = Layer(3, 3)   # 3 inputs  → 3 neurons  (9 weights + 3 biases = 12)
output = Layer(3, 1)   # 3 inputs  → 1 neuron   (3 weights + 1 bias   = 4)

# Forward pass — each layer's output feeds into the next
x = np.array([0.5, 0.8])
h1 = layer1.forward(x)       # input → hidden layer 1
h2 = layer2.forward(h1)      # hidden layer 1 → hidden layer 2
y  = output.forward(h2)      # hidden layer 2 → output

矩阵运算让计算库能同时处理多个神经元和多个样本,因此很适合在 GPU 上并行执行。

每个权重和偏置都是一个参数。第一层有 9 个,第二层有 12 个,输出层有 4 个,共 25 个参数。训练需要为每个参数计算更新量。

网络是怎么学的?

训练网络意味着调整权重和偏置。权重通常随机初始化,以打破神经元之间的对称性;偏置可以从零开始,就像我们的代码一样。训练随后根据损失的梯度更新它们。

每次训练迭代包含四个阶段:

阶段它做什么
1. 前向传播把输入送过网络的每一层,乘以权重并施加激活,产生一个预测
2. 计算损失用损失函数(例如 MSE)把预测与真实目标值作比较,把所有误差压缩成一个数字——模型错得有多离谱?
3. 反向传播沿网络反向应用链式法则,计算损失相对于每个参数的变化率
4. 梯度下降从每个参数中减去学习率与该参数梯度的乘积,向降低损失的方向迈出一步

训练包含全部四个阶段,从前向传播开始。推理时,网络使用当前参数做预测,不计算训练梯度,也不更新权重。

后文将重点解释如何衡量误差、计算梯度,以及用梯度更新参数。

这个过程有两个方向:数据向前流动以生成预测,梯度再向后流动,让优化器能够更新权重:

  Forward pass: each layer receives activations, passes output →

              activations    activations    activations
  Input ─────────▶ Layer 1 ─────────▶ Layer 2 ─────────▶ Output ──▶ Loss

  Backward pass: each layer receives gradient signal, passes it ←

              gradients      gradients      gradients
  Input ◀───────── Layer 1 ◀───────── Layer 2 ◀───────── Output ◀── ∂L
              ↓ ∂L/∂W₁           ↓ ∂L/∂W₂           ↓ ∂L/∂W₃
           (own weight       (own weight         (own weight
            gradients)        gradients)          gradients)

注意其中的对称:前向传播时,每一层从前一层收到激活值,并把自己的输出往前传。反向传播时,每一层从后一层收到梯度信号,并把它往后传。两个方向上,每一层都需要邻居给的输入才能干活。

一个简单例子:拟合一条直线

我们先考虑一个神经元、一个输入、一个权重和一个偏置。这样只需研究两个参数对应的损失,再把梯度计算扩展到多层网络。

对于单个输入和恒等激活(将输入原样返回),神经元计算 y^=wx+b\hat y = wx + b。这里 ww 和 xx 都是标量。用这个神经元拟合直线,可以先研究学习算法,而不必处理激活函数的导数。

假设有人给你五个点,说它们来自某个线性函数 f(x)=wx+bf(x) = wx + b,并让你找出 w 和 b:

x(输入)-2-1012
y(输出)-3-1135

数据给出了输入和输出。我们要找的是连接两者的参数:直线斜率 ww,以及与 y 轴相交处的截距 bb。

学到函数后,就能对训练数据中没有出现过的输入做预测。如果关系是 f(x)=2x+1f(x) = 2x + 1,即使表里没有 1.5,也能预测 f(1.5)=4f(1.5) = 4。在新输入上做出准确预测称为泛化。本例的数据严格服从一条直线;对真实数据而言,仅仅拟合好训练集,并不能证明模型能够泛化。

那我们把这些点画到图上(绿色圆点),试着靠拖动滑块手动找出 w 和 b。 用损失值来指引你——拖一拖,看看能不能把损失降到零。你会发现 w=2w = 2、b=1b = 1 能让损失归零——那正是生成这些数据的参数,也就是 y=2x+1y = 2x + 1:

0.0
0.0

滑块旁显示的损失,是概括整个数据集预测误差的一个数。

拖动滑块时,注意那些红色虚线——它们是每个数据点上各自的误差,显示预测与真实值差了多少。我们把每个点的误差算作 error=prediction−actualerror = prediction - actual。

不同损失函数用不同方式汇总误差:

  • 均方误差(MSE)——用于回归(预测数值)。把每个误差平方后求平均。
  • 交叉熵——用于分类(预测类别)。衡量预测的概率离真实标签有多远。
  • 平均绝对误差(MAE)——像 MSE,但用绝对值而非平方,对离群值不那么敏感。

拟合直线属于回归,即预测连续数值,因此我们使用均方误差(MSE):把每个误差平方后取平均。平方结果非负,正负误差不会相互抵消,较大的误差也会获得更大的权重:

MSE=1n∑i=1n(ypredi−yactuali)2=(ypred1−yactual1)2+(ypred2−yactual2)2+⋯+(ypredn−yactualn)2nMSE = \frac{1}{n} \sum_{i=1}^{n} (y_{\text{pred}_i} - y_{\text{actual}_i})^2 = \frac{(y_{\text{pred}_1} - y_{\text{actual}_1})^2 + (y_{\text{pred}_2} - y_{\text{actual}_2})^2 + \cdots + (y_{\text{pred}_n} - y_{\text{actual}_n})^2}{n}

把这个公式用到我们的数据上。假设你当前的猜测是 w=3w = 3、b=1b = 1,也就是 f(x)=3x+1f(x) = 3x + 1。对我们那 5 个数据点,我们逐一算出预测、误差(差多远)和误差的平方:

xxyactualy_{\text{actual}}ypred=3x+1y_{\text{pred}} = 3x + 1误差误差²
-2-3-5-24
-1-1-2-11
01100
13411
25724
平均 →损失 = 2.0

MSE 为 2.0。对它开平方,得到均方根误差(RMSE) 2≈1.41\sqrt{2} \approx 1.41,单位与预测值相同。它不同于平均绝对误差,后者在这里是 (2+1+0+1+2)/5=1.2(2 + 1 + 0 + 1 + 2)/5 = 1.2。当 w=2w = 2、b=1b = 1 时,所有误差和这两种指标都为零。

两个参数可以手动调整,但面对数百万参数时,这样做就不现实了。后面我们会将这些计算用于识别手写数字,网络包含超过 100,000 个参数。我们需要系统的方法:反向传播计算梯度,梯度下降据此小幅更新参数,反复执行以降低损失。

线性最小二乘问题也可以通过正规方程或矩阵分解直接求解。得到的是平方误差目标的最小值,并不保证能完美拟合任意数据。一般神经网络没有类似的直接解法,因此使用梯度下降等迭代优化器。

一步步走完训练循环

先来看看这个自动化的算法是怎么工作的。 下面的组件让你为我们的任务一步步跑反向传播和梯度下降,把一切看在眼里:

  • 左图:绿色数据点,以及当前 ww、bb 对应的蓝色预测直线。红线表示误差,误差平方后取平均便得到 MSE。
  • 右图:每次更新后的损失。在默认学习率下,损失逐步降到零。曲线变平可能表示收敛;损失上升则可能说明学习率过大。

点 Step 执行一次梯度下降更新,或者点 Step x10 一口气跑十次。

Backpropagation

-3.0
3.0

Gradient Descent

0.10

▶ Computation (step 0)

保持默认学习率 0.1,多次点击 Step。随着直线接近数据,损失会下降。展开 Computation,查看预测、误差和梯度。

在这个例子里,参数越接近最小值,梯度越小。因此即使 lr 不变,更新量 lr * dw 和 lr * db 也会缩小。参数逐渐收敛到 w = 2, b = 1。更大的学习率可能导致无法收敛,下一个组件会展示这一点。

每点一次「Step」,就跑了一整轮训练迭代——对应我们前面描述的训练循环的四个步骤,在 Python 里长这样:

# 1. forward pass
y_pred = w * x + b

# 2. loss computation
error  = y_pred - y
loss   = np.mean(error ** 2)

# 3. backpropagation
dw = 2 * np.mean(error * x)
db = 2 * np.mean(error)

# 4. gradient descent
w = w - lr * dw
b = b - lr * db

阶段 1 和 2 计算预测及其 MSE。阶段 3 计算损失梯度。阶段 4 从每个参数中减去它的梯度乘以学习率 lr。我们先看学习率,再推导 dw 和 db。

选择学习率

在上面的计算部分里可以看到,梯度下降是这样更新参数的:

w = w - lr * dw
b = b - lr * db

梯度(dw、db)告诉我们每个参数该往哪个方向动,以及相对于其他参数动多少。但实际上该迈多远?这由学习率(lr)控制——它在应用之前把每个梯度缩放一下。

当损失可微且梯度非零时,负梯度在局部指向下降方向。但有限大小的一步仍可能越过最小值,反而增大损失。在本例中:

  • 缓慢收敛(0.01):更新很小,需要很多步才能接近最小值。
  • 平稳收敛(0.1):每一步的损失都会下降。
  • 振幅递减(0.4):w 每一步都会越过最优值,但与它的距离逐渐缩小。
  • 持续振荡(0.5):w 在 -3 和 7 之间来回切换。偏置立即达到 1,但从第一步起,损失一直停在 50。
  • 发散(1.0 或 1.5):w 越过最优值的幅度越来越大,损失不断增长。

自己试试——改一下学习率再点 Step x10 看效果:

0.10

稳定的学习率范围取决于损失函数。对于这个数据集,要从任意初始点收敛,学习率必须为正且小于 0.5。稍后简化损失公式时,我们会看到原因。

更大的网络需要根据模型、数据和优化器调整学习率。学习率调度会在训练过程中改变学习率。Adam 等自适应优化器还会利用梯度历史缩放参数更新。这些方法改变更新规则,但仍保留前向传播、损失计算和反向传播阶段。

计算反向传播

在上面的计算部分里可以看到,反向传播是这样算梯度的:

dw = 2 * np.mean(error * x)
db = 2 * np.mean(error)

这两行里塞了不少东西。为什么 dw 要把 error 乘上 x,而 db 不用?那个 2 从哪来的?mean 又和这有什么关系?我们一步步拆开。

记住,损失是从预测算出来的,而预测取决于 w 和 b。 归根到底,损失是参数的函数——改变 w 或 b,损失就变。 在下面的组件里拖动 w 或 b,看着损失怎么变——白点沿曲线移动,精确显示你在损失地形上的位置:

-3.0
3.0

拖动 w:点沿左侧曲线移动,右侧曲线则上下平移。改变 b 时情况相反。对于这个中心化的数据集,沿 w 轴的最小值始终在 2,沿 b 轴的最小值始终在 1。这里的两个参数可以独立优化,我们将在后面推导原因。

梯度公式来自对这些曲线求导数——衡量当你把每个参数推动一丁点时,损失变化多少。 所以 2 * mean(error * x) 不过就是损失函数对 w 的导数。

要理解我们如何从损失函数走到 2 * mean(error * x),需要三个层层递进的概念:

  1. 导数——衡量一个函数如何变化意味着什么
  2. 链式法则——当函数被串起来时,导数怎么算
  3. 偏导数与梯度——如何同时处理多个参数

到最后,我们会精确追溯这个公式里每一部分的来历。先从导数究竟是什么讲起。

我们刚看到的公式——2 * mean(error * x)——是针对线性模型配 MSE 损失的特例。不同的损失函数和架构会给出不同的梯度公式——但底层的数学原理始终相同。对我们这个简单模型,公式可以手推;对有几百万参数的深度网络,PyTorch、TensorFlow 这类框架会用 autograd(自动微分)自动求导。

导数:某一点上的斜率

导数回答一个问题:如果我把这个输入推动一丁点,输出会变多少? 把它想成曲线在某一点的斜率。如果你站在山坡上,导数告诉你脚下的地有多陡——以及往哪边是下坡。

拿一个简单函数比如 f(x) = x²。沿曲线拖动点 x,看看斜率和导数怎么变:

At x=1.0 derivative is 2.0
1.0
0.80

▼ Computation

在组件里把 x 设为 2、dx 设为 0.5。黄线(dxdx)是给输入的一个推动,绿线(dfdf)是输出随之变化了多少。图下方的 Computation 部分展示了它们怎样组合成导数。

首先,我们在这一点上求值:f(2)=4f(2) = 4。然后把输入推动 dx 再求一次:f(2.5)=6.25f(2.5) = 6.25。差值告诉我们输出变化了多少:df=6.25−4=2.25df = 6.25 - 4 = 2.25。除以推动量得到变化率:df/dx=2.25/0.5=4.5df/dx = 2.25 / 0.5 = 4.5。

这个比值(4.5)近似等于 x = 2 处的导数——它给出的是速率:在这一点上,输出变化的速度大约是输入的 4 倍。 它不正好是 4,因为 dx = 0.5 仍是个不小的推动。现在把 dx 调小——试着拖到 0.1:

  • f(2)=4f(2) = 4
  • f(2.1)=4.41f(2.1) = 4.41
  • df=0.41df = 0.41
  • df/dx=0.41/0.1=4.1df/dx = 0.41 / 0.1 = 4.1——更接近 4 了

导数就是当输入变化趋近于零时,这个比值的极限:

f′(x)=lim⁡dx→0f(x+dx)−f(x)dxf'(x) = \lim_{dx \to 0} \frac{f(x + dx) - f(x)}{dx}

f(x+dx)−f(x)f(x + dx) - f(x) 是输出的变化(dfdf)。除以 dxdx 得到比值。lim⁡dx→0\lim_{dx \to 0} 这一部分只是说「让 dxdx 收缩趋近于零」——正是你用滑块做的事,看着比值收敛到精确值。

对 f(x)=x2f(x) = x^2,我们可以推一推:

f(x+dx)=(x+dx)2=x2+2x⋅dx+dx2f(x + dx) = (x + dx)^2 = x^2 + 2x \cdot dx + dx^2 f(x+dx)−f(x)=2x⋅dx+dx2f(x + dx) - f(x) = 2x \cdot dx + dx^2 f(x+dx)−f(x)dx=2x+dx\frac{f(x + dx) - f(x)}{dx} = 2x + dx

当 dx→0dx \to 0 时,就只剩 2x2x。所以 dfdx=2x\frac{df}{dx} = 2x。

得到导数公式后,就能直接计算 2x2x,而不必通过越来越小的输入变化来估计斜率。

另一份直观入门材料是 3Blue1Brown 的 The Essence of Calculus。

链式法则:求复合函数的导数

我们知道怎么求 f(x)=x2f(x) = x^2 这类简单函数的导数。但当一个函数的输出喂进另一个函数时会怎样? 那叫函数复合——而我们的计算正是这样:

y_pred = w * x + b             # prediction
error  = y_pred - y            # how far off
loss   = np.mean(error ** 2)   # squared error, averaged

为推导梯度,先考虑一个数据点。它的平方误差通过三项运算依赖 w:计算预测、减去目标值、平方。先对这条链求导,再对整个数据集取平均。

所以可以看到,从 w 算到损失并不是一个函数,而是三个函数组成的链条,每一个都把自己的输出送进下一个:

w→f1y_pred→f2error→f3error2w \xrightarrow{f_1} y\_pred \xrightarrow{f_2} error \xrightarrow{f_3} error^2

写清楚就是:

  • f1(w)=w⋅x+bf_1(w) = w \cdot x + b——模型的预测
  • f2(y_pred)=y_pred−yf_2(y\_pred) = y\_pred - y——我们差了多远
  • f3(error)=error2f_3(error) = error^2——误差的平方(我们要最小化的东西)

损失就是 f3(f2(f1(w)))f_3(f_2(f_1(w)))——三个函数一层套一层。

每个单独函数的导数我们都会求,但怎么把它们合起来得到整条链的导数? 答案是链式法则:把各段的局部导数相乘。

d(loss)dw=f1′⋅f2′⋅f3′\frac{d(\text{loss})}{dw} = f'_1 \cdot f'_2 \cdot f'_3

回忆一下导数公式:

f′(x)=lim⁡dx→0f(x+dx)−f(x)dxf'(x) = \lim_{dx \to 0} \frac{f(x + dx) - f(x)}{dx}。

有限变化的比值在极限下趋近导数。导数记为 df/dxdf/dx,输出量写在上面,输入量写在下面。对链中的三个函数而言:

  • f1′f'_1:输出是 y_predy\_pred,输入是 ww → d(y_pred)dw\frac{d(y\_pred)}{dw}
  • f2′f'_2:输出是 errorerror,输入是 y_predy\_pred → d(error)d(y_pred)\frac{d(\text{error})}{d(y\_pred)}
  • f3′f'_3:输出是 error2error^2,输入是 errorerror → d(error2)d(error)\frac{d(\text{error}^2)}{d(\text{error})}

用这套记法,链式法则展开成:

d(loss)dw=f1′⋅f2′⋅f3′=d(y_pred)dw⋅d(error)d(y_pred)⋅d(error2)d(error)\frac{d(\text{loss})}{dw} = f'_1 \cdot f'_2 \cdot f'_3 = \frac{d(y\_pred)}{dw} \cdot \frac{d(\text{error})}{d(y\_pred)} \cdot \frac{d(\text{error}^2)}{d(\text{error})}

为什么是相乘?因为一个函数的输出成为下一个函数的输入。w 的微小变化会使 y_pred 改变该变化的 x 倍,随后 error 改变同样的量。当变化足够小时,error² 的变化近似为 error 变化量的 2·error 倍。每个环节都缩放变化量,这些缩放系数相乘。

组合函数的三种方式

把两个函数 f(x)f(x) 和 g(x)g(x) 组合起来有三种基本方式,每一种都有自己那套导数如何组合的规则:

  1. 相加:h(x)=f(x)+g(x)h(x) = f(x) + g(x)——导数相加。如果 ff 变化 3、gg 变化 5,那么和变化 8。这就是和法则:h′(x)=f′(x)+g′(x)h'(x) = f'(x) + g'(x)。

  2. 相乘:h(x)=f(x)⋅g(x)h(x) = f(x) \cdot g(x)——这更复杂些,因为两个因子都会变。这就是乘积法则:h′(x)=f′(x)⋅g(x)+f(x)⋅g′(x)h'(x) = f'(x) \cdot g(x) + f(x) \cdot g'(x)。你得分别考虑每个函数在另一个保持不变时的变化。

  3. 复合(嵌套):h(x)=f(g(x))h(x) = f(g(x))——gg 的输出喂进 ff。导数相乘。这就是链式法则:h′(x)=f′(g(x))⋅g′(x)h'(x) = f'(g(x)) \cdot g'(x)。给 xx 的一个推动先被 g′g' 缩放,缩放后的变化再被 f′f' 缩放一次。

我们的损失计算是一个复合——f3(f2(f1(w)))f_3(f_2(f_1(w)))——所以我们把导数相乘。要是这些函数是相加或相乘组合的,我们就得用相应的规则。实践中,神经网络三种都用:相加(偏置项)、相乘(权重乘输入)和复合(层与层相互喂入)。反向传播会为每个运算套用对应的那条规则。

好,那我们来算算这条损失链的整体导数。我们展示过怎么求 x2x^2 的导数,得到了 2x2x。 同样的办法对更简单的函数也适用:ax+bax + b 的导数就是 aa(一个常数倍),x−cx - c 的导数是 11(减去一个常数不改变变化率)。这让每个单独导数的计算都很直白:

  • 要算 f1′=d(y_pred)dwf'_1 = \frac{d(y\_pred)}{dw},我们利用「ax+bax + b 的导数是 aa」。因为 y_pred=w⋅x+by\_pred = w \cdot x + b,导数就是 x。
  • 要算 f2′=d(error)d(y_pred)f'_2 = \frac{d(\text{error})}{d(y\_pred)},我们利用「x−cx - c 的导数是 11」。因为 error=y_pred−yerror = y\_pred - y,导数就是 1。
  • 要算 f3′=d(error2)d(error)f'_3 = \frac{d(\text{error}^2)}{d(\text{error})},我们利用「x2x^2 的导数是 2x2x」。因为函数是 error2error^2,导数就是 2 · error。

于是我们得到:

d(loss)dw=f1′⋅f2′⋅f3′=x⋅1⋅(2⋅error)=2⋅error⋅x\frac{d(\text{loss})}{dw} = f'_1 \cdot f'_2 \cdot f'_3 = x \cdot 1 \cdot (2 \cdot error) = 2 \cdot error \cdot x

对单个数据点来说,这就是 2⋅error⋅x2 \cdot error \cdot x。

用数字算一遍。设 w = 3, b = 1,取数据点 x = 2, y = 5:

w = 3
  ↓  × x = ×2
y_pred = 3·2 + 1 = 7
  ↓  × 1
error = 7 - 5 = 2
  ↓  × 2·error = ×4
error² = 4

链式法则给出 f1′⋅f2′⋅f3′=2⋅1⋅4=8f'_1 \cdot f'_2 \cdot f'_3 = 2 \cdot 1 \cdot 4 = 8。对于微小变化 Δw\Delta w,平方误差的变化近似为 8Δw8\Delta w。这是局部近似:将 w 从 3 增至 4,平方误差会从 4 变成 16,增加的是 12,而不是 8。

但我们有 5 个数据点,不是一个。既然 MSE 把所有点的平方误差取平均,那导数也要取平均。对每个点我们算 2⋅error⋅x2 \cdot error \cdot x:

xxyyypred=3x+1y_{pred} = 3x + 1errorerror2⋅error⋅x2 \cdot error \cdot x
-2-3-5-22⋅(−2)⋅(−2)=82 \cdot (-2) \cdot (-2) = 8
-1-1-2-12⋅(−1)⋅(−1)=22 \cdot (-1) \cdot (-1) = 2
01102⋅0⋅0=02 \cdot 0 \cdot 0 = 0
13412⋅1⋅1=22 \cdot 1 \cdot 1 = 2
25722⋅2⋅2=82 \cdot 2 \cdot 2 = 8

求平均:8+2+0+2+85=4\frac{8 + 2 + 0 + 2 + 8}{5} = 4。所以 dw = 4——梯度告诉我们增大 w 会让损失上升,因此我们应当减小它。(确实如此,真值是 w = 2,比我们猜的 3 要小。)

用数学记法就是:

d(loss)dw=1n∑i=1n2⋅errori⋅xi=2⋅1n∑i=1nerrori⋅xi\frac{d(\text{loss})}{dw} = \frac{1}{n} \sum_{i=1}^{n} 2 \cdot error_i \cdot x_i = 2 \cdot \frac{1}{n} \sum_{i=1}^{n} error_i \cdot x_i

在 Python 里:

dw = 2 * np.mean(error * x)

下面的组件让你为每个数据点追踪这条链。点击不同的 x= 按钮,看看局部导数怎么变——注意链式法则对每个点给出的值都不同,因为 x 和 error 不一样:

Tracing d(loss)/dw for data point:
w
→
y_pred = w·x + b
→
error = y_pred - y
→
error²
-3.0
3.0

对 db 来说链条是一样的,只是 f1′f'_1 不同: 因为 y_pred=w⋅x+by\_pred = w \cdot x + b,对 b 的导数就是 1(而不是 x)。所以:

d(loss)db=f1′⋅f2′⋅f3′=1⋅1⋅(2⋅error)=2⋅error\frac{d(\text{loss})}{db} = f'_1 \cdot f'_2 \cdot f'_3 = 1 \cdot 1 \cdot (2 \cdot error) = 2 \cdot error

在我们的 Python 代码里长这样:

db = 2 * np.mean(error)

这就是链式法则为什么重要:只要损失是通过一连串运算算出来的(而它总是如此),你就需要它来回溯每个参数是怎么影响最终结果的。对我们这个双参数模型,链条有 3 步。一个深度神经网络可能有几百步——每层一步——但原理完全一致:每一层不过是复合里多出的一个函数、多出的一个要相乘的局部导数。

把导数用到我们的损失函数上

既然知道了怎么求单个导数、怎么用链式法则把它们组合起来,就把这套知识用到我们的问题上。 我们想最小化的那条「曲线」就是我们的损失函数——我们选来衡量误差的那个公式。 我们对这个函数了如指掌:

loss(w,b)=1n∑i=1n(w⋅xi+b−yi)2loss(w, b) = \frac{1}{n}\sum_{i=1}^{n}(w \cdot x_i + b - y_i)^2

loss = np.mean((w * x + b - y) ** 2)

我们不知道的是,w 和 b 取什么值时它最小。导数帮我们弄清楚这一点: 它告诉我们,如果把 w 增大一丁点,损失是上升还是下降?有多快?

数据(x 和 y)是固定的——那是我们的训练数据。如果我们暂时也把 b 固定住(比如 b = 3),那么损失就成了只关于 w 的函数,可以画成一条简单曲线。比如在 w = 0 处:

y_pred = w * x + b                  # 0 * [-2,-1,0,1,2] + 3 = [3, 3, 3, 3, 3]
error  = y_pred - y                 # [3,3,3,3,3] - [-3,-1,1,3,5] = [6, 4, 2, 0, -2]
loss   = np.mean(error ** 2)        # mean([36, 16, 4, 0, 4]) = 12.0

这给了我们曲线上的一个点:(w=0, loss=12)。对从 -5 到 5 的每个 w 都这么做一遍(保持 b = 3 不变),我们就得到完整图景——损失作为 w 单独的函数:

0.0(b = 3.0 fixed)

横轴是 w,纵轴是损失。最小值位于 w = 2,但此时损失为 4,因为 b 仍固定为 3。每个预测都比目标高 2。拖动滑块,查看每个曲线点对应的预测、误差和平方误差。

对 b 我们可以做同样的事——这次固定 w = 2,让 b 从 -5 变到 5:

3.0(w = 2.0 fixed)

同样的抛物线形状,只是现在 x 轴是 b。最小值在 b = 1,那里损失降到零。合起来,w = 2 和 b = 1 正是生成我们数据的那对参数——y = 2x + 1。

这些图通过计算多个参数值处的损失来展示形状。参数增多后,遍历所有组合就不现实了。梯度下降只在当前参数处计算导数,用它们决定下一步。

偏导数

注意我们刚才做了什么:为了理解损失如何依赖 w,我们冻住 b,只变 w。为了理解它如何依赖 b,我们冻住 w,只变 b。这正是偏导数的含义——在其他参数保持不变的前提下,损失对某一个参数的导数:

  • ∂loss/∂w——推动 w 时损失如何变化(b 被冻住)
  • ∂loss/∂b——推动 b 时损失如何变化(w 被冻住)

上面那两条曲线,各自都是沿着一个参数在损失地形上切出的一片。那条曲线在任意点的斜率就是偏导数:

dw = 2 * np.mean(error * x)        # ∂loss/∂w — how loss changes with w
db = 2 * np.mean(error)            # ∂loss/∂b — how loss changes with b

下面的组件把上面看过的两条曲线合在一起,现在展示的是偏导数在起作用。左图变动 w(固定 b),右图变动 b(固定 w)。在每张图上,白点是你当前所在的位置,蓝色虚线是切线(它的斜率就是偏导数),绿色箭头指出往哪边走能减小损失。

-3.0(b = 3.0 fixed)
3.0(w = -3.0 fixed)

拖动滑块,看看会怎样:

  • 远离最小值时——曲线很陡,切线倾斜得厉害,导数是个大数。梯度下降在这里迈大步。
  • 靠近最小值时——曲线变平,切线几乎水平,导数接近零。步子变得极小——模型在精修。
  • 在最小值处——切线完全水平。导数为零。无处可去——你到了。

我们的五个数据点满足 yi=2xi+1y_i = 2x_i + 1,xx 的平均值为零,x2x^2 的平均值为 2。展开损失得到:

L(w,b)=15∑i((w−2)xi+(b−1))2=2(w−2)2+(b−1)2L(w,b) = \frac{1}{5}\sum_i\big((w-2)x_i + (b-1)\big)^2 = 2(w-2)^2 + (b-1)^2

由于输入之和为零,交叉项消失。这解释了为什么改变 w 只会使 b 的曲线上下平移,而不会改变其最小值的位置,反之亦然。导数可简化为:

∂L∂w=4(w−2),∂L∂b=2(b−1)\frac{\partial L}{\partial w} = 4(w-2), \qquad \frac{\partial L}{\partial b} = 2(b-1)

执行一次梯度下降更新后,与最优值的距离变为:

wnew−2=(1−4lr)(w−2),bnew−1=(1−2lr)(b−1)w_{\text{new}}-2 = (1-4lr)(w-2), \qquad b_{\text{new}}-1 = (1-2lr)(b-1)

当 0<lr<0.50 < lr < 0.5 时,两个距离都会缩小。lr = 0.5 时,第一个乘数为 -1,所以 w 持续振荡而不再靠近。lr = 1 时,乘数为 -3,距离每一步都会变为原来的三倍。

这种独立性是本例中心化线性数据的特性。在其他数据集和多层网络中,一个参数的梯度通常依赖其他参数。执行梯度下降时,应先在当前参数处计算全部梯度,再应用更新。

从导数到梯度

梯度不过是把所有偏导数捆在一起的向量:[dw, db]。它指向损失上升最陡的方向。所以我们朝相反方向走——这就是更新规则里为什么是减法:w = w - lr * dw。

只有两个参数时,可以把损失画成三维曲面:w 为一轴,b 为另一轴,损失为高度。我们的损失形成一个凸碗,唯一最小值在 w = 2, b = 1。之前的曲线都是这个曲面的切片。从不同起点尝试 Step (both);组件固定使用学习率 0.1,参数会接近同一个最小值。

-3.0
3.0

试着分别点 Step (w) 和 Step (b)——你会看到点每次只沿一个轴移动,在碗里走出阶梯状的路径。 然后试试 Step (both)——这才是真正的梯度下降在做的事,一次更新两个参数。你可以拖动来旋转曲面,从不同角度看它。

黄色箭头表示更新方向:沿参数轴的分量与 [-dw, -db],也就是负梯度,成正比。箭头高度随损失变化。Step (w) 和 Step (b) 沿单个参数轴移动,Step (both) 则合并两项更新。

在起点 w = -3, b = 3,梯度为 dw = -20、db = 4,所以更新沿 w 轴移动得更多。两者的相对大小同时取决于损失曲率和当前参数。例如在 w = 2, b = 3,dw = 0,只有偏置发生变化。

梯度衡量的是局部敏感度,并不是某个参数该为误差承担多少责任。最小值附近可能出现小梯度,但高损失的平坦区域也可能出现小梯度。

走出碗外:深度网络为何非凸

我们的平方误差损失是凸的,因为它是若干关于参数的仿射表达式的平方和。对于这个数据集,L=2(w−2)2+(b−1)2L = 2(w-2)^2 + (b-1)^2 有唯一最小值。一般二次函数不一定凸,例如 −w2-w^2 就是向下弯曲的。

多层网络中,各层参数通过乘积和激活相互作用,因此损失通常是非凸的。即使只有两个标量线性层,也能看到这一点。设它们的权重为 aa 和 cc,输入为 1,目标也为 1:

y^=ca,L(a,c)=(ca−1)2\hat y = ca, \qquad L(a,c) = (ca-1)^2

(a,c)=(1,1)(a,c)=(1,1) 和 (−1,−1)(-1,-1) 的损失都为零,但两者中点 (0,0)(0,0) 的损失为 1。凸函数的中点值不能高于两端函数值的平均,因此这个损失非凸。非线性激活会增加更多结构,但损失关于参数的非凸性并不要求使用非线性激活。

这种曲面可能包含局部极小值、鞍点和平坦区域。梯度为零本身不能证明已经找到最佳解,梯度下降也不保证得到全局最小值。《Deep Learning》的优化章节对此有更详细的介绍。

训练的目标是找到能在新数据上做出有用预测的参数。除了训练损失,还应监控验证表现。早停和正则化可以帮助减少过拟合,但不会把非凸损失变成凸损失。

随机梯度下降

到目前为止,我们一直用全部训练数据一次性计算梯度。每次你在上面的组件里点「Step」,dw = 2 * mean(error * x) 都会为我们那 5 个数据点分别算出 2 * error * x,再把它们平均成一个梯度:

dw = 2 * mean(error * x)
   = 2 * mean([-24.00, -7.00, 0.00, -3.00, -16.00])
   = -20.00

使用全部 5 个点可以得到训练损失的精确梯度,但并不能让更新免受离群值影响:很大的误差仍可能主导 MSE 梯度。在大型数据集上,每次更新前都处理全部样本代价很高。小批量让我们处理一部分数据后就能更新参数。

先打乱样本,再分成小批量。每个批次执行四阶段训练循环:前向传播、损失计算、反向传播和参数更新。每次更新只使用当前批次的样本。

这就是随机梯度下降(SGD)。“随机”在这里指样本的随机打乱。更新规则不变,只是对小批量取平均,而不是对整个数据集取平均:

w=w−lr⋅1∣B∣∑i∈B∂lossi∂ww = w - lr \cdot \frac{1}{|B|} \sum_{i \in B} \frac{\partial \text{loss}_i}{\partial w}

BB 是当前小批量。它的梯度估计当前参数处的全数据集梯度。单次更新可能增大全数据集损失。由于参数在批次之间不断变化,一个 epoch 的小批量更新并不等价于一次全批量更新。

当你把每条样本都过了一遍,那就是一个 epoch。再打乱一次,开始下一个 epoch。 这就是你在训练日志里看到「epoch」的原因——每个 epoch 意味着模型把数据集里每条样本恰好看过一次。

对我们那 5 个数据点、批大小为 2 的情形,长这样:

Epoch打乱后的数据批 1批 2批 3
1[0, 2, -1, -2, 1](0, 2)(-1, -2)(1)
2[2, -2, 1, 0, -1](2, -2)(1, 0)(-1)
3[-1, 1, -2, 2, 0](-1, 1)(-2, 2)(0)

每个批次都执行完整循环(前向传播 → 损失 → 反向传播 → 梯度下降),所以每个 epoch 有 3 次参数更新,而不是 1 次。每个样本恰好使用一次。打乱顺序会改变样本的分组,减小更新对原始排列顺序的依赖。

下面的组件在我们这个 5 点数据集上并排跑两种方法,方便你直接对比。两者从相同的参数(w = -3, b = 3)出发,用相同的学习率。每点一次「Step」,两种方法各做一次参数更新。 蓝线(全批量)每一步都用全部 5 个点。 橙线(小批量)只用 batch_size 个点 ——橙色圆圈标出用的是哪些,epoch 进度条则追踪在数据集里走到了哪。

2
0.10

点击几次 Step,观察右侧损失曲线。在默认学习率下,蓝色的全批量曲线平稳下降,橙色的小批量曲线则呈锯齿状。

默认启用了固定顺序,所以每次运行的批次都一样,这个锯齿图案是可复现的。取消勾选就会在每个 epoch 随机打乱——橙色曲线每次都不同,但整体行为是一样的。

看看最初几步:第 1→2 步损失下降,第 2→3 步却上升,因为更新帮助了当前批次的点,却影响了其他点。第 3→4 步又下降。每个批次只看到部分数据,因此个别更新可能增大全体损失。在默认设置下,整体趋势仍然是损失下降。

默认的全批量训练用更少的更新接近答案,但每次更新要处理更多样本。对这 5 个点,比较 3 次参数更新的成本:

  • 全批量(3 步):每步都用全部 5 个点。那是 3 × 5 = 15 次数据点计算。每个点被处理 3 次。
  • 批大小为 2 的 SGD(3 步 = 1 个 epoch):每步只用 2 个点(最后一批只有 1 个)。那是 2 + 2 + 1 = 5 次数据点计算。每个点被处理一次。

两种方法都更新 3 次,但小批量只处理三分之一的样本。这比较的是计算工作量,而不是达到某个损失的进度:更新后两种方法的损失不一定相同。对大型数据集,更有意义的是比较达到目标验证表现所需的时间,它还取决于硬件和批大小。

你也可以试试不同的批大小,看看行为差异:

  • batch size = 1:每次更新使用一个样本,不同样本给出的更新可能差异很大。5 步为 1 个 epoch。这是最初的随机梯度下降形式。
  • batch size = 2:噪声较小,每个 epoch 需要 3 步(2 + 2 + 剩余的 1 个样本)。
  • batch size = 5:一次使用全部数据,也就是全批量梯度下降。两条线完全重合,1 步为 1 个 epoch。

批大小影响梯度噪声、内存占用和硬件利用率。小批次使每轮数据遍历包含更多更新,大批次则在每次更新中平均更多样本。噪声有时能帮助优化,但不保证逃离局部极小值,也不保证收敛到全局最小值。

跨越多层的链式法则

还记得反向传播如何用链式法则把局部导数相乘吗? 我们的例子是最简单的情形:一个神经元,一个权重 w、一个偏置 b、一个输入:

wbxnŷerrlossinput1 neuronoutput∂loss/∂w: x · 1 · 2·error ∂loss/∂b: 1 · 1 · 2·error

从每个参数到损失的链条有 3 环 (f1′⋅f2′⋅f3′)(f'_1 \cdot f'_2 \cdot f'_3),在 Python 里长这样:

y_pred = w * x + b                  # f1: prediction
error  = y_pred - y                 # f2: how far off
loss   = error ** 2                 # f3: squared error

dw = x * 1 * (2 * error)           # chain rule for w: f'1 · f'2 · f'3
db = 1 * 1 * (2 * error)           # chain rule for b: same chain, different f'1

但真实网络每层有多个神经元,每个都有自己的权重。梯度计算本身没变——我们仍然用链式法则为每个单独的权重算偏导数,和之前一样。区别在于规模。对离损失更远的权重,链条变得更长——要穿过更多层,要相乘的导数更多。而且它还变得更宽——一个神经元的输出可以喂进下一层的许多神经元,所以梯度必须把所有这些路径的贡献加起来。

下面的图有两个隐藏层,每层 3 个神经元,以及一个标量输出。切换按钮,比较第二隐藏层和第一隐藏层中的权重:

w₁v₁x₁x₂h₁h₂h₃g₁g₂g₃ŷlossinputlayer 1layer 2output

两个隐藏层使用 ReLU,输出使用恒等激活。对一个训练样本,前向传播定义为:

z=W1x+b1,h=ReLU⁡(z),q=W2h+b2,g=ReLU⁡(q),y^=u⋅g+c,L=(y^−y)2.\begin{aligned} \mathbf{z} &= W_1\mathbf{x}+\mathbf{b}_1, & \mathbf{h} &= \operatorname{ReLU}(\mathbf{z}),\\ \mathbf{q} &= W_2\mathbf{h}+\mathbf{b}_2, & \mathbf{g} &= \operatorname{ReLU}(\mathbf{q}),\\ \hat y &= \mathbf{u}\cdot\mathbf{g}+c, & L &= (\hat y-y)^2. \end{aligned}

W1W_1 的形状是 3×23\times2,W2W_2 是 3×33\times3,输出权重向量 u\mathbf{u} 有 3 个分量。z\mathbf{z} 和 q\mathbf{q} 保存激活之前的加权和;h\mathbf{h} 和 g\mathbf{g} 保存激活之后的输出。图中的 w1w_1 连接 x1x_1 与 h1h_1,v1v_1 连接 h1h_1 与 g1g_1。把 h1h_1 到 g2g_2、g3g_3 的权重分别记为 v2v_2、v3v_3,这三个权重构成 W2W_2 的第一列。

默认选中 Gradient for v₁ (layer 2)。这个权重通过 q1q_1、g1g_1 和 y^\hat y 影响损失。记 error=y^−yerror=\hat y-y,其导数为:

∂L∂v1=h1⏟∂q1/∂v1⋅ReLU⁡′(q1)⏟∂g1/∂q1⋅u1⏟∂y^/∂g1⋅2 error⏟∂L/∂y^.\frac{\partial L}{\partial v_1} = \underbrace{h_1}_{\partial q_1/\partial v_1} \cdot \underbrace{\operatorname{ReLU}'(q_1)}_{\partial g_1/\partial q_1} \cdot \underbrace{u_1}_{\partial\hat y/\partial g_1} \cdot \underbrace{2\,error}_{\partial L/\partial\hat y}.

g1g_1 的其他输入乘以不同的权重,因此不会出现在 ∂q1/∂v1\partial q_1/\partial v_1 中。但它们仍然影响求导时的 q1q_1 和预测值。注意 ∂L/∂g1=u1 2 error\partial L/\partial g_1 = u_1\,2\,error:即使求的是前面某层参数的导数,输出连接仍会贡献它的权重。

选择 Gradient for w₁ (layer 1)。改变 w1w_1 会改变 h1h_1,而 h1h_1 又输入第二隐藏层的三个神经元。因此必须累加三条路径的贡献:

∂L∂w1=x1 ReLU⁡′(z1)∑j=13vj ReLU⁡′(qj) uj 2 error.\frac{\partial L}{\partial w_1} = x_1\,\operatorname{ReLU}'(z_1) \sum_{j=1}^{3} v_j\,\operatorname{ReLU}'(q_j)\,u_j\,2\,error.

每条路径贡献一个局部导数的乘积。在分支处,反向传播将这些贡献相加。对两个高亮权重,计算如下:

# All values come from the same forward pass; indices start at 0.
# ReLU's derivative is 1 for positive inputs, 0 for negative inputs.
# At exactly zero, we use 0 as the implementation convention.
d_output = 2 * error

# Gradients with respect to the second layer's pre-activations q.
d_q = (q > 0) * u * d_output            # shape: (3,)

# v₁ is W2[0, 0]: h₁ -> g₁.
dv1 = h[0] * d_q[0]

# Sum the three paths from h₁ through the second layer.
d_h1 = np.dot(W2[:, 0], d_q)

# w₁ is W1[0, 0]: x₁ -> h₁.
dw1 = x[0] * (z[0] > 0) * d_h1

用相同运算可以求出所有权重与偏置的梯度。下面的代码完成一个样本的反向传播,再更新全部参数:

# Output layer: y_pred = u @ g + c
du = g * d_output                      # 3 weights
dc = d_output                         # scalar bias

# Second hidden layer: g = ReLU(W2 @ h + b2)
dW2 = np.outer(d_q, h)                 # shape: (3, 3)
db2 = d_q

# First hidden layer: h = ReLU(W1 @ x + b1)
d_z = (z > 0) * (W2.T @ d_q)
dW1 = np.outer(d_z, x)                 # shape: (3, 2)
db1 = d_z

# Apply updates only after every gradient has been computed.
u = u - lr * du
c = c - lr * dc
W2 = W2 - lr * dW2
b2 = b2 - lr * db2
W1 = W1 - lr * dW1
b1 = b1 - lr * db1

对于使用 MSE 的小批量,在更新前先平均各个样本的梯度。反向传播会复用 d_q、d_z 等中间梯度,不必为每个参数分别追踪每条路径。

增加层数会在每条路径上引入更多权重和激活导数因子。反复相乘后,梯度到达较早层时可能已经缩小或放大很多。

梯度消失问题

在标量链中,如果每层贡献的导数都是 0.5,那么通过 100 层就会使传入梯度乘上 0.5100≈7.9×10−310.5^{100} \approx 7.9\times10^{-31}。较早层收到的更新信号会非常弱。这就是梯度消失问题。更宽的网络使用矩阵乘积和路径求和,但反复收缩仍可能产生同样的效果。

反复放大则可能导致梯度爆炸,使更新变得不稳定。

下面的组件让你亲眼看到这一点。把局部导数拖到 1 以下,看着梯度在向后流经各层时衰减为无——然后试试大于 1 的值,看它爆炸:

8
0.50

Sigmoid 的导数最大为 0.25,因此连续相乘的 Sigmoid 导数可能显著缩小梯度。权重矩阵同样影响梯度大小;仅凭激活导数,无法判断完整梯度会消失还是爆炸。

以下设计有助于训练更深的网络:

  • ReLU 激活在正输入处的导数为 1,因此激活分支本身不会缩小梯度。负输入处的导数为 0,会阻断经过该神经元的梯度。权重矩阵仍可能放大或缩小信号。
  • 残差连接在一组层之外增加恒等路径。对于 h+F(h)h + F(h),导数包含恒等项和 FF 的导数,为梯度提供额外路径。参见 ResNet 论文。
  • 批归一化用小批量统计量归一化中间值,并学习缩放与平移。它可以使优化更容易,但不强制导数等于 1,也不保证梯度稳定。参见批归一化论文。

初始化、架构和优化器设置都会影响梯度传播。这些技术分别处理问题的不同部分,没有一种能保证每个梯度始终保持有效的尺度。

准备好把这一切付诸实践了吗?在下一篇文章里,我们会在 MNIST 上构建并训练一个真正的神经网络——用 NumPy 从零写出前向传播、反向传播和梯度下降,然后与 Keras 实现作对比。