梯度与有限差分:机器是怎么找到“下山的路“的

1. 一个被跳过的“为什么“

从第一篇到现在,训练循环一直是这个套路:

for(size_t i = 0; i < 100*1000; i += 1) {
    knn_finite_diff(n, g, eps, ti, to);  // 求梯度
    knn_learn(n, g, rate);               // 沿梯度反方向更新参数
}

我们一直在说“求梯度,沿反方向走“,cost 也确实一路降到了接近 0。但这套循环为什么有效?梯度到底是什么?为什么每个参数戳一下、再各自动一下,十几万参数的网络就能慢慢“学会“XOR?

这篇把循环拆开,从数学和几何的角度把这些问题讲清楚。

2. 导数:一个参数变化时,cost 怎么变

回到第一篇的直线拟合。cost 是关于 w 的函数,固定 b 只看 w,它大概是一条开口向上的抛物线:

cost
  │╲         ╱
  │ ╲       ╱
  │  ╲     ╱
  │   ╲   ╱
  │    ╲_╱  ← 最低点:最好的 w
  └──────────── w
         ↑
       当前位置

站在抛物线上任何一点,都可以问一个问题:w 增加一点点,cost 会怎么变? 这就是导数(斜率):

  • 在最低点左侧,往右走 cost 变小 → 斜率是负的;
  • 在最低点右侧,往右走 cost 变大 → 斜率是正的;
  • 恰好在最低点,往哪走都不变 → 斜率是 0。

注意一个美妙的性质:斜率的符号自动告诉你该往哪边走。斜率为负,说明 w 该增大;斜率为正,说明 w 该减小。只要算出了斜率,就不需要任何“试错方向“的猜测——沿着斜率的反方向走,cost 一定下降(只要步子别迈太大)。

3. 梯度:多维版本的“斜率“

一个神经元有 3 个参数,{2, 4, 1} 的网络有 17 个。这时 cost 不再是抛物线,而是高维空间里的一个“曲面“。没法直接画图,但直觉可以借用二维的:把 cost 想象成一片山区的海拔,参数是坐标,你站在山上某处,想去谷底。

梯度回答的问题是:“往哪个方向走,海拔上升最快?” 它由每个方向上的斜率拼起来:

梯度 = (cost 对 w1 的斜率, cost 对 w2 的斜率, cost 对 b 的斜率, ...)

每个分量叫做偏导数:只动这一个参数、其他全部固定时,cost 的变化率。

所以梯度和导数的关系很简单:单参数时,梯度就是导数,一个数;多参数时,梯度是把每个参数各自的偏导数按位置排起来的一组数(向量)。可以说梯度 = 一组导数值的集合,每个分量对应一个参数。

几何上有两个关键事实:

  1. 梯度指向上升最快的方向,所以负梯度就是下降最快的方向。这就是为什么更新公式是减号:w -= rate * gradient。
  2. 梯度永远垂直于等高线。看地图时,等高线是海拔相同的点连成的圈,“最陡的下山方向“一定垂直穿过等高线、直指谷底。沿着等高线走海拔不变,垂直它走变化最快。

4. 有限差分:不会求导,就“戳一下试试“

理论上斜率要用微积分推导。但我们的 cost 函数是一整个神经网络套着 sigmoid,手工解析求导很麻烦。有限差分是一个偷懒但有效的办法:

想知道某一点的斜率?把参数稍微抬高 eps,看 cost 变了多少,变化量除以 eps 就是斜率的近似值。

对应库里的代码:

saved = MATRIX_AT(n.weights[i], j, k);
MATRIX_AT(n.weights[i], j, k) += eps;
MATRIX_AT(g.weights[i], j, k) = (knn_cost(n, ti, to) - c) / eps;
MATRIX_AT(n.weights[i], j, k) = saved;

这就像蒙眼下山时伸脚试探:脚往东边抬一下,感觉高了还是低了?低了就往东边挪。eps 越小,近似越接近真实导数(代码里取 0.1,够用了)。

对每个参数都“戳一下“,17 个参数戳 17 次,就拼出了完整的梯度向量。这就是 knn_finite_diff 里三层循环在干的事。

5. 为什么循环下去就能收敛到答案

把单轮迭代拆开看,每一轮做三件事:算出当前位置的梯度、沿反方向走一小步、回到第一步。

关键在于第二步的保证:只要方向是下降方向、步子足够小,走完这一步 cost 必然比上一步低。这是导数定义直接给出的结论,不需要碰运气。

于是一个不等式链条形成了:

cost₀ > cost₁ > cost₂ > cost₃ > ...

每一轮 cost 都严格下降,而 cost 又有下限(它是误差平方和,最小也就是 0)。一个不断下降又有下限的序列,必然停在某个地方——停下来的地方,梯度必然接近 0(否则还能继续下降)。梯度为 0 的点是曲面上的“平地“:谷底、山顶或鞍部。因为我们一直在往下走,到达的基本上是谷底。

