开始前,会这些就够了
- 知道导数描述局部变化率
- 会对简单多项式求导
- 能运行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,表示它成功复现了这些预定诊断现象,其中包括错误梯度通过单方向检查,并不是宣布错误梯度正确。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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读成所有梯度都正确。
自己试一小步
你能做一份能区分实现错误与检查条件问题的梯度报告吗?
- 自行选一个三变量平滑函数,写出独立手算梯度,并故意在两个坐标加入相反误差。
- 设计一个会漏掉错误的方向和至少三个能分别检查坐标的方向,用精确分数验证可精确处理的部分。
- 对一个非二次函数扫描多个步长,再加入一个分段函数的折点与平滑点。
- 保存原始表和结论,逐项标出真正检出的错误、方向漏检、数值误差以及不适用普通导数的位置。
做到这些,就算完成
- 正确参照至少有一种不调用候选梯度的独立推导。
- 保留所有预先选定的方向与步长,不只提交通过项。
- 结论明确本次没有证明什么,也没有把检查结果当成训练效果。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- PyTorch 2.9:Gradcheck mechanics (新标签页)↗
中心差分构造雅可比与快速检查中的方向投影。
- PyTorch 2.9:torch.autograd.gradcheck (新标签页)↗
双精度默认容差和不可导点的检查限制。
- JAX:jax.test_util.check_grads (新标签页)↗
随机方向核验是控制差分成本的一种设计。