qinghuan
← 回到知识探索

AI 直觉 / 动手实验

梯度核验通过一次,就能相信整段反向代码吗?

用一份故意写错的梯度,对照同时扰动、逐坐标扰动、多个步长和折点,做一张能解释通过与失败的诊断表。

展开本文目录
  1. 下载完整练习包
  2. 先准备一个能独立展开的正确答案
  3. 两个参数一起动,错误可能互相抵消
  4. 分开动两个坐标,漏洞就出现了
  5. 步长太大,检查到的是较宽区间的变化
  6. 步长太小,减法可能把有效数字一起减掉
  7. 到了折点,中心差分的平均值不是导数
  8. 运行三张表,保留失败发生的条件
  9. 给核验结论写上输入、精度与覆盖范围
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 知道导数描述局部变化率
  • 会对简单多项式求导
  • 能运行Python标准库脚本

你把参数稍微改一点,比较损失怎样变化,结果与反向代码对上了。这是好证据,但还要说明改了哪些参数、往哪个方向改、步长多大。两个参数的梯度分别错了一点,合起来检查时可能刚好抵消。

本篇做三张诊断表:第一张让错误梯度蒙混通过一个方向;第二张观察步长变小后误差先改善、后来又恶化;第三张说明不可导点为何不该硬判成普通梯度错误。全部使用标准库,没有调用自动微分框架或训练模型。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • gradient_diagnostics.py:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的实际输出
  • verification.json:源码与产物验证记录
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B gradient_diagnostics.py

解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。多项式、浮点差分和 ReLU 的小规模检查;不执行自动微分框架或训练模型。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

先准备一个能独立展开的正确答案

这里每次只改变一个参数,得到的局部变化率叫偏导;把各个参数的偏导排成一列,就是梯度。

取f(x,y)=x²+3xy+2y²,在x=1、y=2时输出15。手工求偏导得到[2x+3y,3x+4y],所以正确梯度是[8,11]。程序另放一份故意错误的候选[9,10]:第一个多1,第二个少1,前向公式保持不变。

不能只让同一份求导函数拿自己的结果检查自己。我们独立展开前向:f(1+t,2)=15+8t+t²,f(1,2+t)=15+11t+2t²。系数8和11直接来自多项式展开,既可手算,也可代入精确分数验证,为后面的差分与候选梯度提供独立参照。

“前向相同”并不意味着候选梯度也相同。在这个实验里,两个候选共用同一个前向函数,只有返回的导数列表不同,因此差异来源非常明确。若真实项目的前向也在改动,先冻结一个可手算的小输入,再比较反向结果,才不会把两种变化混在一起。

两个参数一起动,错误可能互相抵消

点积就是把两列数对应相乘再相加;本例 [8,11] 与 [1,1] 的点积为 8×1+11×1。

沿方向[1,1]移动,就是同时把x、y都加t。正确梯度在这个方向的变化率为8+11=19,错误候选也给出9+10=19。中心差分比较f(x+hd)与f(x−hd),再除以2h;对本例二次多项式,它也精确得到19,所以错误梯度通过了这次检查。

这里的方向向量没有归一化,t表示沿[1,1]整体移动多少,而不是移动的几何距离。若先把方向缩成单位长度,数值会相应变化,但两个误差相互抵消的事实仍在。方向检查核对的是梯度与方向的点积,单个点积相等不推出每个坐标都相等。

把误差列表单独写成[1,−1],就能一眼看到原因:它与[1,1]的点积为零。所谓漏检,不是差分没有算准,而是我们选择的观察方式把错误藏起来了。每次都用同一个全一方向重跑一百次,也不会增加新的方向信息;重复次数不能补上观察角度的缺失。

分开动两个坐标,漏洞就出现了

只沿[1,0]移动,中心差分得到8,错误候选给9;只沿[0,1]移动,中心差分得到11,候选给10。两项误差分别为1和−1,立刻定位到问题。程序使用1/1000步长和精确分数,排除了这组结果由浮点容差造成的可能。

这不是说随机方向检查没有价值。它把很多坐标压成少量检查,能节省前向计算;框架也会使用这种办法。反例只说明固定选择一个方向可能漏错。真正发现差异后,逐坐标检查更便于定位;对于这个二次函数,坐标差分精确,但一般函数仍有近似误差。

步长太大,检查到的是较宽区间的变化

再换成一维函数g(x)=x³,检查x=1处的导数3。把(1+h)³与(1−h)³展开,中心差分恰好为3+h²。因此即使用无限精确的算术,只要h不为零,差分仍与导数相差h²。这叫截断误差,是用有限区间代替极限带来的。

本例从0.1到10的负16次方试16个步长,CSV同时记录实际浮点差分和精确公式3+h²。这样可以分别问:有限步长本身带来多少误差,实际计算又偏离这个公式多少。不能一看差分与3不一样,就断定导数3写错了。

步长太小,减法可能把有效数字一起减掉

Python浮点只保存有限精度。h很小时,1+h与1−h可能已经贴近可表示数的间隔;两次立方结果又十分接近,相减会暴露舍入影响,最后还要除以很小的2h。缩小步长不再保证更接近3,甚至可能变差。

