逐行拆解梯度下降:十二行 Python 和它背后的微积分
十二行代码给一堆散点拟合出最佳直线:从零这条水平线出发,重复 900 次去问「往哪边错得少一点」。把它一行一行拆开——梯度到底是什么、两个公式怎么推出来的、为什么是减号、alpha 又在管什么。
English version: Gradient Descent, Line by Line: Twelve Lines of Python and the Calculus Behind Them
这个函数做的事,是给一堆散点拟合出一条最佳直线。它从「斜率 0、截距 0」这条水平线出发,重复 900 次去问一个问题:「往哪边挪,错得少一点?」然后朝那个方向挪一小步。
里面没有任何比乘法、加法、求平均更复杂的东西。下面把它一行一行拆开。
def gradient_descent_training(X, y, alpha=0.0001, epochs=900):
m, b = 0.0, 0.0
for epoch in range(epochs):
n = len(X)
y_pred = m * X + b
error = y_pred - y
dm = (2/n) * np.dot(error, X)
db = (2/n) * np.sum(error)
m -= alpha * dm
b -= alpha * db
return m, b
输入什么,输出什么
它返回两个数 m 和 b,也就是它最后停下来的那条直线的斜率和截距。这两个数合起来就是训练好的模型——要预测一个新值,算 m * x_new + b 就行了。
| 名字 | 是什么 | 例子 |
|---|---|---|
X |
输入,一个 NumPy 数组,每个数据点一个数 | [0, 0.17, 0.34, ...] |
y |
真实观测到的输出,长度和 X 一样 |
[-0.39, -2.66, 1.35, ...] |
alpha |
学习率:每次迈多大一步 | 0.0001 |
epochs |
整个过程重复多少遍 | 900 |
这一点值得停下来想一秒。一个训练好的机器学习模型并不是什么神秘的东西,这里它字面意义上就是两个数,而这个函数全部的工作,就是给它们找到好的取值。
逐行解释
m, b = 0.0, 0.0
初始猜测。斜率 0、截距 0 就是过原点的那条水平线——几乎肯定拟合得很烂,但没关系。函数的意义正在于:不管从哪儿开始,它都能一点点改进。
for epoch in range(epochs):
下面的东西做 900 遍。跑一遍叫一个 epoch(轮次)。注意 epoch 在循环体里从来没被用到,它只是个计数器。
y_pred = m * X + b
当前这条线对所有数据点的预测,一次算完。
从纯 Python 过来的人常在这一行卡住:X 是 NumPy 数组,所以 m * X + b 会对每个元素都做一遍算术,y_pred 回来时是一整个预测数组,不需要写循环。X 有 60 个点,这一行就一次做出 60 个预测。
error = y_pred - y
每个预测错了多少,一个数据点一个数。正数表示线在这个点上方,负数表示在下方。
顺序很关键,必须是「预测减真值」。写反了,后面每一步都会朝错误的方向走——线不是靠向数据,而是逃离数据。这是这类代码里最常见的 bug,没有之一。
dm 和 db
真正在「思考」的两行。下面单开一节讲。
m -= alpha * dm 和 b -= alpha * db
挪一下。把每个参数朝着「能降低误差」的方向挪一小段。也单开一节讲。
return m, b
把循环最后落到的那两个数交出去。
两行梯度
dm = (2/n) * np.dot(error, X)
db = (2/n) * np.sum(error)
这两行回答同一个问题:如果我把 m(或 b)往上推一丁点,总误差是变好还是变坏,变多少?
梯度就是这么回事:一个数,告诉你哪边是上坡、坡有多陡。
先看 db,它简单
np.sum(error) 把所有误差加起来,除以 n 就是平均误差,那个 2 是微积分留下的尾巴。所以:
- 预测平均偏高 →
db为正 → 更新把b往下压。 - 预测平均偏低 →
db为负 → 更新把b往上抬。
这和常识对一条线的高度该怎么调完全一致。微积分和你的直觉站在一边。
再看 dm
np.dot(error, X) 是点积:两个数组逐元素相乘,然后把结果加起来。它等价于 np.sum(error * X),只是更快也更地道。
np.dot([2, -1, 3], [10, 20, 30]) # = 2*10 + (-1)*20 + 3*30 = 90
所以 dm 是按 x 加权的平均误差:每个点的误差,按它在 x 轴上离原点多远被放大或缩小。
为什么要按 x 加权?因为改变斜率是让直线绕 y 轴转动。在 x = 0 附近它几乎不动线,但在 x = 100 处会甩出去很远。离原点越远的点,对「斜率对不对」的话语权就越大,而乘以 X 正是把这件事算进去的方式。
完整的微积分推导
这一节从零推出 dm 和 db。你需要微积分里的一条规则(链式法则)和偏导数的概念。跳过也可以——把上面两段大白话当成定义,后文不依赖这段推导。
第 1 步:写出要最小化的东西
损失是均方误差:
L(m, b) = (1/n) * sum over i of ( m*x_i + b - y_i )**2
记号要读仔细,大部分困惑都从这里开始。
L 是 m 和 b 的函数,不是 x 的函数。数据 x_i 和 y_i 是固定的——它们是测出来的,不会变。唯一能自由变动的是那两个参数。所以求导时,我们是对 m 和 b 求导,每一个 x_i、y_i 都当常数。
这就是让梯度下降讲得通的那个思维翻转。你习惯的是 x 是变量、m 是固定系数;这里正好反过来。
第 2 步:给括号里的东西起个名
求和里每一项都是「某个东西的平方」。给那个东西起个名:
u_i = m*x_i + b - y_i # 第 i 个点上的误差
L = (1/n) * sum of u_i**2
所以 u_i 就是代码里的 error。损失是 u_i 平方的平均。
第 3 步:对 m 求导
求和法则让我们可以一项一项处理,所以只看 u_i**2。链式法则说:对复合函数求导,先对外层求导、内层原样保留,再乘以内层的导数。
d/dm of (u_i**2) = 2 * u_i * (du_i/dm)
那 du_i/dm 是多少?对 u_i = m*x_i + b - y_i 关于 m 求导,其他都当常数:
d/dm of (m*x_i) = x_i # x_i 是常数系数
d/dm of (b) = 0 # b 不依赖 m
d/dm of (-y_i) = 0 # 数据是常数
所以 du_i/dm = x_i
代回去:
d/dm of (u_i**2) = 2 * u_i * x_i
把求和和 1/n 加回来:
dL/dm = (2/n) * sum over i of ( u_i * x_i )
= (2/n) * sum over i of ( error_i * x_i )
「逐元素相乘再求和」就是点积——这就是代码里那一行:
dm = (2/n) * np.dot(error, X)
第 4 步:对 b 求导
一模一样,只是内层导数变了:
d/db of (m*x_i) = 0
d/db of (b) = 1
d/db of (-y_i) = 0
所以 du_i/db = 1
于是:
dL/db = (2/n) * sum over i of ( u_i * 1 )
= (2/n) * sum over i of error_i
db = (2/n) * np.sum(error)
两个公式的全部差别就在于 du/dm = x_i 而 du/db = 1。正是这一个 x_i 因子,导致斜率的梯度被 x 加权、截距的梯度没有——也导致后面会提到的:两个参数收敛速度差得很远。
为什么梯度指向上坡
导数是变化率:dL/dm 表示 m 每增加一个单位,L 上升多少。
所以如果 dL/dm 为正,增大 m 会让损失变大。你想要的是反过来,那就减小 m;如果 dL/dm 为负,增大 m 会让损失变小,那就增大 m。两种情况下你都是朝导数符号的反方向走——而 m -= alpha * dm 干的正是这件事。这一行就是梯度下降之所以有效的全部理由,上面这段就是它的论证。
(dL/dm, dL/db) 这一对合起来就是梯度,教科书里写成一个倒三角。把它读成 (m, b) 平面上的一个向量,它指向损失上升最快的方向;取负,就是下降最快的方向。两行更新语句就是沿着这个向量走。
用数值验证一遍——值得掌握的小技巧
你从来不必盲信一段推导。导数是斜率的极限,所以你可以直接把参数推一下、看看损失怎么变,来近似它:
dL/dm ≈ ( L(m+h, b) - L(m-h, b) ) / (2h) h 取小值,比如 1e-5
在下面那个三点数据集上、取 m = 0.5、b = 0.5,把它和公式对照:
| 量 | 公式算出的 | 数值推出的 |
|---|---|---|
dL/dm |
-16.000000 | -16.000000 |
dL/db |
-7.000000 | -7.000000 |
小数点后六位都一致。这在实际工作里叫 gradient checking(梯度检查),是大家调试手写反向传播的办法。如果解析梯度和数值梯度对不上,有 bug 的一定是解析那个——而且通常是漏了个系数或者符号写反。
2/n 这个常数,以及去掉它会怎样
2 来自对平方求导,1/n 来自损失取的是平均而不是求和。
两个都不是承重结构。它们都是正常数,去掉只是把所有梯度按同一个倍数缩放,不改变「哪边是下坡」,只改变每步走多远。把 2 砍半、同时把 alpha 加倍,行为完全一样。有些教科书把损失定义成前面带 1/(2n),就是为了让那个 2 约掉、导数长得干净——纯属美观,你在别处看到是这个原因。
不过 1/n 确实干了一件有用的事:它让梯度的大小和数据点个数无关。没有它,数据量翻倍梯度就翻倍,在 100 个点上调好的学习率,到 10,000 个点上就会炸。
为什么一定存在最小值
再求一次导:
d2L/dm2 = (2/n) * sum of x_i**2
平方和恒为正,所以二阶导恒为正,所以损失曲线是碗形的。两个参数时同样的论证给出一个真正的碗:凸函数,有且只有一个最低点,没有假山谷能把你困住。
这件事比看上去重要。它意味着在这个问题上,梯度下降不可能因为结构性原因失败。如果它失败了,问题出在你的 alpha 或者你的代码。
它顺带带来的捷径
碗底是平的,两个偏导数都为 0。令它们为 0,得到两个未知数、两个方程:
(2/n) * sum( (m*x_i + b - y_i) * x_i ) = 0
(2/n) * sum( m*x_i + b - y_i ) = 0
解出来,就直接得到最小二乘的闭式解,一个循环都不用跑:
m = sum( (x_i - x_mean)*(y_i - y_mean) ) / sum( (x_i - x_mean)**2 )
b = y_mean - m * x_mean
那为什么还要迭代?因为这条捷径只对极少数模型存在。损失只要再难一点——逻辑回归、神经网络——那两个方程就没有闭式解了。而梯度下降除了「一个可微的损失」之外什么都不需要,这就是为什么它一路撑到了现代机器学习,而那个公式没有。
在小问题上把两种都算一遍仍然值得。看着一个循环吭哧吭哧磨到代数瞬间给出的那个数,是「这个循环在做真正的数学」最有说服力的演示。
为什么是减号,alpha 又在管什么
m -= alpha * dm
b -= alpha * db
那个减号
梯度指向上坡——指向误差更大的方向。我们要的是误差更小,所以往反方向走,而减法就是「往反方向走」的写法。
把 -= 改成 +=,函数就会做它职责的反面:往上爬,误差每轮都变大,数值最后溢出。故意试一次,这是说服自己「这个符号在干实事」的好办法。
alpha,学习率
梯度说的是往哪边,alpha 说的是走多远。这件事的重要性,几乎超过函数里的其他一切:
| alpha | 会发生什么 |
|---|---|
| 太小 | 方向对,但线爬得太慢。epoch 用完了还没走到。 |
| 差不多 | 先快速下降,然后稳定在最优解附近。 |
| 太大 | 每一步都冲过头,而且比上一步冲得更远。误差爆炸,先 inf,再 nan。 |
蒙眼下山那个比喻是常见的比喻,也确实是个好比喻:你站在浓雾里的山坡上,想走到谷底。你看不见山谷,但能感觉到脚下往哪边倾斜。于是你感受坡度、朝下坡迈一步、再重复。梯度就是脚下的坡感,alpha 就是你的步长。
不存在一个放之四海皆准的 alpha。它取决于数据,找到一个合适的值本身就是工作的一部分。
一个 epoch,用真实数字走一遍
三个正好落在 y = 2x + 1 上的数据点,所以你事先知道答案该是多少。取 alpha = 0.1。
X = np.array([1., 2., 3.])
y = np.array([3., 5., 7.])
第 1 个 epoch,从 m = 0、b = 0 出发:
| 步骤 | 值 |
|---|---|
y_pred = 0*X + 0 |
[0, 0, 0] |
error = y_pred - y |
[-3, -5, -7] —— 每个预测都偏低 |
np.dot(error, X) |
-3(1) + -5(2) + -7(3) = -34 |
dm = (2/3)(-34) |
-22.67 |
db = (2/3)(-15) |
-10.0 |
m = 0 - 0.1(-22.67) |
2.267 |
b = 0 - 0.1(-10.0) |
1.0 |
两个梯度都是负的,因为每个预测都偏低。减去一个负数把两个参数都往上推——这是对的。一个 epoch 之后,线已经是 y = 2.27x + 1.0,而真值是 y = 2x + 1。
后面是这样:
| Epoch | m | b | 均方误差 |
|---|---|---|---|
| 起始 | 0.000 | 0.000 | 27.67 |
| 1 | 2.267 | 1.000 | 0.33 |
| 2 | 2.018 | 0.893 | 0.0053 |
| 3 | 2.044 | 0.908 | 0.0013 |
| 900 | 2.000 | 1.000 | ~0 |
注意第 1 轮的冲过头和第 2 轮的修正回来。这种摆动是正常的——是线荡过答案又荡回来,alpha 小一点就会温柔一些。
也注意工作量有多集中在第一步:误差立刻从 27.67 掉到 0.33,然后花 900 轮在打磨。你以后见到的几乎每一条训练曲线,都是这个形状。
整件事画成一张图
┌─────────────────────────┐
│ 起点:m = 0, b = 0 │
└────────────┬────────────┘
↓
┌─────────────────────────┐
┌──→ │ 预测 │
│ │ y_pred = m*X + b │
│ └────────────┬────────────┘
│ ↓
│ ┌─────────────────────────┐
│ │ 衡量 │
│ │ error = y_pred - y │
│ └────────────┬────────────┘
│ ↓
│ ┌─────────────────────────┐
│ │ 问哪边是下坡 │
│ │ dm, db │
│ └────────────┬────────────┘
│ ↓
│ ┌─────────────────────────┐
│ │ 朝那边迈一步 │
│ │ m -= alpha*dm │
│ │ b -= alpha*db │
│ └────────────┬────────────┘
│ ↓
│ ⟨ 跑满 900 轮了吗?⟩
└──── 没有 ───────┤
有
↓
返回 m, b
四步一个圈:**预测、衡量、问哪边、迈一步。**转九百圈,你就有了一个训练好的模型。
这个循环就是这个函数值得细读的原因。它并不是直线专用的。把模型换成一个十亿参数的神经网络,这四步一字不改——只有第三步里的梯度计算变难了,而那正是反向传播要解决的问题。你以后会用到的每一个深度学习框架,都是这里写的东西的工业级版本。
这份代码里值得注意的四件事
1. 默认的 alpha 非常小。 在 x 从 0 跑到 10 的数据上,alpha=0.0001 配 900 轮根本跑不完。一次实测里,斜率只到 2.33(最佳拟合是 2.54),截距只到 0.29(最佳拟合是 -1.07)——几乎没离开起点。真正走到答案大约需要 50,000 轮。改成 alpha=0.01,900 轮绰绰有余。这不是代码的 bug,但确实意味着照原样跑会让人觉得它「不work」。多试几个 alpha,看看变化。
2. 截距比斜率慢一大截。 dm 乘了 X,所以当 x 在 0 到 10 之间时,它大约比 db 大十倍。两者用同一个 alpha,那么适合斜率的步长对截距来说就远远太小了。标准做法是训练前把 X 标准化成均值 0、标准差 1。
3. n = len(X) 写在循环里面。 同一个数被重算了 900 遍。无害,但它该放在循环外面。
4. 没有任何东西记录误差。 函数只返回 m 和 b,不保留历史,所以你既画不出损失曲线,也说不清它到底是收敛了、摆动了还是炸了。每轮往一个 list 里追加一次均方误差只要一行,却能让调试轻松得多。
检查一下你懂了没有
- 为什么是
error = y_pred - y,而不是y - y_pred? np.dot(error, X)用到了X,np.sum(error)却没有。差别从哪来?- 所有预测都偏高。
dm和db的符号分别是什么?m和b会往哪个方向动? - 你把
-=改成了+=。描述一下这 900 轮里打印出来的误差会怎么变。 - 在一份明显陡峭上升的数据上跑完 900 轮,函数返回
m = 0.02, b = 0.01。最可能的原因是什么?