用下山的画面总结整个循环:

站在随机初始化的山坡上(随机参数,cost 很大)
  → 伸脚试探每个方向的坡度(有限差分求梯度)
  → 朝最陡的下坡方向挪一小步(learn,步长 rate)
  → 再试探、再挪……
  → 直到四面八方都是上坡,走不动了(梯度≈0,到谷底)

这就是为什么 XOR 的输出会从“全都瞎猜在 0.5 附近“变成泾渭分明的 0.98 / 0.01——不是某一步突然变聪明,而是几万次“挪一小步“的累积。

两个诚实的补充

第一,到达的是“附近的谷底“,不一定是“最低的谷底“。 复杂网络的 cost 曲面千沟万壑,有很多局部最低点。初始参数随机,落在哪个山谷里就收敛到哪个谷底。好在实践中,神经网络的局部谷底往往已经足够好——这也解释了之前观察到的现象:同一套代码多跑几次,学出的参数组合不同但都能完成任务。

第二,rate(学习率)是个微妙的旋钮。 步子太大,可能一步跨过谷底,在对面山壁上弹来弹去,甚至越弹越高(发散);步子太小,几万轮也走不到底。代码里 rate = 0.1 是试出来的经验值,不是理论推出来的。

6. 梯度为什么要单独存到一个网络里

库函数 knn_finite_diff(KNN n, KNN g, ...) 接收两个网络:n 是真正训练的参数,g 只用来装梯度。为什么不直接改 n?

梯度这组数和参数是一一对应的:weights[1] 的每个元素都有一个对应的梯度值。最省事的存法,就是用一个和参数形状完全相同的容器来装。knn_alloc(arch, ...) 本来就能分配形状正确的网络,直接复用它,g.weights[i] 的第 (j,k) 个元素天然对应 n.weights[i] 的第 (j,k) 个参数的梯度。

好处体现在更新时代码极其对称:

MATRIX_AT(n.weights[i], j, k) -= rate * MATRIX_AT(g.weights[i], j, k);

同一个 (i, j, k) 索引,左边是参数,右边是它的梯度,直接对位相减。如果梯度用别的结构存,就得维护两套索引的映射关系,纯属自找麻烦。这是纯工程便利,不是数学要求。

7. 参数很多时,每个梯度值代表什么

每个梯度值是一句关于对应参数的“情报“:

此刻其他参数都不动,只把这个参数增加 1 个单位,cost 大约会变化多少。

  • 符号告诉方向:为正 → 该减小;为负 → 该增大。
  • 大小告诉灵敏度:绝对值大 → 这个参数很“敏感“,动一点 cost 就剧烈变化;绝对值小 → 动了也白动;接近 0 → 当前对结果几乎没影响。

比如 17 个参数,某一轮的梯度可能是:某个权重 2.3(很敏感,且该调小),某个偏置 0.002(几乎无所谓),另一个权重 -0.7(中等敏感,且该调大)。每个参数有自己的“脾气“完全正常——它们在计算链路里的位置不同,对最终输出的影响力自然不同。

8. 一组大小不一的数,怎么就拼出了“下降最快的方向“

这是最值得掰开的一点。先建立一个关键近似(微积分的核心思想,一阶近似):

cost 的变化 ≈ g₁·Δw₁ + g₂·Δw₂ + ... + g₁₇·Δw₁₇

同时调整多个参数时,cost 的总变化约等于“每个参数的移动量 × 各自梯度“的加总。每个梯度值就是每投入一单位移动量的“回报率“。

现在问题变成一个“预算分配“问题:一步能走的总长度是有限的(比如规定一步只走长度 1),怎么分配给各个参数,让 cost 降得最多?

拿两个参数举例,梯度是 (3, 4),走一步长度 1。几种分法对比:

方案 A:全给 w1 → Δw = (-1, 0)
        cost 变化 ≈ -(3×1 + 4×0) = -3

方案 B:全给 w2 → Δw = (0, -1)
        cost 变化 ≈ -(3×0 + 4×1) = -4

方案 C:按梯度比例 3:4 分 → Δw = (-0.6, -0.8)   (0.6² + 0.8² = 1,步长仍是 1)
        cost 变化 ≈ -(3×0.6 + 4×0.8) = -(1.8 + 3.2) = -5  ← 降得最多!

方案 C 赢的原因是:两个方向都在“赚钱“,把预算按回报率比例同时投给两边,总收益比押注任何单边都高。这背后的数学事实是:两个向量的点积在它们同方向时最大——所以要让 Σ gᵢ·Δwᵢ 最负,Δw 就必须和 g 完全反向,也就是 -g 方向。

几何直觉也一致:山坡往东的坡度是 3,往北的坡度是 4,最陡的下坡方向既不是正东也不是正北,而是东 3 份、北 4 份混合出来的那个斜向。梯度大的轴说明那边陡,多走一点;梯度小的轴说明那边平缓,少走不亏。加权混合完,就是局部意义上的“最快下降方向“。

