读完这篇,你能做什么
能手推共享节点的梯度,运行 PyTorch gradcheck,并用完整 Java 标量 DAG 检查按依赖逆序传播的结果。
学习理由、准备要求与知识点
为什么学它
损失能算出来,不代表梯度就正确。看懂计算图和累积规则,才能排查参数不更新、重复反向和异常梯度。
先读这些内容
知识点清单
- 反向模式自动微分
- 局部导数与向量雅可比积
- 共享节点梯度求和
- 叶子与非叶子张量
- 梯度累积和图生命周期
- detach 与
no_grad - 双精度 gradcheck
- 有限差分适用边界
运行前先确认Java 17;Python 3.14、PyTorch 2.9.1+cpu。Python 实验实际执行 CPU autograd 和 gradcheck;Java 为原创标量 DAG 教学引擎,不是 PyTorch C++ 引擎实现。
01先弄懂它是什么
训练需要知道每个参数会怎样影响最终损失,逐个手算很麻烦。自动微分会记录程序实际执行的基本运算,再用每一步已知的导数和链式法则算出结果。它不是先把整个公式整理成符号表达式,也不是给每个参数加一点变化来估算梯度。对于一个标量损失和许多参数,反向模式可以从损失往回计算,避免每求一个参数的梯度都重新跑完整个前向过程。
一个输入可能通过多条路线影响输出,这些影响都要算上。例如 u=x*y+x,L=u*u;x 既参与乘法,也直接参与加法,u 又参与了平方的两个因子。计算图用节点记录中间值,用连线记录谁依赖谁。反向传播时,同一个节点从不同路线收到的导数贡献必须相加;如果后来的值覆盖了先来的值,即使前向算出的答案对了,梯度仍会错。
张量可能一次输出很多数。反向传播通常不必把“每个输出对每个输入的导数”全部铺成一张大表,这张表叫雅可比矩阵;它可以直接计算我们需要的加权组合,也就是向量雅可比积。标量损失对自身的导数是一,因此从一开始往回传。
若输出是 a、b 两个数,就要先说明想求哪种组合:例如给外部梯度 [2,3],对应计算 2a+3b 对输入的导数。非标量输出仅靠一次不加说明的 backward,并不会自动确定唯一的标量训练目标。
文中这几个词,先认一认
- 局部导数
- 只看当前这一步基本运算,输出对直接输入的变化率。
- 伴随量
- 最终那个标量输出对某个中间值的导数,表示这个中间值改变一点会怎样影响最终结果。
- 叶子张量
- 在
PyTorch中,没有被自动微分记录为某个运算结果的张量。例如直接创建的参数通常是叶子,requires_grad=False 的张量也按约定算叶子。需要梯度且参与反向计算的叶子,默认把梯度累积到 .grad。 - 梯度校验
- 把自动求出的梯度与小幅改变输入得到的数值近似作比较,在指定点和误差范围内检查实现是否有问题。
02看看它怎么工作
在 x=3、y=2 时,u=9、L=81。先得到 dL/du=2u=18,再得到 du/dx=y+1=3、du/dy=x=3,所以两个输入梯度都为五十四。这个例子同时测试共享输入和重复使用中间节点,能发现把边误去重或把 += 写成赋值的缺陷。
反向传播必须等一项中间结果从所有使用它的地方收齐贡献,再继续往前传。为此先按依赖整理节点:被使用的值排在前,使用它的运算排在后,这叫拓扑顺序;反向时倒着走,就是逆拓扑顺序。例如 u*u 中只有一个 u 节点,却有左右两个使用位置。遍历时这个节点只需处理一次,但两处用法各自带来的导数都要保留并相加。去掉重复访问的节点,不等于删掉重复使用产生的贡献。
PyTorch 的叶子 .grad 默认累积。重新构建同一前向式再 backward,若没有清零,梯度会继续相加;非叶子中间值通常不会默认保留 .grad,观察它需要 retain_grad。为反向计算保存的部分中间数据通常在使用后释放,因此重复反向与重建前向、retain_graph 之间要有明确选择。
detach 产生与原计算历史断开的张量,通常仍共享底层存储;它不是复制数据的同义词。no_grad 让其中大多数运算不记录梯度历史,常用于推理或参数更新。model.eval 主要切换部分模块行为,并不等价于关闭自动微分;混淆这些概念会造成训练断图或额外内存消耗。
gradcheck 用双精度输入更容易比较有限差分和自动微分。差分步长过大有截断误差,过小有浮点抵消;不可导点、随机运算、重叠存储视图和非有限计算都会影响结果。通过某几个点只说明这些输入和容差下相符,不是对整个算子定义域的数学证明。
先跑通一个最小例子
先完成第 13 篇“从数组到 Tensor”的隔离环境安装。使用其 Python 3.14、PyTorch 2.9.1+cpu 解释器运行 experiment.py,例如 .\.venv\Scripts\python.exe experiment.py。标量 Tensor 的 shape 为 [];requires_grad 开启记录,backward 传播梯度。
import torch
torch.set_num_threads(1)
def f(x,y):
u=x*y+x
return u*u
x=torch.tensor(3.,dtype=torch.float64,requires_grad=True)
y=torch.tensor(2.,dtype=torch.float64,requires_grad=True)
u=x*y+x
u.retain_grad() # 显式观察非叶子梯度。
loss=u*u
loss.backward()
assert loss.item()==81.
assert x.grad.item()==54.
assert y.grad.item()==54.
assert u.grad.item()==18.
f(x,y).backward() # 重建前向,但叶子已有梯度仍会累积。
assert x.grad.item()==108.
x.grad=None; y.grad=None
f(x,y).backward()
assert x.grad.item()==54.
assert torch.autograd.gradcheck(f,(x,y),eps=1e-6,atol=1e-5)
with torch.no_grad():
z=x*y
assert not z.requires_grad
detached=x.detach()
assert not detached.requires_grad
print("PASS 9; shared-node gradients =",(x.grad.item(),y.grad.item()))
核对运行结果打印 PASS 9;输入梯度为 (54,54),未清零重做前向后得到 108,gradcheck 返回真。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart LR
X["x"] --> M["x 乘 y"]
Y["y"] --> M
X --> U["u 等于 x乘y 加 x"]
M --> U
U --> L["L 等于 u乘u"]
L --> R["逆拓扑累加每条边贡献"]
R --> G["输入梯度"]按顺序看,每一步做了什么
- 列出前向运算与需保存的局部值。
- 手算一个包含共享节点的参考例。
- 逆拓扑传播并累加各边贡献。
- 明确每轮叶子梯度的清零策略。
- 用双精度 gradcheck 检查平滑输入。
03对照实现代码
PyTorch v2.9.1;torch/autograd/graph.py · _engine_run_backward
已核对固定版本 Python 桥接函数;以下为原创图传播数学片段,不是该桥接函数的逐行翻译,也不代表 PyTorch C++ 引擎的调度实现。
def propagate(parent_grad,output_grad,local_derivative):
# 原创单条边模型:共享节点必须累加,不能覆盖。
contribution=output_grad*local_derivative
return parent_grad+contribution
# 真实框架还管理张量、保存值、版本检查和设备执行。
_engine_run_backward 的关键调用是 Variable._execution_engine.run_backward,代码注释明确其进入 C++ 引擎。因此这个 Python 函数是桥接入口,不能把几十行 Python 当作整个反向调度器,也不能据此声称真实框架采用本文 Java 的遍历容器。
桥接函数根据日志等级决定是否注册整图日志钩子,在 try 中执行引擎调用,并在 finally 中移除钩子。这解释了可选诊断逻辑的清理责任:即使反向抛异常,也应该执行该次安装的钩子清理。
输出节点和其他位置、关键字参数被继续传给执行引擎,函数返回引擎结果。图保留、输入选择和梯度种子等含义需要连同公开 API 阅读,不能只凭这个转发函数推断所有默认行为;本文实验使用最简单的标量反向路径。
原创 Java 实现仅用不可变前向值、局部权重和图身份演示链式法则,不包含 Tensor 广播、原地修改版本检查、钩子或设备流。Python 实验调用的则是真实 PyTorch autograd,两类证据分别验证数学教学模型与固定环境的公开 API 行为。
代码先调用谁,再调用谁
- 标量
Tensor.backward进入 autograd _engine_run_backward安装可选日志钩子- 调用 C++
execution_engine.run_backward - finally 清理日志钩子
04动手写一个例子
共享标量 DAG 的逆拓扑自动微分
构造由有限标量加法与乘法组成的不可变 DAG,对一个输出求所有可达输入导数;每次 backward 重置可达节点梯度。本实现不支持修改前向值后复用图。
- 输入 1
L=(x*y+x)^2,x=3,y=2- 输出 1
L=81,dx=54,dy=54
- 输入 2
L=x*x,x=-3- 输出 2
dx=-6
每条边保存父节点与当前前向点的局部导数。加法两条边权重都是一,乘法对左输入的局部导数为右输入值,反之亦然。即使两个输入引用相同对象,也创建两条边,正确表达平方中的两次使用。
使用显式栈生成拓扑列表,并按对象身份去重节点,避免共享子图被反复展开。依赖节点先入结果列表,输出后入;倒序扫描时先处理输出,再把完整伴随量乘以局部导数累加到父节点。显式栈避免深图直接消耗 Java 调用栈。
图只通过已有节点创建新节点,边列表不可变,所以合法构造流程不会产生环。每次 backward 清零本次可达节点,再将输出梯度设为一;这与 PyTorch 默认叶子累积语义不同,代码明确选择方便独立核验的重置契约。未参与本图的节点不被触碰。
为什么这样写是对的倒序传播某节点时,该节点已收到所有可达下游使用者的贡献;每条依赖边恰好贡献一次。
身份集合让每个节点只展开一次,每条边在建序和反向各处理常数次。重复使用保留边,但不会重复展开整棵共享子图。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
record Edge(Node parent,double local){}
static final class Node {
final double value; final List<Edge> edges; double grad;
Node(double value,Edge... edges){
if(!Double.isFinite(value))throw new IllegalArgumentException("finite value");
this.value=value;this.edges=List.of(edges);
}
}
record Frame(Node node,boolean expanded){}
static Node add(Node a,Node b){return new Node(a.value+b.value,new Edge(a,1),new Edge(b,1));}
static Node mul(Node a,Node b){return new Node(a.value*b.value,new Edge(a,b.value),new Edge(b,a.value));}
static void backward(Node output){
Set<Node> seen=Collections.newSetFromMap(new IdentityHashMap<>());
List<Node> order=new ArrayList<>();Deque<Frame> stack=new ArrayDeque<>();
stack.push(new Frame(output,false));
while(!stack.isEmpty()){
Frame f=stack.pop();
if(f.expanded){order.add(f.node);continue;}
if(!seen.add(f.node))continue; // 节点去重,但不删除其重复依赖边。
stack.push(new Frame(f.node,true));
for(Edge e:f.node.edges)stack.push(new Frame(e.parent,false));
}
for(Node n:order)n.grad=0;
output.grad=1; // 本模型每次重置,再给标量输出播种一。
for(int i=order.size()-1;i>=0;i--){
Node n=order.get(i);
for(Edge e:n.edges){
double next=e.parent.grad+n.grad*e.local; // 多条路径必须累加。
if(!Double.isFinite(next))throw new ArithmeticException("gradient range");
e.parent.grad=next;
}
}
}
static void near(double a,double b){if(!Double.isFinite(a)||Math.abs(a-b)>1e-12)throw new AssertionError(a+" != "+b);}
public static void main(String[] args){
Node x=new Node(3),y=new Node(2),u=add(mul(x,y),x),loss=mul(u,u);
backward(loss);near(loss.value,81);near(x.grad,54);near(y.grad,54);near(u.grad,18);
backward(loss);near(x.grad,54); // 验证重置语义,不默默累积两倍。
Node q=new Node(-3);backward(mul(q,q));near(q.grad,-6);
Node z=new Node(0);backward(mul(z,z));near(z.grad,0);
Node shared=new Node(5);backward(add(shared,shared));near(shared.grad,2);
Node untouched=new Node(9);backward(loss);near(untouched.grad,0);
System.out.println("PASS 6");
}
}这些特殊情况也要试一试
- 重复边代表不同使用位置,不能删除。
- 本引擎重置可达梯度,
PyTorch默认累积叶子梯度。 - 图值不可修改后复用。
- 有限前向值不保证所有反向中间量有限。
进阶选读 · 自定义算子与向量雅可比积
场景封装新算子后需要验证梯度和内存行为。
做法先推导局部反向公式,再对平滑双精度小输入做 gradcheck,加入广播、共享输入与边界测试;高阶需求另测 gradgradcheck。
复杂度完整数值雅可比通常需多次前向,成本随输入输出维度增长。
好处与代价数值检查适合小规模验证,不能替代推导,也不应在每个训练步执行。
05项目里怎么用
排查断图时,从参数到损失逐段检查 requires_grad、grad_fn 与实际参与路径。把中间张量转换为普通数值再重新创建 Tensor,会丢失原历史;使用 detach 也会有意切断路径。应记录需要学习的参数,而不是盲目给所有数据都开启梯度。
先规定“收集几批梯度,再更新一次参数”。把一大批拆成若干小批分别计算,这些小批叫微批次;有意累积时,不能每算完一个就把之前的梯度清空。另一方面,若每次反向后都保留整张计算图,又会多占内存。清零时机、保留图、损失缩放和更新频率必须一起设计。模型的评估模式与是否记录梯度也要分别设置,并检查实际张量状态,不能只看到 eval 就认定梯度记录已经关闭。
自动微分会按记录的运算规则求梯度,但不会替你判断前向计算是不是在解决正确的任务。比如维度被错误广播、分母为零、标签错了,或先算出 NaN、无穷再试图遮住,都可能留下错误的梯度路径。先保证计算本身在数学上合法,再用手算例子、数值差分和实际训练表现互相核对。
容易踩的坑
- 共享输入的梯度贡献用赋值覆盖。
- 把
model.eval当作关闭梯度记录。 - 把 detach 当作独立数据复制。
- 在不可导点或低精度输入上盲信 gradcheck 结果。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 手推局部梯度 | 含义明确,可发现公式错误 | 复杂图容易漏路径 | 小模型与自定义算子设计 |
| 自动微分 | 高效组合基本运算 | 需要图和保存值内存 | 真实训练 |
| 有限差分 | 独立于手写反向实现 | 成本高且受步长误差影响 | 小规模梯度核验 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01共享节点为什么要累加?
展开参考答案与推导
最终输出可通过多个使用路径依赖同一个节点,多元链式法则要求把各路径贡献相加。节点身份唯一不意味着只存在一条导数贡献。
02自动微分等于有限差分吗?
展开参考答案与推导
不是。自动微分组合已知局部导数,有限差分通过扰动输入近似导数;后者在这里用作独立核验。
03Java 模型与 PyTorch 清零规则相同吗?
展开参考答案与推导
不同。Java 每次重置可达节点,PyTorch 默认向叶子 .grad 累积;文章通过重复前向实验显式展示区别。
换个情况,还会处理吗
01第二次重新前向后梯度翻倍,先查什么?
展开参考答案与推导
检查叶子 .grad 是否在预期更新边界清零。重建计算图不会自动清除已有叶子梯度,翻倍可能正是默认累积行为。
02gradcheck 在 ReLU 零点失败就代表实现错吗?
展开参考答案与推导
不一定。零点不可导,数值中心差分与框架选定的反向约定可能不一致,应先避开不可导点并单独核对边界规则。
03前向值有限但梯度 NaN 怎样排查?
展开参考答案与推导
检查完整运算路径是否曾除零或产生非有限中间值,也检查被掩码掉的分支。仅观察最终标量有限不能保证反向路径安全。
写代码试一试
01手算共享输入梯度。
- 输入 / 条件
L=(x*y+x)^2,x=3,y=2- 输出 / 预期
dx=54,dy=54
提示 先求dL/du=18。
展开参考答案与推导
x 的两个路径局部贡献为 y 与一,所以十八乘三为五十四;y 路径局部导数为 x,也得到五十四。
02验证重复边。
- 输入 / 条件
L=x*x,x=-3- 输出 / 预期
dx=-6
提示 两条边各贡献-3。
展开参考答案与推导
乘法的两个输入位置都引用 x,但两条局部边都必须传播,合计负六;按父节点删除一条边会错误得到负三。
03设计可重复梯度检查。
- 输入 / 条件
平滑函数和double输入- 输出 / 预期
解析与数值梯度在容差内相符
提示 固定随机性并避免重叠视图。
展开参考答案与推导
用独立双精度叶子、小规模平滑值和明确 eps、atol 执行 gradcheck,再补不同数值及边界;通过有限测试不宣称全域证明。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能手推共享节点的梯度,运行 PyTorch gradcheck,并用完整 Java 标量 DAG 检查按依赖逆序传播的结果。
本篇重点加练
向量雅可比积与梯度截断决定优化的实际目标,需检验前向相同但梯度不同的程序。
- 局部导数与向量雅可比积
- detach 与
no_grad
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01代码推演
对 a=x*y、b=a+x、L=b²+a,在 x=2、y=3 处手算反向传播,标明每个中间节点收到的梯度贡献。
- 提交内容
- 计算图与梯度表
- 怎样算完成
- 多路径贡献齐全
- 局部导数与累计梯度分开
练到的知识点反向模式自动微分 · 局部导数与向量雅可比积 · 共享节点梯度求和
- 作业 02原理问答
同一个叶张量参与两次独立前向与一次复用前向,分别定义期望的梯度读取时机和图生命周期;说明哪些行为需要显式声明。
- 提交内容
- 三个执行流程图
- 怎样算完成
- 叶子非叶子区分
- 图复用和梯度累积不混用
练到的知识点叶子与非叶子张量 · 梯度累积和图生命周期
- 作业 03原理问答
对
f(x1,x2)=(x1x2,x1²+x2),在自选非退化点推导给定上游向量 v 的反向结果;解释改变 v 时输出梯度代表什么标量目标。- 提交内容
- 雅可比、上游向量与目标说明
- 怎样算完成
- 输入输出维度一致
- 上游梯度来源明确
练到的知识点局部导数与向量雅可比积
综合应用
- 作业 04编程实践
实现自定义可微函数
f(x,y)=sin(x*y)+x²,在 CPU 双精度上检验前向及反向;测试多个尺度和不对称输入。- 提交内容
- 实现、gradcheck 与独立数值记录
- 怎样算完成
- 梯度来源可追踪
- 检查容差和数据类型记录
练到的知识点局部导数与向量雅可比积 · 双精度 gradcheck · 有限差分适用边界
- 作业 05排错实验
在同一小计算图中分别加入 detach、
no_grad和遗漏清零,保存每个版本的前向值与叶梯度;判断错误是否一定能从损失值看出。- 提交内容
- 三个故障实验与比较表
- 怎样算完成
- 每次只注入一项变化
- 前向和反向分别验收
练到的知识点梯度累积和图生命周期 · detach 与 no_grad
- 作业 06面试场景
某自定义反向在随机检查中通过,但训练时出现异常。设计覆盖重复输入、共享子图、极端尺度及不可导点的追加测试,并划定数值检查局限。
- 提交内容
- 针对性测试集
- 怎样算完成
- 测试针对不同失效机制
- 不可导点标准独立
练到的知识点局部导数与向量雅可比积 · 共享节点梯度求和 · 有限差分适用边界
- 作业 07编程实践
在 CPU 双精度上比较 L=(x−target)² 中 target=x
/2与 target=detach(x/2),记录前向、梯度及一步更新;不把两个程序默认视为同一优化目标。- 提交内容
- 两程序及目标审计
- 怎样算完成
- 前向与梯度分别记录
- 截断位置可追踪
练到的知识点局部导数与向量雅可比积 · detach 与 no_grad
- 作业 08设计评审
教师输出参与学生损失时,自行定义固定教师与联合训练两种任务,画梯度允许流向的参数集合并制定梯度归属测试。
- 提交内容
- 两份梯度契约和测试清单
- 怎样算完成
- 训练目标先于 API 选择
- 应更新和不应更新参数明确
练到的知识点detach 与 no_grad
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
为含循环复用参数的标量程序设计简易自动微分实验,对比保存全部中间值和重新计算中间值的成本,测量 CPU 小规模实例。
- 提交内容
- 原型与成本报告
- 怎样算完成
- 梯度等价性独立校验
- 时间和存储口径明确
练到的知识点反向模式自动微分 · 局部导数与向量雅可比积 · 梯度累积和图生命周期
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 反例构造
构造两个对所有给定输入前向值相同、自动求导梯度却不同的表达式;分析差异来自数学函数本身还是计算图中人为设定的求导规则。
- 提交内容
- 表达式、CPU 核验及解释
- 怎样算完成
- 前向等价范围明确
- 梯度规则差异有证据
练到的知识点局部导数与向量雅可比积 · detach 与 no_grad
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
PyTorch v2.9.1:Python 反向入口如何进入 C++ 引擎 (新标签页)↗
核对 Python 入口到 C++ 引擎的真实调用边界和日志钩子清理。
- 深入参考文档英文
PyTorch 2.9 Autograd mechanics (新标签页)↗
阅读动态图、叶子累积、保存张量与梯度模式。
- 深入参考文档英文
PyTorch 2.9 Gradcheck mechanics (新标签页)↗
核对数值雅可比、双精度假设和检查范围。