- 人工智能
- 深度学习
- 机器学习
- 教程
【免费下载链接】d2l-zh
《动手学深度学习》:面向中文读者、能运行、可讨论。中英文版被70多个国家的500多所大学用于教学。
自动微分(automatic differentiation)是几乎所有深度学习优化算法的基石,它让框架能够自动构建计算图、记录前向计算,并通过反向传播一次性填出每个参数的偏导数。本文以《动手学深度学习》(d2l-zh)预备知识章节的autograd专题文档为核心,结合仓库中 d2l/torch.py、d2l/mxnet.py 等工具库源码,系统讲解梯度存储、标量与向量反向传播、计算分离(detach)、以及带 Python 控制流的梯度计算,并给出可运行的三框架(MXNet / PyTorch / TensorFlow)代码。读完本文,你将掌握在任何深度学习框架中"算梯度"的标准四步法,并理解其背后的计算图原理。
为什么要自动微分:从手工求导到计算图
正如 微积分章节 所介绍的,求导是优化问题的核心:我们训练模型,本质上是在不断最小化一个损失函数。虽然求导的计算本身只需要基本的微积分,但对于复杂的深层模型,手工推导并更新每一个参数的偏导数"是一件很痛苦的事情(而且经常容易出错)"。
深度学习框架通过自动计算导数(即自动微分)来加快这一过程。实际运行时,系统会基于我们设计的模型构建一个计算图(computational graph),跟踪"哪些数据通过哪些操作组合起来产生输出"。自动微分使系统能够随后反向传播梯度(backpropagate)——这里的"反向传播"指的是跟踪整个计算图,逐个填充关于每个参数的偏导数。
在 预备知识章节 的导读中,作者也明确指出:"幸运的是,autograd包会自动计算微分"。仓库中d2l/mxnet.py、d2l/torch.py、d2l/tensorflow.py等工具库的几乎所有训练函数都依赖这一机制,本文将在最后一节以源码为证展开说明。
一个简单的例子:对 y = 2xᵀx 求导
作为演示例子,假设我们要对函数 $y = 2\mathbf{x}^{\top}\mathbf{x}$ 关于列向量 $\mathbf{x}$ 求导。首先创建变量x并赋初始值:
# MXNet from mxnet import autograd, np, npx npx.set_np() x = np.arange(4.0) x# PyTorch import torch x = torch.arange(4.0) x# TensorFlow import tensorflow as tf x = tf.range(4, dtype=tf.float32) x梯度存储:attach_grad / requires_grad_ / tf.Variable
在计算 $y$ 关于 $\mathbf{x}$ 的梯度之前,需要一个地方来存储梯度。文档强调了一个工程细节:不要在每次对一个参数求导时都重新分配内存,因为我们经常会成千上万次地更新同一批参数,每次都分配新内存会很快耗尽内存。注意,标量函数关于向量 $\mathbf{x}$ 的梯度本身是向量,且与 $\mathbf{x}$ 形状相同。
# MXNet:调用 attach_grad 为张量的梯度分配内存,grad 属性初始化为 0 x.attach_grad() x.grad# PyTorch:requires_grad_ 等价于 x = torch.arange(4.0, requires_grad=True),默认 grad 为 None x.requires_grad_(True) x.grad# TensorFlow:将张量包装为 Variable 才能记录梯度 x = tf.Variable(x)记录计算并触发反向传播
接下来计算 $y$,然后调用反向传播函数求出梯度:
# MXNet:把代码放入 autograd.record 作用域,以建立计算图 with autograd.record(): y = 2 * np.dot(x, x) y# PyTorch y = 2 * torch.dot(x, x) y# TensorFlow:把所有计算记录在"磁带"(tape)上 with tf.GradientTape() as t: y = 2 * tf.tensordot(x, x, axes=1) y由于x是长度为 4 的向量,x与x做内积得到标量y。随后调用反向传播函数,并打印梯度:
# MXNet / PyTorch y.backward() x.grad# TensorFlow:注意这里从磁带中取梯度 x_grad = t.gradient(y, x) x_grad函数 $y = 2\mathbf{x}^{\top}\mathbf{x}$ 关于 $\mathbf{x}$ 的梯度应为 $4\mathbf{x}$,可以用下面的比较式快速验证:
x.grad == 4 * x # MXNet / PyTorch x_grad == 4 * x # TensorFlow梯度会被覆盖,PyTorch 默认累加
现在计算x的另一个函数:
# MXNet:再次计算的新梯度会覆盖旧值 with autograd.record(): y = x.sum() y.backward() x.grad# PyTorch:默认会累积梯度,需要先清除旧值再重新 backward x.grad.zero_() y = x.sum() y.backward() x.grad# TensorFlow:每次新建 tape,梯度自然被"新计算"覆盖 with tf.GradientTape() as t: y = tf.reduce_sum(x) t.gradient(y, x)这里体现了三个框架的默认差异:MXNet 与 TensorFlow 的新一次反向计算会覆盖旧梯度,而 PyTorch 默认累积梯度,因此连续反向传播前需要手动zero_()。这也是 d2l/torch.py 中所有训练循环都会先调用updater.zero_grad()或param.grad.zero_()的原因。
非标量变量的反向传播:批内偏导数之和
当y不是标量时,向量y关于向量x的导数最自然的解释是一个矩阵;对更高阶、更高维的y和x,求导结果甚至可能是高阶张量。但在实践中,当我们对向量调用 backward 时,目标通常不是计算微分矩阵,而是计算一批训练样本中每个样本的偏导数之和——这正对应批量训练的损失函数场景。
# MXNet:对向量 y 调用 backward 时,会先对 y 的元素求和生成标量,再计算该标量对 x 的梯度 with autograd.record(): y = x * x # y 是向量 y.backward() x.grad # 等价于 y = sum(x * x)# PyTorch:对非标量调用 backward 需传入 gradient 参数,指明被微分函数对 self 的梯度 # 本例只需求偏导数之和,因此传入全 1 梯度即可;y.sum().backward() 是等价写法 x.grad.zero_() y = x * x y.sum().backward() x.grad# TensorFlow:t.gradient(y, x) 与 y = tf.reduce_sum(x * x) 等价 with tf.GradientTape() as t: y = x * x t.gradient(y, x)理解这一语义至关重要:深度学习中几乎所有的loss都是对批量内各样本损失求和(或求平均)后的标量,因此对向量反向传播"自动求和"的行为恰好与批量训练的数学需求一致。仓库源码中可见这种模式的直接应用,例如 d2l/torch.py 的train_epoch_ch3中,使用内置优化器时调用l.mean().backward(),使用自定义优化器时调用l.sum().backward()。
分离计算(detach):把中间变量当常数
有时我们希望把某些计算移出被记录的计算图。例如:y是x的函数,z又是y和x的函数。假设我们想计算z关于x的梯度,但出于某种原因希望把y视为常数,只考虑x在y被计算之后发挥的作用。
此时可以分离y,返回一个与y值相同的新变量u,但它丢弃了计算图中"如何计算y"的全部信息——梯度不会向后流经u到x。因此下面的反向传播计算的是z = u * x关于x的偏导数(把u当常数),而不是z = x * x * x关于x的偏导数:
# MXNet with autograd.record(): y = x * x u = y.detach() z = u * x z.backward() x.grad == u# PyTorch x.grad.zero_() y = x * x u = y.detach() z = u * x z.sum().backward() x.grad == u# TensorFlow:设置 persistent=True 以便在同一 tape 上多次调用 t.gradient with tf.GradientTape(persistent=True) as t: y = x * x u = tf.stop_gradient(y) z = u * x x_grad = t.gradient(z, x) x_grad == u由于y的计算仍然被记录在图中,我们随后依然可以单独对y反向传播,得到y = x * x关于x的导数 $2x$:
# MXNet y.backward() x.grad == 2 * x# PyTorch x.grad.zero_() y.sum().backward() x.grad == 2 * x# TensorFlow t.gradient(y, x) == 2 * x分离计算是工程中的常用技巧。例如在 d2l/torch.py 的train_epoch_ch8中,循环神经网络训练时会调用state.detach_(),目的正是切断时间步之间的梯度传播路径,只保留需要学习的梯度流。
Python 控制流的梯度计算:while、if 与任意函数调用
自动微分的一个突出好处是:即使构建函数的计算图需要穿过复杂的 Python 控制流(条件、循环或任意函数调用),我们仍然可以计算出结果变量的梯度。下面的函数f中,while循环的迭代次数和if分支的走向都取决于输入a的值:
# MXNet def f(a): b = a * 2 while np.linalg.norm(b) < 1000: b = b * 2 if b.sum() > 0: c = b else: c = 100 * b return c# PyTorch def f(a): b = a * 2 while b.norm() < 1000: b = b * 2 if b.sum() > 0: c = b else: c = 100 * b return c# TensorFlow def f(a): b = a * 2 while tf.norm(b) < 1000: b = b * 2 if tf.reduce_sum(b) > 0: c = b else: c = 100 * b return c计算梯度:
# MXNet a = np.random.normal() a.attach_grad() with autograd.record(): d = f(a) d.backward()# PyTorch a = torch.randn(size=(), requires_grad=True) d = f(a) d.backward()# TensorFlow a = tf.Variable(tf.random.normal(shape=())) with tf.GradientTape() as t: d = f(a) d_grad = t.gradient(d, a) d_grad如何验证结果正确?注意函数f在输入a上是分段线性的:对于任意a,都存在某个常数标量k,使得f(a) = k * a,其中k的取值取决于输入a。因此可以用d / a来验证梯度:
a.grad == d / a # MXNet / PyTorch d_grad == d / a # TensorFlow这说明了自动微分与数值方法、符号求导的一个本质区别:框架记录的是实际执行过的操作序列,因此即使控制流动态改变了计算路径,梯度依然精确匹配"实际路径"的导数,而非对函数形式的整体求导。
从源码看自动微分在训练循环中的真实应用
自动微分并非孤立概念,它渗透在 d2l-zh 仓库的每一个训练函数中。以 PyTorch 工具库 d2l/torch.py 为例:
- 自定义 SGD 更新:
sgd(params, lr, batch_size)(d2l/torch.py)在torch.no_grad()上下文中执行param -= lr * param.grad / batch_size,更新后立即param.grad.zero_()清零——这正是"梯度存储、反向传播、读取梯度、清空再复用"四步法在训练循环中的落地; - 从零实现的训练函数:
train_epoch_ch3(d2l/torch.py)对每个小批量调用l.mean().backward()(内置优化器)或l.sum().backward()(自定义优化器),随后由优化器读取梯度并更新参数; - 循环神经网络训练:
train_epoch_ch8(d2l/torch.py)在每个时间步初始化后对状态张量调用detach_(),并配合grad_clipping做梯度裁剪; - 优化算法验证:
train_ch11(d2l/torch.py)直接以torch.normal(..., requires_grad=True)创建带梯度的参数w、b,每轮l.backward()后调用对应的优化器状态更新函数。
MXNet 工具库 d2l/mxnet.py 同样大量使用autograd.record()作用域与w.attach_grad()的配对模式。你在后续章节中会反复看到这一模式:先在参数上附加梯度,再在record/GradientTape中记录计算,然后执行反向传播,最后读取并更新梯度。掌握了本文的自动微分原理,就等于拿到了阅读全书所有训练代码的钥匙。
小结
- 深度学习框架可以自动计算导数:我们首先将梯度附加到想要计算偏导数的变量上,然后记录目标值的计算,执行反向传播函数,并访问得到的梯度;
- 梯度存储要复用而非频繁分配内存;标量函数关于向量的梯度与向量形状相同;
- 对非标量反向传播默认等价于"先求和再求导",契合批量训练损失;
- 需要把中间结果当常数时,用
detach()/tf.stop_gradient()切断梯度流; - 计算图包含 Python 控制流时仍可正确求导,且结果可用
d / a这类解析关系验证。
练习(来自原文档)
- 为什么计算二阶导数比一阶导数的开销更大?
- 在运行反向传播函数之后,立即再次运行它,看看会发生什么。
- 在控制流的例子中,我们计算
d关于a的导数;如果将变量a更改为随机向量或矩阵,此时f(a)不再是标量,结果会发生什么?如何分析? - 重新设计一个求控制流梯度的例子,运行并分析结果。
- 令 $f(x) = \sin(x)$,绘制 $f(x)$ 和 $\frac{df(x)}{dx}$ 的图像,其中后者不使用 $f'(x) = \cos(x)$。
延伸阅读
- 本文对应文档的原文与中文翻译:autograd_origin.md、autograd.md
- 前置数学基础:微积分、线性代数
- 自动微分在模型训练中的首次实战:线性回归从零实现、softmax 回归从零实现
- 梯度分离技巧的进阶应用:循环神经网络从零实现
- 运行环境配置请参考安装指南
- 人工智能
- 深度学习
- 机器学习
- 教程
【免费下载链接】d2l-zh
《动手学深度学习》:面向中文读者、能运行、可讨论。中英文版被70多个国家的500多所大学用于教学。
相关推荐
MXNet autograd 自动微分实战指南:从梯度计算到自定义反向传播
MXNet autograd 自动微分实战指南:从梯度计算到自定义反向传播 本教程是 MXNet Crash Course 快速入门系列的第三步,围绕 mxne
深度学习人工智能机器学习分布式训练前向传播、反向传播与计算图:以《动手学深度学习》单隐藏层MLP为例精讲梯度流动
前向传播、反向传播与计算图:以《动手学深度学习》单隐藏层MLP为例精讲梯度流动 导读 反向传播(backpropagation)是训练所有深度神经网络的核心算法
人工智能深度学习机器学习教程开发你的第一个Claude插件:基于GitHub_Trending/cl/claude-plugins-official的实践教程
开发你的第一个Claude插件:基于GitHub_Trending/cl/claude plugins official的实践教程 欢迎来到Claude插件开发
AI 插件开发工具插件系统
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考