脚本不预先指定哪个步长必定最好,而是记录这次运行观察到的最小误差及其步长,并检查中间至少有一项优于两端。这个最佳值只属于该函数、该点和本次运行;换数值规模、精度或实现,误差曲线可能改变。真实核验应保留步长扫描,别为了得到PASS无限放宽容差。

误差大小还要结合参照值解释。正确导数非常接近零时,仅看相对误差可能被很小的分母放大;导数本身很大时,固定的绝对误差又可能过于苛刻。因此报告应先保留原数值与绝对差,再声明怎样结合绝对容差和相对容差。本文精确分数部分直接判断相等,浮点扫描只展示误差,不设通用合格线。

到了折点,中心差分的平均值不是导数

令r(x)=max(0,x)。在0左边,函数一直为0,左侧斜率为0;右边沿x增长,右侧斜率为1。两个方向没有共同的极限,所以0处不存在普通导数。中心差分却始终给出1/2,因为它把两侧变化合在一起。

若某个反向实现对这个折点选取0作为运算约定,而差分给1/2,两者不同并不自动说明实现坏了。应先把检查点归类为不可导,另外核对实现声明的约定。本文不替PyTorch或其他框架执行这个算子,只用分数分别计算−1、0、1三处的左右斜率。

在负一和正一附近,当前步长不会跨过折点,左右斜率分别一致为零和一。表中的这两行与零点一起出现,可以确认问题来自具体检查位置,而不是把整个分段函数都判成无法核验。一般函数的左右有限差分未必精确代表极限;本例能这样判断,是因为两侧各自就是直线。

运行三张表,保留失败发生的条件

gradient-directions.csv有6条记录,分别对应三个方向与两份候选梯度;gradient-step-sweep.csv有16条步长记录;gradient-kinks.csv有三个位置。gradient-report.json保存手算参照、错误候选、诊断结果和浮点扫描摘要。错误候选的一次通过也是预期观测,不能删掉。

先独立核对二次多项式展开,再检查方向表中的点积;用3+h²核对步长表;最后比较折点表的左右列。程序打印PASS,表示它成功复现了这些预定诊断现象,其中包括错误梯度通过单方向检查,并不是宣布错误梯度正确。

