备注
点击此处下载完整示例代码
简介 || 张量 || Autograd || 模型构建 || TensorBoard 支持 || 训练模型 || 模型理解
Autograd 的基础 ¶
创建于:2025 年 4 月 1 日 | 最后更新:2025 年 4 月 1 日 | 最后验证:2024 年 11 月 5 日
按照下面的视频或 YouTube 上的视频进行操作。
PyTorch 的 Autograd 功能是使其在构建机器学习项目时灵活快速的部分原因。它允许快速轻松地计算复杂计算中的多个偏导数(也称为梯度)。这种操作是基于反向传播的神经网络学习中的核心。
Autograd 的强大之处在于它在运行时动态跟踪您的计算,这意味着如果您的模型有决策分支,或者长度在运行时才知道的循环,计算仍然会被正确跟踪,您将获得正确的梯度来驱动学习。这一点,加上您的模型是用 Python 构建的,比依赖于静态分析更严格结构的模型的框架提供了更大的灵活性。
我们为什么需要 Autograd? ¶
机器学习模型是一个函数,具有输入和输出。对于这次讨论,我们将输入视为一个 i 维向量 \(\vec{x}\),其元素为 \(x_{i}\)。然后我们可以将模型 M 表达为输入的向量值函数:\(\vec{y} = \vec{M}(\vec{x})\)。(我们将其输出值视为向量,因为在一般情况下,一个模型可能有任意数量的输出。)
由于我们将主要在训练的背景下讨论自动微分,因此我们感兴趣的输出将是模型的损失。损失函数 \(L(\vec{y}) = L(\vec{M}(\vec{x}))\) 是模型输出的单值标量函数。此函数表示我们的模型预测与特定输入的理想输出之间的偏差。注意:从这一点开始,我们通常会在上下文中明确的情况下省略向量符号 - 例如,用 \(y\) 代替 \(\vec{y}\)。
在训练模型时,我们希望最小化损失。在理想化的完美模型情况下,这意味着调整其学习权重——即函数的可调整参数——使得对于所有输入损失为零。在现实世界中,这意味着一个迭代过程,不断调整学习权重,直到我们观察到对于各种输入都能得到可接受的损失。
我们如何决定调整权重的大小和方向呢?我们希望最小化损失,这意味着使其关于输入的一阶导数等于 0:\(\frac{\partial L}{\partial x} = 0\)。
然而,损失并不是直接从输入导出的,而是模型输出(它是直接关于输入的函数)的函数,\(\frac{\partial L}{\partial x}\) = \(\frac{\partial {L({\vec y})}}{\partial x}\)。根据微分学的链式法则,我们有 \(\frac{\partial {L({\vec y})}}{\partial x}\) = \(\frac{\partial L}{\partial y}\frac{\partial y}{\partial x}\) = \(\frac{\partial L}{\partial y}\frac{\partial M(x)}{\partial x}\)。
\(\frac{\partial M(x)}{\partial x}\) 这里事情变得复杂。如果我们再次使用链式法则展开表达式,模型输出相对于输入的偏导数将涉及许多局部偏导数,这些偏导数覆盖了每个乘法学习权重、每个激活函数以及模型中的每个其他数学变换。每个这样的偏导数的完整表达式是所有可能路径的局部梯度的乘积之和,这些路径通过计算图结束于我们试图测量的梯度变量。
特别是,我们对我们关心的学习权重的梯度感兴趣——它们告诉我们如何改变每个权重,使损失函数更接近于零。
由于这样的局部导数(每个对应模型计算图的独立路径)的数量会随着神经网络深度的增加而呈指数增长,因此计算它们的复杂性也会增加。这就是自动微分发挥作用的地方:它跟踪每次计算的历史。你 PyTorch 模型中的每个计算张量都携带其输入张量和创建它的函数的历史。结合 PyTorch 中旨在对张量进行操作的函数每个都内置了计算其自身导数的实现这一事实,这极大地加快了学习所需局部导数的计算速度。
一个简单的例子
那么很多理论——但在实践中使用自动微分是什么样的呢?
让我们从简单的例子开始。首先,我们将进行一些导入,以便我们可以绘制我们的结果:
# %matplotlib inline
import torch
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
import math
接下来,我们将创建一个在区间 \([0, 2{\pi}]\) 上均匀分布的输入张量,并指定 requires_grad=True
。(像大多数创建张量的函数一样, torch.linspace()
接受一个可选的 requires_grad
选项。)设置此标志意味着在随后的每次计算中,autograd 都会累积该计算的历史记录在输出张量中。
a = torch.linspace(0., 2. * math.pi, steps=25, requires_grad=True)
print(a)
接下来,我们将执行一个计算,并绘制其输出与输入之间的关系:
b = torch.sin(a)
plt.plot(a.detach(), b.detach())
让我们更仔细地看看张量 b
。当我们打印它时,我们会看到一个指示器,表明它正在跟踪其计算历史:
print(b)
这 grad_fn
给我们一个提示,当我们执行反向传播步骤并计算梯度时,我们需要计算所有这些张量输入的 \(\sin(x)\) 的导数。
让我们再进行一些计算:
c = 2 * b
print(c)
d = c + 1
print(d)
最后,让我们计算一个单元素输出。当你对一个没有参数的张量调用 .backward()
时,它期望调用张量只包含一个元素,就像在计算损失函数时那样。
out = d.sum()
print(out)
我们将每个 grad_fn
存储在我们的张量中,允许你通过其 next_functions
属性遍历整个计算过程,直到其输入。下面我们可以看到,在 d
上钻取这个属性,显示了所有先前张量的梯度函数。注意, a.grad_fn
被报告为 None
,这表明这是没有自己历史记录的函数输入。
print('d:')
print(d.grad_fn)
print(d.grad_fn.next_functions)
print(d.grad_fn.next_functions[0][0].next_functions)
print(d.grad_fn.next_functions[0][0].next_functions[0][0].next_functions)
print(d.grad_fn.next_functions[0][0].next_functions[0][0].next_functions[0][0].next_functions)
print('\nc:')
print(c.grad_fn)
print('\nb:')
print(b.grad_fn)
print('\na:')
print(a.grad_fn)
在所有这些机制就绪的情况下,我们如何得到导数呢?你调用输出的 backward()
方法,并检查输入的 grad
属性以检查梯度:
out.backward()
print(a.grad)
plt.plot(a.detach(), a.grad.detach())
回顾我们到达这里的计算步骤:
a = torch.linspace(0., 2. * math.pi, steps=25, requires_grad=True)
b = torch.sin(a)
c = 2 * b
d = c + 1
out = d.sum()
添加一个常数,就像我们在计算 d
时做的那样,不会改变导数。这留下了\(c = 2 * b = 2 * \sin(a)\),其导数应该是\(2 * \cos(a)\)。从上面的图中看,这正是我们所看到的。
注意,只有计算中的叶子节点才有它们的梯度被计算。例如,如果你尝试 print(c.grad)
,你会得到 None
。在这个简单的例子中,只有输入是一个叶子节点,所以只有它有梯度被计算。
训练中的自动微分
我们已经简要地了解了 autograd 的工作原理,但它是如何用于其预期目的的呢?让我们定义一个小型模型,并检查它在经过一个训练批次后的变化。首先,定义一些常量、我们的模型以及一些输入和输出的替代品:
BATCH_SIZE = 16
DIM_IN = 1000
HIDDEN_SIZE = 100
DIM_OUT = 10
class TinyModel(torch.nn.Module):
def __init__(self):
super(TinyModel, self).__init__()
self.layer1 = torch.nn.Linear(DIM_IN, HIDDEN_SIZE)
self.relu = torch.nn.ReLU()
self.layer2 = torch.nn.Linear(HIDDEN_SIZE, DIM_OUT)
def forward(self, x):
x = self.layer1(x)
x = self.relu(x)
x = self.layer2(x)
return x
some_input = torch.randn(BATCH_SIZE, DIM_IN, requires_grad=False)
ideal_output = torch.randn(BATCH_SIZE, DIM_OUT, requires_grad=False)
model = TinyModel()
你可能会注意到,我们从未为模型的层指定 requires_grad=True
。在 torch.nn.Module
的子类中,默认情况下我们希望跟踪层的权重梯度以进行学习。
如果我们查看模型的层,我们可以检查权重的值,并验证尚未计算梯度:
print(model.layer2.weight[0][0:10]) # just a small slice
print(model.layer2.weight.grad)
让我们看看在运行一个训练批次时这会有什么变化。对于损失函数,我们将只使用我们的 prediction
和 ideal_output
之间的欧几里得距离的平方,并使用基本的随机梯度下降优化器。
optimizer = torch.optim.SGD(model.parameters(), lr=0.001)
prediction = model(some_input)
loss = (ideal_output - prediction).pow(2).sum()
print(loss)
现在,让我们调用 loss.backward()
看看会发生什么:
loss.backward()
print(model.layer2.weight[0][0:10])
print(model.layer2.weight.grad[0][0:10])
我们可以看到,每个学习权重的梯度已经计算出来了,但是权重没有改变,因为我们还没有运行优化器。优化器负责根据计算出的梯度更新模型权重。
optimizer.step()
print(model.layer2.weight[0][0:10])
print(model.layer2.weight.grad[0][0:10])
你应该看到 layer2
的权重已经改变了。
关于这个过程的一个重要事项:在调用 optimizer.step()
之后,你需要调用 optimizer.zero_grad()
,否则每次运行 loss.backward()
,学习权重的梯度都会累积:
print(model.layer2.weight.grad[0][0:10])
for i in range(0, 5):
prediction = model(some_input)
loss = (ideal_output - prediction).pow(2).sum()
loss.backward()
print(model.layer2.weight.grad[0][0:10])
optimizer.zero_grad(set_to_none=False)
print(model.layer2.weight.grad[0][0:10])
在运行上面的单元格之后,你应该看到在多次运行 loss.backward()
之后,大多数梯度的幅度将会变得很大。在运行下一个训练批次之前未能将梯度置零,会导致梯度以这种方式爆炸,从而造成学习结果不正确和不可预测。
关闭和开启自动微分
有时候你需要对是否启用 autograd 有更精细的控制。根据具体情况,有多种方法可以实现这一点。
最简单的方法是直接在张量上更改 requires_grad
标志:
a = torch.ones(2, 3, requires_grad=True)
print(a)
b1 = 2 * a
print(b1)
a.requires_grad = False
b2 = 2 * a
print(b2)
在上面的单元格中,我们看到 b1
有一个 grad_fn
(即,一个追踪的计算历史),这正是我们所期望的,因为它是由一个开启了 autograd 的 tensor a
派生出来的。当我们显式地关闭 autograd a.requires_grad = False
时,计算历史不再被追踪,正如我们在计算 b2
时所见。
如果你只需要临时关闭 autograd,更好的方法是使用 torch.no_grad()
:
a = torch.ones(2, 3, requires_grad=True) * 2
b = torch.ones(2, 3, requires_grad=True) * 3
c1 = a + b
print(c1)
with torch.no_grad():
c2 = a + b
print(c2)
c3 = a * b
print(c3)
torch.no_grad()
也可以用作函数或方法装饰器:
def add_tensors1(x, y):
return x + y
@torch.no_grad()
def add_tensors2(x, y):
return x + y
a = torch.ones(2, 3, requires_grad=True) * 2
b = torch.ones(2, 3, requires_grad=True) * 3
c1 = add_tensors1(a, b)
print(c1)
c2 = add_tensors2(a, b)
print(c2)
对于在不需要时开启 autograd,有一个相应的上下文管理器 torch.enable_grad()
,它也可以用作装饰器。
最后,你可能有一个需要梯度跟踪的张量,但你想要一个不需要的副本。为此,我们有 Tensor
对象的 detach()
方法——它创建了一个从计算历史中分离的张量副本:
x = torch.rand(5, requires_grad=True)
y = x.detach()
print(x)
print(y)
我们在上面这样做,因为我们想要绘制一些张量。这是因为 matplotlib
期望输入 NumPy 数组,而对于 requires_grad=True 的张量,没有启用从 PyTorch 张量到 NumPy 数组的隐式转换。创建一个分离的副本让我们可以继续前进。
自动微分与就地操作
在本笔记本中的每个示例中,我们都使用了变量来捕获计算过程中的中间值。Autograd 需要这些中间值来执行梯度计算。因此,在使用 autograd 时,你必须小心使用就地操作。这样做可能会破坏你在 backward()
调用中计算导数所需的信息。如果尝试对需要 autograd 的叶变量执行就地操作,PyTorch 甚至会阻止你,如下所示。
备注
以下代码单元格会抛出运行时错误。这是预期的。
a = torch.linspace(0., 2. * math.pi, steps=25, requires_grad=True)
torch.sin_(a)
自动微分分析器
自动微分详细跟踪您的计算的每一步。这样的计算历史,结合时间信息,将是一个方便的分析器 - 而自动微分已经内置了这个功能。以下是一个快速示例用法:
device = torch.device('cpu')
run_on_gpu = False
if torch.cuda.is_available():
device = torch.device('cuda')
run_on_gpu = True
x = torch.randn(2, 3, requires_grad=True)
y = torch.rand(2, 3, requires_grad=True)
z = torch.ones(2, 3, requires_grad=True)
with torch.autograd.profiler.profile(use_cuda=run_on_gpu) as prf:
for _ in range(1000):
z = (z / x) * y
print(prf.key_averages().table(sort_by='self_cpu_time_total'))
分析器还可以标记代码的各个子块,按输入张量形状拆分数据,并将数据导出为 Chrome 跟踪工具文件。有关 API 的完整详细信息,请参阅文档。
高级主题:更多自动微分细节和高级 API
如果您有一个具有 n 维输入和 m 维输出的函数 \(\vec{y}=f(\vec{x})\),则完整的梯度是每个输出相对于每个输入的导数矩阵,称为雅可比矩阵:
如果您有第二个函数 \(l=g\left(\vec{y}\right)\),它接受 m 维输入(即与上述输出的维度相同),并返回标量输出,您可以将其相对于 \(\vec{y}\) 的梯度表示为一个列向量,\(v=\left(\begin{array}{ccc}\frac{\partial l}{\partial y_{1}} & \cdots & \frac{\partial l}{\partial y_{m}}\end{array}\right)^{T}\) - 这实际上就是一个一列的雅可比矩阵。
更具体地说,想象第一个函数是你的 PyTorch 模型(可能有很多输入和输出),第二个函数是损失函数(以模型的输出作为输入,损失值作为标量输出)。
如果我们将第一个函数的雅可比矩阵乘以第二个函数的梯度,并应用链式法则,我们得到:
注意:你也可以使用等价的操作 \(v^{T}\cdot J\),并得到一个行向量。
结果列向量是第二个函数相对于第一个函数输入的梯度——或者在我们的模型和损失函数的情况下,是损失相对于模型输入的梯度。
`torch.autograd` 是一个计算这些乘积的引擎。这就是我们在反向传播过程中累积学习权重梯度的方法。
因此, backward()
调用也可以接受一个可选的向量输入。这个向量代表张量上的一组梯度,这些梯度将被乘以前面跟随的 autograd 跟踪张量的雅可比矩阵。让我们用一个具体的例子来尝试一个小向量:
x = torch.randn(3, requires_grad=True)
y = x * 2
while y.data.norm() < 1000:
y = y * 2
print(y)
如果我们现在调用 y.backward()
,我们会得到一个运行时错误,并显示梯度只能隐式计算标量输出。对于多维输出,autograd 预期我们提供梯度,以便它可以将其乘入雅可比矩阵中的三个输出:
v = torch.tensor([0.1, 1.0, 0.0001], dtype=torch.float) # stand-in for gradients
y.backward(v)
print(x.grad)
(请注意,所有输出梯度都与 2 的幂有关——这是我们期望的重复加倍操作的结果。)
高级 API
autograd 上有一个 API,可以直接访问重要的微分矩阵和向量操作。特别是,它允许您计算特定输入的函数的雅可比矩阵和海森矩阵。(海森矩阵类似于雅可比矩阵,但表示所有偏二阶导数。)它还提供了与这些矩阵进行向量乘法的方法。
让我们计算一个简单函数的雅可比矩阵,该函数针对 2 个单元素输入进行评估:
def exp_adder(x, y):
return 2 * x.exp() + 3 * y
inputs = (torch.rand(1), torch.rand(1)) # arguments for the function
print(inputs)
torch.autograd.functional.jacobian(exp_adder, inputs)
如果你仔细观察,第一个输出应该等于 \(2e^x\)(因为 \(e^x\) 的导数是 \(e^x\)),第二个值应该是 3。
当然,你也可以用高阶张量来做这件事:
inputs = (torch.rand(3), torch.rand(3)) # arguments for the function
print(inputs)
torch.autograd.functional.jacobian(exp_adder, inputs)
torch.autograd.functional.hessian()
方法在假设你的函数是二阶可微的情况下工作相同,但返回所有二阶导数的矩阵。
如果你提供向量,还有一个函数可以直接计算向量-雅可比乘积:
def do_some_doubling(x):
y = x * 2
while y.data.norm() < 1000:
y = y * 2
return y
inputs = torch.randn(3)
my_gradients = torch.tensor([0.1, 1.0, 0.0001])
torch.autograd.functional.vjp(do_some_doubling, inputs, v=my_gradients)
torch.autograd.functional.jvp()
方法执行与 vjp()
相同的矩阵乘法,但操作数顺序相反。 vhp()
和 hvp()
方法对向量-海森矩阵乘法执行相同的操作。
更多信息,包括功能 API 文档中的性能说明
脚本总运行时间:(0 分钟 0.000 秒)