qinghuan
← 神经网络与自动微分 / 全部笔记

单元 14 / 神经网络与自动微分

P0 · 必会主线L1 · 基础

自动微分、反向传播与梯度检查

从一个输入经多条路线影响输出的例子出发,看反向传播怎样累加梯度,再学习叶子梯度、计算图何时保留或释放、detach 和双精度核验。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能手推共享节点的梯度,运行 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 传播梯度。

experiment.py · python
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["输入梯度"]

按顺序看,每一步做了什么

  1. 列出前向运算与需保存的局部值。
  2. 手算一个包含共享节点的参考例。
  3. 逆拓扑传播并累加各边贡献。
  4. 明确每轮叶子梯度的清零策略。
  5. 用双精度 gradcheck 检查平滑输入。

03对照实现代码

PyTorch v2.9.1:Python 反向入口如何进入 C++ 引擎 (新标签页)↗

PyTorch v2.9.1;torch/autograd/graph.py · _engine_run_backward

已核对固定版本 Python 桥接函数;以下为原创图传播数学片段,不是该桥接函数的逐行翻译,也不代表 PyTorch C++ 引擎的调度实现。

核心机制代码 · python
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 行为。

代码先调用谁,再调用谁

  1. 标量 Tensor.backward 进入 autograd
  2. _engine_run_backward 安装可选日志钩子
  3. 调用 C++ execution_engine.run_backward
  4. 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 默认叶子累积语义不同,代码明确选择方便独立核验的重置契约。未参与本图的节点不被触碰。

为什么这样写是对的倒序传播某节点时,该节点已收到所有可达下游使用者的贡献;每条依赖边恰好贡献一次。

时间 O(V+E),V 为可达节点数,E 为依赖边数。额外空间 O(V+E),包含图与遍历栈;额外遍历结构最坏 O(V+E)。

身份集合让每个节点只展开一次,每条边在建序和反向各处理常数次。重复使用保留边,但不会重复展开整棵共享子图。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 47 行

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

Main.java · 完整可运行程序 · Java
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

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    对 a=x*y、b=a+x、L=b²+a,在 x=2、y=3 处手算反向传播,标明每个中间节点收到的梯度贡献。

    提交内容
    计算图与梯度表
    怎样算完成
    • 多路径贡献齐全
    • 局部导数与累计梯度分开

    练到的知识点反向模式自动微分 · 局部导数与向量雅可比积 · 共享节点梯度求和

  2. 作业 02原理问答

    同一个叶张量参与两次独立前向与一次复用前向,分别定义期望的梯度读取时机和图生命周期;说明哪些行为需要显式声明。

    提交内容
    三个执行流程图
    怎样算完成
    • 叶子非叶子区分
    • 图复用和梯度累积不混用

    练到的知识点叶子与非叶子张量 · 梯度累积和图生命周期

  3. 作业 03原理问答

    对 f(x1,x2)=(x1x2,x1²+x2),在自选非退化点推导给定上游向量 v 的反向结果;解释改变 v 时输出梯度代表什么标量目标。

    提交内容
    雅可比、上游向量与目标说明
    怎样算完成
    • 输入输出维度一致
    • 上游梯度来源明确

    练到的知识点局部导数与向量雅可比积

综合应用

  1. 作业 04编程实践

    实现自定义可微函数 f(x,y)=sin(x*y)+x²,在 CPU 双精度上检验前向及反向;测试多个尺度和不对称输入。

    提交内容
    实现、gradcheck 与独立数值记录
    怎样算完成
    • 梯度来源可追踪
    • 检查容差和数据类型记录

    练到的知识点局部导数与向量雅可比积 · 双精度 gradcheck · 有限差分适用边界

  2. 作业 05排错实验

    在同一小计算图中分别加入 detach、no_grad 和遗漏清零,保存每个版本的前向值与叶梯度;判断错误是否一定能从损失值看出。

    提交内容
    三个故障实验与比较表
    怎样算完成
    • 每次只注入一项变化
    • 前向和反向分别验收

    练到的知识点梯度累积和图生命周期 · detach 与 no_grad

  3. 作业 06面试场景

    某自定义反向在随机检查中通过,但训练时出现异常。设计覆盖重复输入、共享子图、极端尺度及不可导点的追加测试,并划定数值检查局限。

    提交内容
    针对性测试集
    怎样算完成
    • 测试针对不同失效机制
    • 不可导点标准独立

    练到的知识点局部导数与向量雅可比积 · 共享节点梯度求和 · 有限差分适用边界

  4. 作业 07编程实践

    在 CPU 双精度上比较 L=(x−target)² 中 target=x/2 与 target=detach(x/2),记录前向、梯度及一步更新;不把两个程序默认视为同一优化目标。

    提交内容
    两程序及目标审计
    怎样算完成
    • 前向与梯度分别记录
    • 截断位置可追踪

    练到的知识点局部导数与向量雅可比积 · detach 与 no_grad

  5. 作业 08设计评审

    教师输出参与学生损失时,自行定义固定教师与联合训练两种任务,画梯度允许流向的参数集合并制定梯度归属测试。

    提交内容
    两份梯度契约和测试清单
    怎样算完成
    • 训练目标先于 API 选择
    • 应更新和不应更新参数明确

    练到的知识点detach 与 no_grad

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    为含循环复用参数的标量程序设计简易自动微分实验,对比保存全部中间值和重新计算中间值的成本,测量 CPU 小规模实例。

    提交内容
    原型与成本报告
    怎样算完成
    • 梯度等价性独立校验
    • 时间和存储口径明确

    练到的知识点反向模式自动微分 · 局部导数与向量雅可比积 · 梯度累积和图生命周期

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 反例构造

    构造两个对所有给定输入前向值相同、自动求导梯度却不同的表达式;分析差异来自数学函数本身还是计算图中人为设定的求导规则。

    提交内容
    表达式、CPU 核验及解释
    怎样算完成
    • 前向等价范围明确
    • 梯度规则差异有证据

    练到的知识点局部导数与向量雅可比积 · detach 与 no_grad

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