导数、梯度与优化
在导读里,我们让程序试遍 451 组参数,再挑出预测误差最小的一组。两个参数时还好办;假如每个参数有 100 种选择,十个参数就有 种组合。再勤快的循环,也很难逐一试完。
能不能不用每条路都走一遍,而是先看看:往哪边挪一点,损失会变小?
导数和梯度,正是用来回答这个问题的。它们不神秘:先观察一个量变动时,另一个量怎样跟着变;再把这种关系用到参数更新中。
1. 从“平均每页多几秒”到变化率
先看熟悉的函数 。输入从 10 增加到 12,输出从 50 增加到 56。输出的变化除以输入的变化:
这个比值叫这段区间上的平均变化率。在打印模型里,单位是秒/页;在图上,它是连接两个点的直线斜率。
一次函数的斜率处处相同。曲线就未必了。为了容易手算,换成 ,看看 附近:
| 输入增加多少 | 平均变化率 |
|---|---|
| 1 | 5 |
| 0.1 | 4.1 |
| 0.01 | 4.01 |
这里算的都是:
这叫差商。把平方展开,会发现:
只要 ,这个等式就成立。 越接近 0,结果就越接近 4。我们把这个极限叫作函数在 2 处的导数,写成 。
一般地:
表示“观察它趋近的值”。这里不是把 塞进分母,而是看 从两侧越来越接近 0 时,差商是否趋向同一个有限值。
图 1:两个点之间的割线描述平均变化;导数则描述一个点附近的变化趋势。
如果函数在某点可导,附近一小步可以这样估计:
例如, 可以估成 ;精确值是 4.0401。步子小的时候,这个局部近似很有用,但走远了就不一定准。
导数也不是处处存在。例如 在 0 处有个尖角:从左看斜率是 ,从右看是 1,没有唯一的切线斜率。后面遇到这样的函数时,需要相应的处理方法。
2. 换一个横轴:现在调整的是参数
刚才讨论输入 怎样改变输出。训练时,我们关心的通常是:参数怎样改变损失。
先拿一个容易看清的教学损失函数练手:
横轴是参数 ,纵轴是损失 。最低点显然在 。我们已经知道答案,但先让程序假装不知道,看看能否顺着局部变化找到它。
用刚才展开平方的办法,可以得到:
当 时,导数为 :向右挪一小步,损失会下降。当 时,导数为 4:向右会升高,应该向左走。
也就是说,我们可以朝导数的反方向移动:
希腊字母 读作 eta,这里表示正的学习率,用来决定步子大小。导数告诉我们方向和局部变化快慢,学习率控制这次实际走多远。
例如,从 出发,取 :
损失从 9 降到 。再计算新位置的导数,继续走。这种反复更新的过程,就是梯度下降在单参数情形下的样子。
注意,这里的 已经是参数,不是某份文档的页数。区分“在改变输入”还是“在调整参数”,比记住字母叫什么更重要。
3. 步子大一点,会不会更快
有时会,有时却会冲过头。
对这个特定的二次函数,更新式还能整理成:
左边是更新后离最低点的差,右边是原来的差乘上一个系数。于是可以直接看出:
| 学习率 | 每一步会发生什么 |
|---|---|
| 从同一侧逐渐靠近 | |
| 一步到达 3 | |
| 来回跨过最低点,但幅度缩小 | |
| 除非已在最低点,否则在两侧往返 | |
| 除非已在最低点,否则越走越远 |
图 2:四次实验都从 0 出发。能否收敛,取决于步长与当前损失曲面的形状是否匹配。
这里 的表现特别好,只是因为我们挑了这个简单函数。换成 ,曲线陡了一百倍,同一个学习率就可能太大。输入和损失的尺度,也会影响训练是否稳定。
因此,不存在一个对所有模型都合适的学习率。实际训练常会观察损失曲线,比较几种候选步长,必要时逐渐减小它。后面做完整模型时,我们还会用验证集判断方案效果,而不是只看训练损失下降得快不快。
4. 两个参数时,分别问两次
回到直线模型,取一条教学样本:,实际答案 。预测为 ,平方损失为:
现在可以调整 ,也可以调整 。如果只想知道 的影响,就先把 固定;只想知道 的影响,就把 固定。这种“其他参数暂时不动”的导数叫偏导数。
符号 表示对 的偏导。我们先不急着背结果,而是把计算拆成两步:
是预测误差。参数先改变误差,误差再改变损失。这是上一章函数复合的具体例子。
误差增加一点,损失的变化率是 ; 增加一点,误差的变化率是 。把这两段关系乘起来:
同理, 增加 1,误差也增加 1,所以:
这就是链式法则:沿着“谁影响谁”的计算顺序,把相邻两步的变化率接起来。对于一般的复合函数 ,对应的是 ,外层的导数要在内层输出处计算。
取 ,预测为 4,误差 ,因此:
把所有偏导按参数顺序排成向量,就是梯度:
读作 nabla。这个新符号里装的,仍然是两个已经算出来的数。
取学习率 0.05,同时更新两个参数:
新预测为 5.5,损失从 9 降到 2.25。两个偏导都要在同一组旧参数处计算,再一起更新。 如果改完 才重新计算 的偏导,那就是另一种更新过程了。
5. 从一条样本走向整个训练集
设有 条样本,第 条为 。把每条平方误差平均,得到导读中用过的 MSE:
每条样本都会贡献一份梯度,再取平均:
从代码角度看,先循环计算预测误差,再分别累加“误差乘输入”和“误差”,最后乘 就行了。第六章会据此从零训练一条直线。
参数有更多个时,梯度也只是多几个分量。在常用的欧几里得距离下,负梯度给出固定小步长时局部下降最快的方向。理由可以用上一章的点积理解:参数改变量记作 ,损失的一阶近似为:
令 ,近似变化量就是 ,不会是正数。不过这是局部近似,实际步子太大时,损失仍然可能上升。
如果看这段向量式还有点吃力,先抓住前面两个参数的更新就足够继续阅读。
6. 能不能检查自己有没有求错导
可以把参数往两侧各挪一小步,计算中央差分:
它只需要调用原函数,不需要我们手写导数。对于两个参数,就每次只改其中一个,分别检查。
这很适合调试:如果解析梯度与数值梯度差得很远,先检查公式和代码。但 也不能无限缩小,浮点数会有舍入误差;太大的 又会让局部近似变差。下面用 演示。
数值差分通常要为每个参数额外计算损失,参数很多时成本很高。后面的自动微分会用链式法则更高效地求梯度;它与“把参数挪一下再算”不是同一种方法。
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 返回的每一步都打印出来,会看到前者从左边靠近,后者在两边来回跨越。只看最后一个数字,有时会错过训练过程的重要信息。
我们的例子只有一个低谷。更复杂的损失可能有多个局部最低点,也可能有梯度为零但不是最低点的地方,例如 在 0 处。梯度很小,只能说明当前位置附近的一阶变化很小,不自动等于“训练已经找到全局最好答案”。
此外,训练损失下降,也不能单独证明新数据上的预测更好。优化解决的是“怎样降低给定目标”,泛化还要靠独立数据来检查。
8. 自测与小实验
- 对 ,在 处的导数是多少?用 的前向差商核对。
- 对 ,从 出发,学习率为 0.1,下一步到哪里?损失是多少?
- 从 0 出发,把学习率改成 1,前两次更新分别到哪里?
- 在 处,为什么对 的偏导是对 偏导的两倍?
- 把单样本更新学习率从 0.05 改为 0.2,计算更新后的损失。步子大了是否一定更好?
- 梯度为零,能否保证已经到达全局最低点?训练损失下降,能否保证测试表现改善?
展开参考答案
- 导数为 6,差商为 。
- 导数为 4,因此到达 ;损失为 。
- 先到 6,再回到 0,随后继续往返。
- 先乘输入 才影响误差,而 直接进入误差;链式法则带来这个因子。
- 新参数为 ,预测为 10,误差为 3,损失仍为 9。这一步没有改善;再增大一些还可能更差。
- 都不能。驻点可能是局部极值或其他平坦位置;新数据上的表现需要独立评估。
进阶选做:给 sample_loss 增加第二条样本,按本章的 MSE 公式平均损失与梯度。用中央差分分别检查两个偏导,再尝试几种学习率。
下一章概率与随机变量会处理另一件现实中的麻烦事:同样的输入,实际结果为什么仍会波动。导数帮我们调整模型,概率则帮助我们描述不确定性。
如果想慢慢补齐导数的图形直觉,可选读 OpenStax《Calculus Volume 1》的导数定义。其中的极限推导可以分几次阅读,不必一次记完。