神经网络如何学习:反向传播与梯度下降
神经网络使用参数(包括权重和偏置)将输入转换为预测。架构规定要执行哪些计算,参数值则决定这些计算如何响应输入。训练根据样本调整参数,让网络学会识别手写数字、翻译文本等任务。
训练就是寻找有用参数值的过程。网络做出预测,用损失函数衡量误差,再计算损失随各个参数如何变化。反向传播负责计算这些导数,也称梯度。梯度下降等优化器利用梯度更新参数。
在最简单不过的例子上,它长这样——一个模型学着拟合一条直线。点几下 Step 看看:
每次点击执行一次训练迭代:计算预测、衡量误差(红色虚线)、计算梯度并更新参数。在这里使用的学习率下,直线每一步都会更接近数据。
我们会从一个神经元开始,逐步把这些计算扩展到多层网络。交互组件展示数值如何变化,Python 代码则把这些变化与实现联系起来。
先看神经元和损失的例子,再学习导数与链式法则。如果你已经熟悉微积分,可以直接阅读跨越多层的链式法则。
神经网络就是一堆参数
我们使用全连接前馈网络:神经元按层排列,每个神经元接收前一层的所有输出,并把这些输入组合成一个输出。要理解这种网络,我们先从单个神经元开始。
下面的组件展示一个有 3 个输入的神经元。左侧设置输入值,右侧设置权重、偏置和激活函数。权重与偏置是在训练中学习的参数;激活函数则是设计网络时做出的选择。
在这个组件上你能看到,神经元把每个输入乘以对应的权重,连同偏置一起加起来, 再把结果送过一个激活函数。
写成公式就是:
其中 是输入, 是对应的权重, 是偏置, 是激活函数。
试几种配置来建立直觉:
- 权重控制输入的影响。 设置 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 元素的向量。权重决定每个输入有多重要,偏置把结果整体上移或下移。这些参数合起来定义了神经元对什么有反应。换一组权重和偏置,同一个神经元就会在输入中检测出完全不同的模式。
由于「相乘再求和」就是点积,这通常写成向量形式:
这里 是所有输入组成的向量(列表)——例如 —— 而 是所有权重组成的向量——例如 。 点积 把每一对相乘再把结果加起来:。
在 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 个输出。之所以叫「隐藏」,是因为你只看得到进去的输入和出来的输出——中间那些层是网络内部的,从外面看不见:
每个神经元为每个输入存储一个权重,再加上偏置并应用激活函数:
一层通常把它们统一存在一个权重矩阵()里——每个神经元占一行权重。
对上图那个网络,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₂对单个神经元,我们有一个权重向量与输入的点积:。对一整层,我们把所有权重向量堆成矩阵 ,把所有偏置堆成向量 ,于是同一个运算一次就作用到所有神经元上:
计算 时, 的每一行都与输入做点积——那就是一个神经元的加权和。矩阵乘法一次运算就把它们全做完了。
想从几何角度理解这项计算,可以阅读权重矩阵究竟做了什么。文章跟踪四个输入点通过隐藏层的过程,展示权重矩阵、偏置和非线性激活如何改变它们的表示,让类别变得可分。
在 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)注意其中的对称:前向传播时,每一层从前一层收到激活值,并把自己的输出往前传。反向传播时,每一层从后一层收到梯度信号,并把它往后传。两个方向上,每一层都需要邻居给的输入才能干活。
一个简单例子:拟合一条直线
我们先考虑一个神经元、一个输入、一个权重和一个偏置。这样只需研究两个参数对应的损失,再把梯度计算扩展到多层网络。
对于单个输入和恒等激活(将输入原样返回),神经元计算 。这里 和 都是标量。用这个神经元拟合直线,可以先研究学习算法,而不必处理激活函数的导数。
假设有人给你五个点,说它们来自某个线性函数 ,并让你找出 w 和 b:
| x(输入) | -2 | -1 | 0 | 1 | 2 |
| y(输出) | -3 | -1 | 1 | 3 | 5 |
数据给出了输入和输出。我们要找的是连接两者的参数:直线斜率 ,以及与 y 轴相交处的截距 。
学到函数后,就能对训练数据中没有出现过的输入做预测。如果关系是 ,即使表里没有 1.5,也能预测 。在新输入上做出准确预测称为泛化。本例的数据严格服从一条直线;对真实数据而言,仅仅拟合好训练集,并不能证明模型能够泛化。
那我们把这些点画到图上(绿色圆点),试着靠拖动滑块手动找出 w 和 b。
用损失值来指引你——拖一拖,看看能不能把损失降到零。你会发现 、 能让损失归零——那正是生成这些数据的参数,也就是 :
滑块旁显示的损失,是概括整个数据集预测误差的一个数。
拖动滑块时,注意那些红色虚线——它们是每个数据点上各自的误差,显示预测与真实值差了多少。我们把每个点的误差算作 。
不同损失函数用不同方式汇总误差:
- 均方误差(MSE)——用于回归(预测数值)。把每个误差平方后求平均。
- 交叉熵——用于分类(预测类别)。衡量预测的概率离真实标签有多远。
- 平均绝对误差(MAE)——像 MSE,但用绝对值而非平方,对离群值不那么敏感。
拟合直线属于回归,即预测连续数值,因此我们使用均方误差(MSE):把每个误差平方后取平均。平方结果非负,正负误差不会相互抵消,较大的误差也会获得更大的权重:
把这个公式用到我们的数据上。假设你当前的猜测是 、,也就是 。对我们那 5 个数据点,我们逐一算出预测、误差(差多远)和误差的平方:
| 误差 | 误差² | |||
|---|---|---|---|---|
| -2 | -3 | -5 | -2 | 4 |
| -1 | -1 | -2 | -1 | 1 |
| 0 | 1 | 1 | 0 | 0 |
| 1 | 3 | 4 | 1 | 1 |
| 2 | 5 | 7 | 2 | 4 |
| 平均 → | 损失 = 2.0 |
MSE 为 2.0。对它开平方,得到均方根误差(RMSE) ,单位与预测值相同。它不同于平均绝对误差,后者在这里是 。当 、 时,所有误差和这两种指标都为零。
两个参数可以手动调整,但面对数百万参数时,这样做就不现实了。后面我们会将这些计算用于识别手写数字,网络包含超过 100,000 个参数。我们需要系统的方法:反向传播计算梯度,梯度下降据此小幅更新参数,反复执行以降低损失。
线性最小二乘问题也可以通过正规方程或矩阵分解直接求解。得到的是平方误差目标的最小值,并不保证能完美拟合任意数据。一般神经网络没有类似的直接解法,因此使用梯度下降等迭代优化器。
一步步走完训练循环
先来看看这个自动化的算法是怎么工作的。 下面的组件让你为我们的任务一步步跑反向传播和梯度下降,把一切看在眼里:
- 左图:绿色数据点,以及当前 、 对应的蓝色预测直线。红线表示误差,误差平方后取平均便得到 MSE。
- 右图:每次更新后的损失。在默认学习率下,损失逐步降到零。曲线变平可能表示收敛;损失上升则可能说明学习率过大。
点 Step 执行一次梯度下降更新,或者点 Step x10 一口气跑十次。
Backpropagation
Gradient Descent
▶ 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.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,看着损失怎么变——白点沿曲线移动,精确显示你在损失地形上的位置:
拖动 w:点沿左侧曲线移动,右侧曲线则上下平移。改变 b 时情况相反。对于这个中心化的数据集,沿 w 轴的最小值始终在 2,沿 b 轴的最小值始终在 1。这里的两个参数可以独立优化,我们将在后面推导原因。
梯度公式来自对这些曲线求导数——衡量当你把每个参数推动一丁点时,损失变化多少。
所以 2 * mean(error * x) 不过就是损失函数对 w 的导数。
要理解我们如何从损失函数走到 2 * mean(error * x),需要三个层层递进的概念:
- 导数——衡量一个函数如何变化意味着什么
- 链式法则——当函数被串起来时,导数怎么算
- 偏导数与梯度——如何同时处理多个参数
到最后,我们会精确追溯这个公式里每一部分的来历。先从导数究竟是什么讲起。
我们刚看到的公式——
2 * mean(error * x)——是针对线性模型配 MSE 损失的特例。不同的损失函数和架构会给出不同的梯度公式——但底层的数学原理始终相同。对我们这个简单模型,公式可以手推;对有几百万参数的深度网络,PyTorch、TensorFlow 这类框架会用 autograd(自动微分)自动求导。
导数:某一点上的斜率
导数回答一个问题:如果我把这个输入推动一丁点,输出会变多少? 把它想成曲线在某一点的斜率。如果你站在山坡上,导数告诉你脚下的地有多陡——以及往哪边是下坡。
拿一个简单函数比如 f(x) = x²。沿曲线拖动点 x,看看斜率和导数怎么变:
▼ Computation
在组件里把 x 设为 2、dx 设为 0.5。黄线()是给输入的一个推动,绿线()是输出随之变化了多少。图下方的 Computation 部分展示了它们怎样组合成导数。
首先,我们在这一点上求值:。然后把输入推动 dx 再求一次:。差值告诉我们输出变化了多少:。除以推动量得到变化率:。
这个比值(4.5)近似等于 x = 2 处的导数——它给出的是速率:在这一点上,输出变化的速度大约是输入的 4 倍。 它不正好是 4,因为 dx = 0.5 仍是个不小的推动。现在把 dx 调小——试着拖到 0.1:
- ——更接近
4了
导数就是当输入变化趋近于零时,这个比值的极限:
是输出的变化()。除以 得到比值。 这一部分只是说「让 收缩趋近于零」——正是你用滑块做的事,看着比值收敛到精确值。
对 ,我们可以推一推:
当 时,就只剩 。所以 。
得到导数公式后,就能直接计算 ,而不必通过越来越小的输入变化来估计斜率。
另一份直观入门材料是 3Blue1Brown 的 The Essence of Calculus。
链式法则:求复合函数的导数
我们知道怎么求 这类简单函数的导数。但当一个函数的输出喂进另一个函数时会怎样? 那叫函数复合——而我们的计算正是这样:
y_pred = w * x + b # prediction
error = y_pred - y # how far off
loss = np.mean(error ** 2) # squared error, averaged为推导梯度,先考虑一个数据点。它的平方误差通过三项运算依赖 w:计算预测、减去目标值、平方。先对这条链求导,再对整个数据集取平均。
所以可以看到,从 w 算到损失并不是一个函数,而是三个函数组成的链条,每一个都把自己的输出送进下一个:
写清楚就是:
- ——模型的预测
- ——我们差了多远
- ——误差的平方(我们要最小化的东西)
损失就是 ——三个函数一层套一层。
每个单独函数的导数我们都会求,但怎么把它们合起来得到整条链的导数? 答案是链式法则:把各段的局部导数相乘。
回忆一下导数公式:
。
有限变化的比值在极限下趋近导数。导数记为 ,输出量写在上面,输入量写在下面。对链中的三个函数而言:
- :输出是 ,输入是 →
- :输出是 ,输入是 →
- :输出是 ,输入是 →
用这套记法,链式法则展开成:
为什么是相乘?因为一个函数的输出成为下一个函数的输入。w 的微小变化会使 y_pred 改变该变化的 x 倍,随后 error 改变同样的量。当变化足够小时,error² 的变化近似为 error 变化量的 2·error 倍。每个环节都缩放变化量,这些缩放系数相乘。
组合函数的三种方式
把两个函数 和 组合起来有三种基本方式,每一种都有自己那套导数如何组合的规则:
-
相加:——导数相加。如果 变化 3、 变化 5,那么和变化 8。这就是和法则:。
-
相乘:——这更复杂些,因为两个因子都会变。这就是乘积法则:。你得分别考虑每个函数在另一个保持不变时的变化。
-
复合(嵌套):—— 的输出喂进 。导数相乘。这就是链式法则:。给 的一个推动先被 缩放,缩放后的变化再被 缩放一次。
我们的损失计算是一个复合————所以我们把导数相乘。要是这些函数是相加或相乘组合的,我们就得用相应的规则。实践中,神经网络三种都用:相加(偏置项)、相乘(权重乘输入)和复合(层与层相互喂入)。反向传播会为每个运算套用对应的那条规则。
好,那我们来算算这条损失链的整体导数。我们展示过怎么求 的导数,得到了 。 同样的办法对更简单的函数也适用: 的导数就是 (一个常数倍), 的导数是 (减去一个常数不改变变化率)。这让每个单独导数的计算都很直白:
- 要算 ,我们利用「 的导数是 」。因为 ,导数就是
x。 - 要算 ,我们利用「 的导数是 」。因为 ,导数就是
1。 - 要算 ,我们利用「 的导数是 」。因为函数是 ,导数就是
2 · error。
于是我们得到:
对单个数据点来说,这就是 。
用数字算一遍。设 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链式法则给出 。对于微小变化 ,平方误差的变化近似为 。这是局部近似:将 w 从 3 增至 4,平方误差会从 4 变成 16,增加的是 12,而不是 8。
但我们有 5 个数据点,不是一个。既然 MSE 把所有点的平方误差取平均,那导数也要取平均。对每个点我们算 :
| -2 | -3 | -5 | -2 | |
| -1 | -1 | -2 | -1 | |
| 0 | 1 | 1 | 0 | |
| 1 | 3 | 4 | 1 | |
| 2 | 5 | 7 | 2 |
求平均:。所以 dw = 4——梯度告诉我们增大 w 会让损失上升,因此我们应当减小它。(确实如此,真值是 w = 2,比我们猜的 3 要小。)
用数学记法就是:
在 Python 里:
dw = 2 * np.mean(error * x)下面的组件让你为每个数据点追踪这条链。点击不同的 x= 按钮,看看局部导数怎么变——注意链式法则对每个点给出的值都不同,因为 x 和 error 不一样:
对 db 来说链条是一样的,只是 不同:
因为 ,对 b 的导数就是 1(而不是 x)。所以:
在我们的 Python 代码里长这样:
db = 2 * np.mean(error)这就是链式法则为什么重要:只要损失是通过一连串运算算出来的(而它总是如此),你就需要它来回溯每个参数是怎么影响最终结果的。对我们这个双参数模型,链条有 3 步。一个深度神经网络可能有几百步——每层一步——但原理完全一致:每一层不过是复合里多出的一个函数、多出的一个要相乘的局部导数。
把导数用到我们的损失函数上
既然知道了怎么求单个导数、怎么用链式法则把它们组合起来,就把这套知识用到我们的问题上。 我们想最小化的那条「曲线」就是我们的损失函数——我们选来衡量误差的那个公式。 我们对这个函数了如指掌:
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 单独的函数:
横轴是 w,纵轴是损失。最小值位于 w = 2,但此时损失为 4,因为 b 仍固定为 3。每个预测都比目标高 2。拖动滑块,查看每个曲线点对应的预测、误差和平方误差。
对 b 我们可以做同样的事——这次固定 w = 2,让 b 从 -5 变到 5:
同样的抛物线形状,只是现在 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)。在每张图上,白点是你当前所在的位置,蓝色虚线是切线(它的斜率就是偏导数),绿色箭头指出往哪边走能减小损失。
拖动滑块,看看会怎样:
- 远离最小值时——曲线很陡,切线倾斜得厉害,导数是个大数。梯度下降在这里迈大步。
- 靠近最小值时——曲线变平,切线几乎水平,导数接近零。步子变得极小——模型在精修。
- 在最小值处——切线完全水平。导数为零。无处可去——你到了。
我们的五个数据点满足 , 的平均值为零, 的平均值为 2。展开损失得到:
由于输入之和为零,交叉项消失。这解释了为什么改变 w 只会使 b 的曲线上下平移,而不会改变其最小值的位置,反之亦然。导数可简化为:
执行一次梯度下降更新后,与最优值的距离变为:
当 时,两个距离都会缩小。lr = 0.5 时,第一个乘数为 -1,所以 w 持续振荡而不再靠近。lr = 1 时,乘数为 -3,距离每一步都会变为原来的三倍。
这种独立性是本例中心化线性数据的特性。在其他数据集和多层网络中,一个参数的梯度通常依赖其他参数。执行梯度下降时,应先在当前参数处计算全部梯度,再应用更新。
从导数到梯度
梯度不过是把所有偏导数捆在一起的向量:[dw, db]。它指向损失上升最陡的方向。所以我们朝相反方向走——这就是更新规则里为什么是减法:w = w - lr * dw。
只有两个参数时,可以把损失画成三维曲面:w 为一轴,b 为另一轴,损失为高度。我们的损失形成一个凸碗,唯一最小值在 w = 2, b = 1。之前的曲线都是这个曲面的切片。从不同起点尝试 Step (both);组件固定使用学习率 0.1,参数会接近同一个最小值。
试着分别点 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,只有偏置发生变化。
梯度衡量的是局部敏感度,并不是某个参数该为误差承担多少责任。最小值附近可能出现小梯度,但高损失的平坦区域也可能出现小梯度。
走出碗外:深度网络为何非凸
我们的平方误差损失是凸的,因为它是若干关于参数的仿射表达式的平方和。对于这个数据集, 有唯一最小值。一般二次函数不一定凸,例如 就是向下弯曲的。
多层网络中,各层参数通过乘积和激活相互作用,因此损失通常是非凸的。即使只有两个标量线性层,也能看到这一点。设它们的权重为 和 ,输入为 1,目标也为 1:
和 的损失都为零,但两者中点 的损失为 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)。“随机”在这里指样本的随机打乱。更新规则不变,只是对小批量取平均,而不是对整个数据集取平均:
是当前小批量。它的梯度估计当前参数处的全数据集梯度。单次更新可能增大全数据集损失。由于参数在批次之间不断变化,一个 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 进度条则追踪在数据集里走到了哪。
点击几次 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、一个输入:
从每个参数到损失的链条有 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 个神经元,以及一个标量输出。切换按钮,比较第二隐藏层和第一隐藏层中的权重:
两个隐藏层使用 ReLU,输出使用恒等激活。对一个训练样本,前向传播定义为:
的形状是 , 是 ,输出权重向量 有 3 个分量。 和 保存激活之前的加权和; 和 保存激活之后的输出。图中的 连接 与 , 连接 与 。把 到 、 的权重分别记为 、,这三个权重构成 的第一列。
默认选中 Gradient for v₁ (layer 2)。这个权重通过 、 和 影响损失。记 ,其导数为:
的其他输入乘以不同的权重,因此不会出现在 中。但它们仍然影响求导时的 和预测值。注意 :即使求的是前面某层参数的导数,输出连接仍会贡献它的权重。
选择 Gradient for w₁ (layer 1)。改变 会改变 ,而 又输入第二隐藏层的三个神经元。因此必须累加三条路径的贡献:
每条路径贡献一个局部导数的乘积。在分支处,反向传播将这些贡献相加。对两个高亮权重,计算如下:
# 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 层就会使传入梯度乘上 。较早层收到的更新信号会非常弱。这就是梯度消失问题。更宽的网络使用矩阵乘积和路径求和,但反复收缩仍可能产生同样的效果。
反复放大则可能导致梯度爆炸,使更新变得不稳定。
下面的组件让你亲眼看到这一点。把局部导数拖到 1 以下,看着梯度在向后流经各层时衰减为无——然后试试大于 1 的值,看它爆炸:
Sigmoid 的导数最大为 0.25,因此连续相乘的 Sigmoid 导数可能显著缩小梯度。权重矩阵同样影响梯度大小;仅凭激活导数,无法判断完整梯度会消失还是爆炸。
以下设计有助于训练更深的网络:
- ReLU 激活在正输入处的导数为 1,因此激活分支本身不会缩小梯度。负输入处的导数为 0,会阻断经过该神经元的梯度。权重矩阵仍可能放大或缩小信号。
- 残差连接在一组层之外增加恒等路径。对于 ,导数包含恒等项和 的导数,为梯度提供额外路径。参见 ResNet 论文。
- 批归一化用小批量统计量归一化中间值,并学习缩放与平移。它可以使优化更容易,但不强制导数等于 1,也不保证梯度稳定。参见批归一化论文。
初始化、架构和优化器设置都会影响梯度传播。这些技术分别处理问题的不同部分,没有一种能保证每个梯度始终保持有效的尺度。
准备好把这一切付诸实践了吗?在下一篇文章里,我们会在 MNIST 上构建并训练一个真正的神经网络——用 NumPy 从零写出前向传播、反向传播和梯度下降,然后与 Keras 实现作对比。