跳到正文
本地阅读 · 自动求导与计算图

自动求导与计算图

计算机基础 · PyTorch

前置知识:Tensor、形状与 NumPy 对照

本章目录

本章学习目标

  • 手算并验证梯度
  • 理解叶子张量、梯度累加与图的释放
  • 使用有限差分诊断求导实现

反向传播回答参数怎样影响目标

模型训练需要知道目标函数对参数的导数。PyTorch 在执行支持自动求导的张量操作时,记录从输入到输出的计算关系,随后用链式法则把标量损失对各个中间量的影响传回参数。自动求导不是把每个参数稍微改一点再重复运行;后者是数值差分,计算成本和精度特征不同。

从 y=(wx+b)² 开始。令 x=2、w=3、b=1,预测中间量 wx+b 为七,损失为四十九。损失对 w 的导数是二乘七乘二,即二十八;对 b 的导数是十四。先手算,再创建需要梯度的 w 和 b,调用 loss.backward,最后读取 grad。这个例子把链式法则、参数形状和代码输出连在一起。

训练参数通常是 requires_grad=True 的浮点叶子张量。输入数据不一定需要梯度,标签通常也不需要梯度。构造一个数学上可微的目标,并不要求对每个数据对象都保存导数。把所有张量都开启梯度会增加记录成本,还可能掩盖真正需要更新的参数。

梯度累加是默认行为

backward 会把新梯度加到已有 grad,而不是自动覆盖。这让多个小批次累积梯度成为可能,也意味着普通训练循环在每次更新前必须清理梯度。最常用做法是 optimizer.zero_grad(set_to_none=True),计算新损失,backward,然后 optimizer.step。清理、求导和更新是三件事,不能只调用 backward 就认为权重已经改变。

在手算例子中重新构建同样的前向表达式并再次 backward,w.grad 会从二十八变成五十六。随后把 grad 设为 None,再重新计算,结果回到二十八。这里每次都建立新的计算图,避免误把已经释放的图再次用于反向传播。

默认一次 backward 后会释放不再需要的图缓冲。重复对同一损失调用 backward 可能报错;retain_graph=True 应只在确实需要多次使用同一图时使用,不应作为入门训练错误的通用修补方式。正常训练每个批次做新的前向计算,自然获得新的图。

切断图、原地操作和非叶子张量

item 返回普通 Python 数值,适合日志记录,但这个数值已经离开计算图。把若干 loss.item 值重新装成一个 Tensor 再求导,不会恢复它们与参数的依赖关系。detach 也主动切断关系,因此如果在预测后 detach 再计算训练损失,模型参数不会收到正确梯度。

原地操作会修改已有存储,可能覆盖反向传播所需的值。优化器内部会以恰当方式更新参数,而在前向计算中随意对参与求导的张量做原地修改容易触发版本检查错误。初学者先采用易理解的非原地表达式,再研究内存优化,能减少难定位的问题。

非叶子中间张量参与计算图,但默认不一定保存 grad;参数没有梯度和中间量没有保存梯度不是同一种问题。如果需要观察中间量,可在合适场景调用 retain_grad。诊断时还要检查某个参数是否实际用于产生损失、是否被 detach,以及损失是否是标量。

用有限差分做独立核对

数值差分用 f(w+h) 与 f(w-h) 的差除以二倍步长,近似局部导数。它不依赖自动求导图,所以可以用作独立检查。步长太大会带来截断误差,太小又可能受到浮点消减影响。本章用 float64 和适中的步长核对光滑函数,比较时使用容差。

ReLU 的零点等不可微位置不适合用一个差分值证明全部求导逻辑正确。PyTorch 采用某种明确的次梯度约定,而中心差分可能得到另外的数值。检查应先选择光滑小例子,随后再针对分段函数解释边界条件。

本章的浏览器演示使用标量线性模型的平方损失,拖动参数时重新计算解析梯度。它是教学模拟,并不运行 PyTorch。配套脚本用真实 backward 验证同一组数值。面对训练异常时,先检查损失是否有限、梯度是否存在、是否有限、形状是否正确,然后再调整学习率。

交互演示:观察机制

完整示例与实际输出

import torch
w=torch.tensor(3.,dtype=torch.float64,requires_grad=True)
b=torch.tensor(1.,dtype=torch.float64,requires_grad=True)
loss=(w*2+b)**2
loss.backward()
assert torch.allclose(w.grad,torch.tensor(28.,dtype=torch.float64))
assert b.grad.item()==14
(w*2+b).square().backward()
assert w.grad.item()==56
w.grad=None; b.grad=None
(w*2+b).square().backward()
h=1e-5
f=lambda v:(v*2+1)**2
finite=(f(3+h)-f(3-h))/(2*h)
assert abs(finite-w.grad.item())<1e-7
print('loss=',loss.item(),'dw=',w.grad.item(),'db=',b.grad.item(),'finite_difference=',finite)

实际运行通过CPU Python execution.

loss= 49.0 dw= 28.0 db= 14.0 finite_difference= 28.000000000361066

五道自测:先回答,再核对

01 · loss.backward 会直接更新模型参数吗?

查看提示

区分求导与优化。

查看参考答案

不会,它计算并累加梯度;优化器 step 才更新参数。

02 · 为什么普通训练每个批次都清理梯度?

查看提示

观察示例第二次 backward。

查看参考答案

因为 backward 默认累加 grad,否则混入上一批次梯度。

03 · 日志用 loss.item 合理吗?用于构建可微总损失呢?

查看提示

Python 数值是否仍有 grad_fn?

查看参考答案

日志合理;构建可微总损失不合理,item 已切断计算关系。

04 · 没有看到非叶子中间量 grad,是否一定求导失败?

查看提示

保存梯度与参与图不同。

查看参考答案

不一定,非叶子默认不保留 grad,需要时 retain_grad;应先检查参数的梯度。

05 · 为什么有限差分优先使用光滑函数和 float64?

查看提示

中心差分的两个误差来源。

查看参考答案

减少不可微边界的歧义和浮点误差,形成更可靠的独立核对。

两项动手任务

任务 1 · 对 f(w)=(3w-2)² 在 w=1 求导,核对手算与 backward。

提示

先写出输入、输出、形状和判断条件,再运行小数据。

完整参考解答与检查方式
import torch
w=torch.tensor(1.,requires_grad=True)
loss=(3*w-2)**2
loss.backward()
assert w.grad.item()==6
print(loss.item(),w.grad.item())

任务 2 · 修复连续两轮 backward 后梯度翻倍的问题。

提示

先写出输入、输出、形状和判断条件,再运行小数据。

完整参考解答与检查方式
import torch
w=torch.tensor(1.,requires_grad=True)
for _ in range(2):
    w.grad=None
    loss=(3*w-2)**2
    loss.backward()
    assert w.grad.item()==6
print(w.grad.item())

如果使用优化器,则在每轮计算前调用 optimizer.zero_grad(set_to_none=True)。

官方资料与掌握状态

完成两项任务后,按独立完成程度记录。状态会尽量保存在当前浏览器,可导出备份。

动手任务记录

掌握状态与复习

关掉资料独立完成后记 A;提示后完成记 B;需要重学记 C。

记录尽量保存在当前浏览器,建议定期导出备份。