Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

导数、梯度与优化

在导读里,我们让程序试遍 451 组参数,再挑出预测误差最小的一组。两个参数时还好办;假如每个参数有 100 种选择,十个参数就有 10010100^{10} 种组合。再勤快的循环,也很难逐一试完。

能不能不用每条路都走一遍,而是先看看:往哪边挪一点,损失会变小?

导数和梯度,正是用来回答这个问题的。它们不神秘:先观察一个量变动时,另一个量怎样跟着变;再把这种关系用到参数更新中。

1. 从“平均每页多几秒”到变化率

先看熟悉的函数 f(x)=3x+20f(x)=3x+20。输入从 10 增加到 12,输出从 50 增加到 56。输出的变化除以输入的变化:

f(12)−f(10)12−10=62=3 \frac{f(12)-f(10)}{12-10}=\frac{6}{2}=3

这个比值叫这段区间上的平均变化率。在打印模型里,单位是秒/页;在图上,它是连接两个点的直线斜率。

一次函数的斜率处处相同。曲线就未必了。为了容易手算,换成 f(x)=x2f(x)=x^2,看看 x=2x=2 附近:

输入增加多少 hh平均变化率
15
0.14.1
0.014.01

这里算的都是:

f(2+h)−f(2)h \frac{f(2+h)-f(2)}{h}

这叫差商。把平方展开,会发现:

(2+h)2−22h=4h+h2h=4+h \frac{(2+h)^2-2^2}{h} =\frac{4h+h^2}{h} =4+h

只要 h≠0h\ne0,这个等式就成立。hh 越接近 0,结果就越接近 4。我们把这个极限叫作函数在 2 处的导数,写成 f′(2)=4f'(2)=4。

一般地:

f′(x)=lim⁡h→0f(x+h)−f(x)h f'(x)=\lim_{h\to0}\frac{f(x+h)-f(x)}{h}

lim⁡\lim 表示“观察它趋近的值”。这里不是把 h=0h=0 塞进分母,而是看 hh 从两侧越来越接近 0 时,差商是否趋向同一个有限值。

图 1:两个点之间的割线描述平均变化;导数则描述一个点附近的变化趋势。

如果函数在某点可导,附近一小步可以这样估计:

f(x+h)≈f(x)+f′(x)h f(x+h)\approx f(x)+f'(x)h

例如,2.0122.01^2 可以估成 4+4×0.01=4.044+4\times0.01=4.04;精确值是 4.0401。步子小的时候,这个局部近似很有用,但走远了就不一定准。

导数也不是处处存在。例如 ∣x∣|x| 在 0 处有个尖角:从左看斜率是 −1-1,从右看是 1,没有唯一的切线斜率。后面遇到这样的函数时,需要相应的处理方法。

2. 换一个横轴:现在调整的是参数

刚才讨论输入 xx 怎样改变输出。训练时,我们关心的通常是:参数怎样改变损失。

先拿一个容易看清的教学损失函数练手:

L(w)=(w−3)2 L(w)=(w-3)^2

横轴是参数 ww,纵轴是损失 LL。最低点显然在 w=3w=3。我们已经知道答案,但先让程序假装不知道,看看能否顺着局部变化找到它。

用刚才展开平方的办法,可以得到:

L′(w)=2(w−3) L'(w)=2(w-3)

当 w=0w=0 时,导数为 −6-6:向右挪一小步,损失会下降。当 w=5w=5 时,导数为 4:向右会升高,应该向左走。

也就是说,我们可以朝导数的反方向移动:

wnew=w−ηL′(w) w_{\text{new}}=w-\eta L'(w)

希腊字母 η\eta 读作 eta,这里表示正的学习率,用来决定步子大小。导数告诉我们方向和局部变化快慢,学习率控制这次实际走多远。

例如,从 w=0w=0 出发,取 η=0.1\eta=0.1:

wnew=0−0.1×(−6)=0.6 w_{\text{new}}=0-0.1\times(-6)=0.6

损失从 9 降到 (0.6−3)2=5.76(0.6-3)^2=5.76。再计算新位置的导数,继续走。这种反复更新的过程,就是梯度下降在单参数情形下的样子。