展开收起代码 · 小实验python · 92 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Small gradient diagnostics: polynomial oracle, floating difference, and a kink."""
import csv
import json
import math
from fractions import Fraction as F
from pathlib import Path


def polynomial(point):
    x, y = point
    return x * x + 3 * x * y + 2 * y * y


def proposed_gradient(point, bug=False):
    x, y = point
    correct = (2 * x + 3 * y, 3 * x + 4 * y)
    return (correct[0] + 1, correct[1] - 1) if bug else correct


def directional_difference(function, point, direction, step):
    if step <= 0 or len(point) != len(direction):
        raise ValueError("positive step and matching dimensions required")
    plus = tuple(x + step * d for x, d in zip(point, direction))
    minus = tuple(x - step * d for x, d in zip(point, direction))
    return (function(plus) - function(minus)) / (2 * step)


def dot(first, second):
    return sum(a * b for a, b in zip(first, second))


def main():
    point = (F(1), F(2))
    h = F(1, 1000)
    directions = {"both": (F(1), F(1)), "x_only": (F(1), F(0)), "y_only": (F(0), F(1))}
    # Independent hand expansion: f(1+t,2)=15+8t+t*t; f(1,2+t)=15+11t+2*t*t.
    assert polynomial(point) == 15
    assert polynomial((1 + h, F(2))) == 15 + 8 * h + h * h
    assert polynomial((F(1), 2 + h)) == 15 + 11 * h + 2 * h * h
    exact_rows = []
    for name, direction in directions.items():
        numerical = directional_difference(polynomial, point, direction, h)
        for bug in (False, True):
            claimed = dot(proposed_gradient(point, bug), direction)
            exact_rows.append({"direction": name, "gradient": "buggy" if bug else "correct",
                               "numerical": str(numerical), "claimed": str(claimed),
                               "error": str(claimed - numerical), "passed": claimed == numerical})
    assert [r["passed"] for r in exact_rows if r["gradient"] == "buggy"] == [True, False, False]
    assert all(r["passed"] for r in exact_rows if r["gradient"] == "correct")
    # At x=1, d(x^3)/dx=3, while the exact central difference is 3+h^2.
    steps = [10.0 ** (-power) for power in range(1, 17)]
    sweep = []
    for step in steps:
        numerical = ((1.0 + step) ** 3 - (1.0 - step) ** 3) / (2 * step)
        sweep.append({"step": step, "numerical": numerical, "absolute_error": abs(numerical - 3.0),
                      "exact_formula": str(3 + F(str(step)) ** 2),
                      "exact_truncation_error": str(F(str(step)) ** 2)})
    assert all(math.isfinite(r["numerical"]) for r in sweep)
    assert min(r["absolute_error"] for r in sweep) < sweep[0]["absolute_error"]
    assert min(r["absolute_error"] for r in sweep) < sweep[-1]["absolute_error"]
    relu = lambda x: max(F(0), x)
    kink = []
    for x in (F(-1), F(0), F(1)):
        left = (relu(x) - relu(x - h)) / h
        right = (relu(x + h) - relu(x)) / h
        central = (relu(x + h) - relu(x - h)) / (2 * h)
        kink.append({"x": str(x), "left_slope": str(left), "right_slope": str(right),
                     "central": str(central), "differentiable_here": left == right})
    assert kink[1] == {"x": "0", "left_slope": "0", "right_slope": "1",
                       "central": "1/2", "differentiable_here": False}
    assert kink[0]["central"] == "0" and kink[2]["central"] == "1"
    best = min(sweep, key=lambda row: row["absolute_error"])
    report = {"scope": "Hand-written polynomial gradient; exact directional and kink checks; binary64 step sweep. No autograd library or training.",
              "point": ["1", "2"], "hand_gradient": ["8", "11"], "bug_gradient": ["9", "10"],
              "exact_step": str(h), "direction_checks": exact_rows, "relu_checks": kink,
              "float_sweep": {"rows": len(sweep), "best_observed_step": best["step"],
                              "best_error": best["absolute_error"], "first_error": sweep[0]["absolute_error"],
                              "last_error": sweep[-1]["absolute_error"],
                              "note": "Best step belongs only to this function, point, and runtime."}}
    Path("gradient-report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
    for filename, rows in (("gradient-directions.csv", exact_rows), ("gradient-step-sweep.csv", sweep), ("gradient-kinks.csv", kink)):
        with Path(filename).open("w", newline="", encoding="utf-8") as handle:
            writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
            writer.writeheader()
            writer.writerows(rows)
    print(json.dumps({"status": "PASS", "direction_rows": len(exact_rows), "step_rows": len(sweep),
                      "kink_rows": len(kink), "bug_passes_both_direction": True,
                      "relu_at_zero": "not_differentiable"}))


if __name__ == "__main__":
    main()

怎样运行:Python 3.10+标准库;保存为gradient_diagnostics.py,在空练习目录运行 python gradient_diagnostics.py。不要加-O,验收使用assert。 2026-10-15 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。多项式、浮点差分和 ReLU 的小规模检查;不执行自动微分框架或训练模型。

运行后对照结果
{"status": "PASS", "direction_rows": 6, "step_rows": 16, "kink_rows": 3, "bug_passes_both_direction": true, "relu_at_zero": "not_differentiable"}

给核验结论写上输入、精度与覆盖范围

PyTorch 2.9的gradcheck机制文档区分完整雅可比检查与随机方向检查;接口文档提醒默认容差针对双精度,且不可导点可能让数值与解析结果不同。JAX的check_grads文档也明确说明使用随机方向降低差分成本。这些说明帮助理解检查的对象,本实验没有调用这两个框架。

一份可用报告应保留被检查的函数、输入点、候选梯度、方向、步长、精度与误差。对于平滑点,多个方向和多个输入能扩大覆盖;对于折点,应单独说明数学定义和实现约定。即使所有选定点都通过,也只能说明这些条件下相符,不能替代整个定义域的证明或实际训练验证。

最后还要保留任务本身的检查。梯度可以准确对应一个写错的损失:例如本应求两项平均,前向却求了总和,导数仍可能与这个错误前向完全一致。差分核验回答的是“候选导数是否跟当前函数相符”,损失定义、标签对应和业务目标还需要各自的证据。

想接着往下学,可以阅读自动微分与梯度核验路线。把本篇数值诊断接到实际框架的反向传播与 gradcheck 中。

记住这些要点

  • 一个方向的点积相等,不能推出所有梯度坐标相等。
  • 用手工展开建立独立参照,避免同一段梯度代码自证。
  • 有限步长有截断误差,过小步长又可能放大浮点误差。
  • 不可导点应独立分类,中心差分的平均值不一定是导数。

别把例子用过头

  • 只检查固定的全一方向,忽略坐标误差抵消。
  • 用候选梯度函数本身生成全部正确答案。
  • 步长越小就默认越准,失败就无限放宽容差。
  • 把含预期失败的诊断脚本PASS读成所有梯度都正确。

自己试一小步

你能做一份能区分实现错误与检查条件问题的梯度报告吗?

  1. 自行选一个三变量平滑函数,写出独立手算梯度,并故意在两个坐标加入相反误差。
  2. 设计一个会漏掉错误的方向和至少三个能分别检查坐标的方向,用精确分数验证可精确处理的部分。
  3. 对一个非二次函数扫描多个步长,再加入一个分段函数的折点与平滑点。
  4. 保存原始表和结论,逐项标出真正检出的错误、方向漏检、数值误差以及不适用普通导数的位置。

做到这些,就算完成

  • 正确参照至少有一种不调用候选梯度的独立推导。
  • 保留所有预先选定的方向与步长,不只提交通过项。
  • 结论明确本次没有证明什么,也没有把检查结果当成训练效果。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。