所以“梯度方向是下降最快的方向“不是比喻,它是严格结论:在步长固定的约束下,负梯度方向让 cost 的一阶下降量最大。

“最快“的两个限定词

  1. 局部的。梯度只描述当前这一点的坡度,走远了坡度就变了——所以要走一小步、重新算梯度、再走。这就是循环存在的原因,而不是算一次梯度一口气冲到底。
  2. 一阶的。“回报率“近似只在步子很小时准。步子迈太大,实际 cost 变化会偏离预言,可能跨过谷底甚至弹得更高——这就是学习率不能贪大的数学原因。

9. 混合方向在代码的哪里

上一节说了要“按比例混合“,但通读 knn_learn,没有一行代码在“计算混合方向“:

void knn_learn(KNN n, KNN g, float rate) {
    for(size_t i = 0; i < n.count; i += 1) {
        for (size_t j = 0; j < n.weights[i].rows; j += 1) {
            for(size_t k = 0; k < n.weights[i].cols; k += 1) {
                MATRIX_AT(n.weights[i], j, k) -= rate * MATRIX_AT(g.weights[i], j, k);
            }
        }
        // 偏置同理 ...
    }
}

17 个参数各减各的,没有任何一行“关心“别的参数。但混合已经发生了:w1 动了 3 份、w2 动了 4 份,走完这两行,参数点的位移向量就是 (-3, -4) × rate,和手算的“东 3 份、北 4 份的斜向“是同一个方向。

换句话说:对方向向量按分量逐个执行,和沿着混合方向走,是同一回事的两种说法。向量本来就可以拆成各轴分量分别施加,拆开后就是这个平淡无奇的循环。混合是涌现出来的,不是写出来的。

真正保证“混合“的,是两个函数的分离

代码里有个容易忽略但很关键的设计:

knn_finite_diff(n, g, eps, ti, to);  // 第一步:把所有参数的梯度都量出来
knn_learn(n, g, rate);               // 第二步:量完之后,所有参数一起动

knn_finite_diff 里每次“戳“完一个参数都会恢复原值(= saved),保证 17 个梯度全是在同一个位置量出来的。量齐之后,knn_learn 才让所有参数同时移动。

为什么不能边量边动?如果改成戳 w1、量梯度、立刻更新 w1,再戳 w2……那量 w2 的梯度时 w1 已经不在原位了,每个梯度在不同点量出,拼出来的“方向“就是四不像——17 次各管各的下坡,而不是一次协调的下坡。先全量、后全动,这个顺序才是“沿混合方向走一步“的代码化身。

一个免费的好处

理论推导时假设“步长固定为 1 再按比例分配“,但代码实际走的步长是 rate × |g|(梯度向量的总长度),没有归一化。这其实是个赠品:

  • 离谷底远时,坡度陡,|g| 大,步子自动大——快速接近;
  • 快到底时,坡度缓,|g| 小,步子自动小——细腻收敛,不容易一脚跨过谷底。

梯度下降自带“刹车“,这也是为什么简单的固定学习率在这个小问题上也够用。

10. 有限差分的代价

有限差分求一个参数的梯度,要完整跑一遍 cost(整个训练集过一遍网络)。17 个参数戳 17 下还能忍,之前算过,一个像样点的网络有二十多万个参数,每轮迭代就要跑二十多万次完整 cost——这就是它只能当教学工具的原因。

而反向传播的洞察是:利用链式法则,一次前向加一次反向传播,就能算出所有参数的梯度,计算量只相当于跑两遍网络。从“戳二十万下“到“算两遍“,是同一个梯度,只是换了条快得多的求解路径。

11. 总结

这篇回答了几个关于训练循环的“为什么“:

  1. 导数回答“动一个参数,cost 怎么变“,它的符号直接给出调整方向;梯度就是所有参数的导数拼成的向量,几何上指向上升最快、垂直于等高线的方向。
  2. 有限差分是数值近似的偷懒办法:戳一下、看变化、除以 eps,绕开了解析求导。
  3. 循环有效是因为每步都保证 cost 严格下降,而 cost 有下限,必然停在梯度为 0 的谷底——虽然不是保证全局最优的那个谷底。
  4. 梯度分量大小不一恰恰携带了信息量:符号定方向、大小定配比,按梯度比例合成的方向就是“单位步长下降最多“的方向。
  5. 混合方向没有专门的代码:每个参数按自己的梯度同时移动,合效果就是沿混合方向前进一步。需要代码保证的是配套的纪律——梯度全在同一点量完(戳完就还原),再统一应用。

一句话概括整个系列至今的训练思想:

每个参数都只关心“我动一下 cost 怎么变“,所有参数一起按这个情报微调,网络就自己走下了山。