注意,这里的 ww 已经是参数,不是某份文档的页数。区分“在改变输入”还是“在调整参数”,比记住字母叫什么更重要。

3. 步子大一点,会不会更快

有时会,有时却会冲过头。

对这个特定的二次函数,更新式还能整理成:

wnew−3=(1−2η)(w−3) w_{\text{new}}-3=(1-2\eta)(w-3)

左边是更新后离最低点的差,右边是原来的差乘上一个系数。于是可以直接看出:

学习率每一步会发生什么
0<η<0.50<\eta<0.5从同一侧逐渐靠近
η=0.5\eta=0.5一步到达 3
0.5<η<10.5<\eta<1来回跨过最低点,但幅度缩小
η=1\eta=1除非已在最低点,否则在两侧往返
η>1\eta>1除非已在最低点,否则越走越远

图 2:四次实验都从 0 出发。能否收敛,取决于步长与当前损失曲面的形状是否匹配。

这里 η=0.5\eta=0.5 的表现特别好,只是因为我们挑了这个简单函数。换成 100(w−3)2100(w-3)^2,曲线陡了一百倍,同一个学习率就可能太大。输入和损失的尺度,也会影响训练是否稳定。

因此,不存在一个对所有模型都合适的学习率。实际训练常会观察损失曲线,比较几种候选步长,必要时逐渐减小它。后面做完整模型时,我们还会用验证集判断方案效果,而不是只看训练损失下降得快不快。

4. 两个参数时,分别问两次

回到直线模型,取一条教学样本:x=2x=2,实际答案 y=7y=7。预测为 y^=wx+b\hat y=wx+b,平方损失为:

L(w,b)=(wx+b−y)2 L(w,b)=(wx+b-y)^2

现在可以调整 ww,也可以调整 bb。如果只想知道 ww 的影响,就先把 bb 固定;只想知道 bb 的影响,就把 ww 固定。这种“其他参数暂时不动”的导数叫偏导数。

符号 ∂L/∂w\partial L/\partial w 表示对 ww 的偏导。我们先不急着背结果,而是把计算拆成两步:

e=wx+b−y,L=e2 e=wx+b-y,\qquad L=e^2

ee 是预测误差。参数先改变误差,误差再改变损失。这是上一章函数复合的具体例子。

误差增加一点,损失的变化率是 2e2e;ww 增加一点,误差的变化率是 xx。把这两段关系乘起来:

∂L∂w=dLde∂e∂w=2ex \frac{\partial L}{\partial w} =\frac{dL}{de}\frac{\partial e}{\partial w} =2ex

同理,bb 增加 1,误差也增加 1,所以:

∂L∂b=2e \frac{\partial L}{\partial b}=2e

这就是链式法则:沿着“谁影响谁”的计算顺序,把相邻两步的变化率接起来。对于一般的复合函数 g(f(x))g(f(x)),对应的是 g′(f(x))f′(x)g'(f(x))f'(x),外层的导数要在内层输出处计算。

取 w=2,b=0w=2,b=0,预测为 4,误差 e=−3e=-3,因此:

∂L∂w=−12,∂L∂b=−6 \frac{\partial L}{\partial w}=-12,\qquad \frac{\partial L}{\partial b}=-6

把所有偏导按参数顺序排成向量,就是梯度:

∇L=[∂L/∂w∂L/∂b] \nabla L= \begin{bmatrix} \partial L/\partial w\\ \partial L/\partial b \end{bmatrix}

∇\nabla 读作 nabla。这个新符号里装的,仍然是两个已经算出来的数。

取学习率 0.05,同时更新两个参数:

wnew=2−0.05×(−12)=2.6bnew=0−0.05×(−6)=0.3 \begin{aligned} w_{\text{new}}&=2-0.05\times(-12)=2.6\\ b_{\text{new}}&=0-0.05\times(-6)=0.3 \end{aligned}

新预测为 5.5,损失从 9 降到 2.25。两个偏导都要在同一组旧参数处计算,再一起更新。 如果改完 ww 才重新计算 bb 的偏导,那就是另一种更新过程了。

5. 从一条样本走向整个训练集

设有 nn 条样本,第 ii 条为 (xi,yi)(x_i,y_i)。把每条平方误差平均,得到导读中用过的 MSE:

L(w,b)=1n∑i=1n(wxi+b−yi)2 L(w,b)=\frac1n\sum_{i=1}^{n}(wx_i+b-y_i)^2

每条样本都会贡献一份梯度,再取平均:

∂L∂w=2n∑i=1n(wxi+b−yi)xi∂L∂b=2n∑i=1n(wxi+b−yi) \begin{aligned} \frac{\partial L}{\partial w} &=\frac2n\sum_{i=1}^{n}(wx_i+b-y_i)x_i\\ \frac{\partial L}{\partial b} &=\frac2n\sum_{i=1}^{n}(wx_i+b-y_i) \end{aligned}

从代码角度看,先循环计算预测误差,再分别累加“误差乘输入”和“误差”,最后乘 2/n2/n 就行了。第六章会据此从零训练一条直线。

参数有更多个时,梯度也只是多几个分量。在常用的欧几里得距离下,负梯度给出固定小步长时局部下降最快的方向。理由可以用上一章的点积理解:参数改变量记作 Δθ\Delta\boldsymbol\theta,损失的一阶近似为:

L(θ+Δθ)≈L(θ)+∇L⋅Δθ L(\boldsymbol\theta+\Delta\boldsymbol\theta) \approx L(\boldsymbol\theta)+ \nabla L\cdot\Delta\boldsymbol\theta

令 Δθ=−η∇L\Delta\boldsymbol\theta=-\eta\nabla L,近似变化量就是 −η∥∇L∥2-\eta\|\nabla L\|^2,不会是正数。不过这是局部近似,实际步子太大时,损失仍然可能上升。

如果看这段向量式还有点吃力,先抓住前面两个参数的更新就足够继续阅读。

6. 能不能检查自己有没有求错导

可以把参数往两侧各挪一小步,计算中央差分:

f′(w)≈f(w+h)−f(w−h)2h f'(w)\approx\frac{f(w+h)-f(w-h)}{2h}

它只需要调用原函数,不需要我们手写导数。对于两个参数,就每次只改其中一个,分别检查。

这很适合调试:如果解析梯度与数值梯度差得很远,先检查公式和代码。但 hh 也不能无限缩小,浮点数会有舍入误差;太大的 hh 又会让局部近似变差。下面用 10−510^{-5} 演示。

数值差分通常要为每个参数额外计算损失,参数很多时成本很高。后面的自动微分会用链式法则更高效地求梯度;它与“把参数挪一下再算”不是同一种方法。

7. 让程序走几步

脚本只用 Python 标准库,Python 3.9+ 即可运行。可以在仓库根目录执行:

python3 src/examples/gradients.py

也可以下载脚本,在保存目录运行 python3 gradients.py。

"""第 2 章:差商、链式法则与梯度下降,只使用标准库。"""


def loss(w):
    return (w - 3) ** 2


def gradient(w):
    return 2 * (w - 3)


def central_difference(function, x, h=1e-5):
    return (function(x + h) - function(x - h)) / (2 * h)


def descend(rate, steps=10, start=0.0):
    w = start
    history = [(w, loss(w))]
    for _ in range(steps):
        w = w - rate * gradient(w)
        history.append((w, loss(w)))
    return history


def sample_loss(w, b, x=2, y=7):
    return (w * x + b - y) ** 2


def sample_gradient(w, b, x=2, y=7):
    error = w * x + b - y
    return 2 * error * x, 2 * error


def main():
    for h in [1.0, 0.1, 0.01]:
        quotient = ((2 + h) ** 2 - 2 ** 2) / h
        print(f"h={h:g},平方函数在 2 附近的差商:{quotient:.2f}")
    print(f"L 在 w=0 的数值导数:{central_difference(loss, 0):.4f}")
    for rate in [0.1, 0.5, 0.9, 1.1]:
        w, value = descend(rate)[-1]
        print(f"学习率 {rate:.1f},10 步后 w={w:.4f},损失={value:.4f}")

    w, b = 2.0, 0.0
    dw, db = sample_gradient(w, b)
    numeric_dw = central_difference(lambda value: sample_loss(value, b), w)
    numeric_db = central_difference(lambda value: sample_loss(w, value), b)
    print(f"解析梯度:({dw:.4f}, {db:.4f})")
    print(f"数值梯度:({numeric_dw:.4f}, {numeric_db:.4f})")
    # 两个梯度都在旧参数处计算,再同时更新。
    w, b = w - 0.05 * dw, b - 0.05 * db
    print(f"更新后 w={w:.2f},b={b:.2f},损失={sample_loss(w, b):.2f}")


if __name__ == "__main__":
    main()

输出为:

h=1,平方函数在 2 附近的差商:5.00
h=0.1,平方函数在 2 附近的差商:4.10
h=0.01,平方函数在 2 附近的差商:4.01
L 在 w=0 的数值导数:-6.0000
学习率 0.1,10 步后 w=2.6779,损失=0.1038
学习率 0.5,10 步后 w=3.0000,损失=0.0000
学习率 0.9,10 步后 w=2.6779,损失=0.1038
学习率 1.1,10 步后 w=-15.5752,损失=345.0384
解析梯度:(-12.0000, -6.0000)
数值梯度:(-12.0000, -6.0000)
更新后 w=2.60,b=0.30,损失=2.25

学习率 0.1 和 0.9 在第 10 步的结果相同,过程却不同。把 descend 返回的每一步都打印出来,会看到前者从左边靠近,后者在两边来回跨越。只看最后一个数字,有时会错过训练过程的重要信息。

我们的例子只有一个低谷。更复杂的损失可能有多个局部最低点,也可能有梯度为零但不是最低点的地方,例如 w3w^3 在 0 处。梯度很小,只能说明当前位置附近的一阶变化很小,不自动等于“训练已经找到全局最好答案”。

此外,训练损失下降,也不能单独证明新数据上的预测更好。优化解决的是“怎样降低给定目标”,泛化还要靠独立数据来检查。

8. 自测与小实验

  1. 对 f(x)=x2f(x)=x^2,在 x=3x=3 处的导数是多少?用 h=0.01h=0.01 的前向差商核对。
  2. 对 L(w)=(w−3)2L(w)=(w-3)^2,从 w=5w=5 出发,学习率为 0.1,下一步到哪里?损失是多少?
  3. 从 0 出发,把学习率改成 1,前两次更新分别到哪里?
  4. 在 x=2,y=7,w=2,b=0x=2,y=7,w=2,b=0 处,为什么对 ww 的偏导是对 bb 偏导的两倍?
  5. 把单样本更新学习率从 0.05 改为 0.2,计算更新后的损失。步子大了是否一定更好?
  6. 梯度为零,能否保证已经到达全局最低点?训练损失下降,能否保证测试表现改善?
展开参考答案
  1. 导数为 6,差商为 6+h=6.016+h=6.01。
  2. 导数为 4,因此到达 5−0.1×4=4.65-0.1\times4=4.6;损失为 1.62=2.561.6^2=2.56。
  3. 先到 6,再回到 0,随后继续往返。
  4. ww 先乘输入 x=2x=2 才影响误差,而 bb 直接进入误差;链式法则带来这个因子。
  5. 新参数为 w=4.4,b=1.2w=4.4,b=1.2,预测为 10,误差为 3,损失仍为 9。这一步没有改善;再增大一些还可能更差。
  6. 都不能。驻点可能是局部极值或其他平坦位置;新数据上的表现需要独立评估。

进阶选做:给 sample_loss 增加第二条样本,按本章的 MSE 公式平均损失与梯度。用中央差分分别检查两个偏导,再尝试几种学习率。

下一章概率与随机变量会处理另一件现实中的麻烦事:同样的输入,实际结果为什么仍会波动。导数帮我们调整模型,概率则帮助我们描述不确定性。

如果想慢慢补齐导数的图形直觉,可选读 OpenStax《Calculus Volume 1》的导数定义。其中的极限推导可以分几次阅读,不必一次记完。