注意
点击 这里 下载完整示例代码
介绍 || 张量 || 自动求导 || 构建模型 || TensorBoard 支持 || 训练模型 || 模型理解
自动微分基础¶
创建日期: 2021年11月30日 | 最后更新日期: 2024年2月26日 | 最后验证日期: 2024年11月5日
请跟随下方视频或在 YouTube 上观看。
PyTorch 的 Autograd 功能是使其在构建机器学习项目时具备灵活性和快速性的部分原因。它允许对复杂计算进行快速且简便的多部分偏导数(也称为 梯度)计算。这一操作对于基于反向传播的神经网络学习至关重要。
autograd 的强大之处在于它在运行时动态地进行计算追踪,这意味着如果你的模型有决策分支,或者循环的长度在运行时才确定,计算仍然会被正确追踪,并且你会得到正确的梯度来驱动学习。结合这一点,由于你的模型是在 Python 中构建的,这比依赖于更僵硬结构模型的静态分析计算梯度的框架提供了更多的灵活性。
我们为什么需要Autograd?¶
机器学习模型是一个函数,有输入和输出。对于本次讨论,我们将输入视为i-维向量\(\vec{x}\),其中元素\(x_{i}\)。我们可以通过输入表示该模型,M,即一个向量值函数:\(\vec{y} = \vec{M}(\vec{x})\)。(因为我们通常将模型的输出视为一个向量,因为一般来说,模型可能有任意数量的输出。)
自从我们将主要讨论autograd在训练中的应用, 我们感兴趣的输出将是模型的损失。损失函数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}\). 通过微分 calculus 的链式法则,我们有 \(\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}\) 是复杂性的起点。 如果我们再次使用链式法则展开表达式,模型输出相对于其输入的部分导数将涉及许多局部部分导数,这些局部部分导数涵盖了每个学习权重、每个激活函数以及其他模型中的每一个数学变换。对于每个这样的部分导数,完整的表达式是通过计算图中每条可能路径到我们试图测量的变量的局部梯度之积的总和得到的。
特别是,我们对学习权重的梯度感兴趣 - 它们告诉我们 如何调整每个权重的方向 以使损失函数更接近于零。
由于神经网络的深度增加会导致局部导数的数量呈指数级上升(每个局部导数对应模型计算图中的一个单独路径),因此计算这些导数的复杂度也会呈指数级上升。这就是autograd发挥作用的地方:它会跟踪每一项计算的历史记录。在你的PyTorch模型中计算出的每一个张量都携带着其输入张量的历史记录以及用于创建它的函数信息。结合PyTorch中用于处理张量的函数本身已经内置了计算自身导数的方法这一事实,这大大加速了学习所需局部导数的计算过程。
简单示例¶
那是一大堆理论——但在实践中,如何使用autograd呢?
让我们从一个简单的例子开始。首先,我们需要导入一些库以便于绘制结果:
# %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)
tensor([0.0000, 0.2618, 0.5236, 0.7854, 1.0472, 1.3090, 1.5708, 1.8326, 2.0944,
2.3562, 2.6180, 2.8798, 3.1416, 3.4034, 3.6652, 3.9270, 4.1888, 4.4506,
4.7124, 4.9742, 5.2360, 5.4978, 5.7596, 6.0214, 6.2832],
requires_grad=True)
接下来,我们将进行一个计算,并将其输出按照输入进行绘图:

[<matplotlib.lines.Line2D object at 0x7f6a9b8e2980>]
让我们更详细地看一下张量 b。当我们打印它时,我们会看到一个指示器,表明它正在跟踪其计算历史:
print(b)
tensor([ 0.0000e+00, 2.5882e-01, 5.0000e-01, 7.0711e-01, 8.6603e-01,
9.6593e-01, 1.0000e+00, 9.6593e-01, 8.6603e-01, 7.0711e-01,
5.0000e-01, 2.5882e-01, -8.7423e-08, -2.5882e-01, -5.0000e-01,
-7.0711e-01, -8.6603e-01, -9.6593e-01, -1.0000e+00, -9.6593e-01,
-8.6603e-01, -7.0711e-01, -5.0000e-01, -2.5882e-01, 1.7485e-07],
grad_fn=<SinBackward0>)
这 grad_fn 给我们一个提示,当我们执行反向传播步骤并计算梯度时,我们需要为这个张量的所有输入计算 \(\sin(x)\) 的导数。
让我们进行一些更多的计算:
tensor([ 0.0000e+00, 5.1764e-01, 1.0000e+00, 1.4142e+00, 1.7321e+00,
1.9319e+00, 2.0000e+00, 1.9319e+00, 1.7321e+00, 1.4142e+00,
1.0000e+00, 5.1764e-01, -1.7485e-07, -5.1764e-01, -1.0000e+00,
-1.4142e+00, -1.7321e+00, -1.9319e+00, -2.0000e+00, -1.9319e+00,
-1.7321e+00, -1.4142e+00, -1.0000e+00, -5.1764e-01, 3.4969e-07],
grad_fn=<MulBackward0>)
tensor([ 1.0000e+00, 1.5176e+00, 2.0000e+00, 2.4142e+00, 2.7321e+00,
2.9319e+00, 3.0000e+00, 2.9319e+00, 2.7321e+00, 2.4142e+00,
2.0000e+00, 1.5176e+00, 1.0000e+00, 4.8236e-01, -3.5763e-07,
-4.1421e-01, -7.3205e-01, -9.3185e-01, -1.0000e+00, -9.3185e-01,
-7.3205e-01, -4.1421e-01, 4.7684e-07, 4.8236e-01, 1.0000e+00],
grad_fn=<AddBackward0>)
最后,让我们计算一个单一元素的输出。当你在一个张量上调用
.backward() 函数且不带参数时,它期望该张量只包含一个元素,这在计算损失函数时是这种情况。
tensor(25., grad_fn=<SumBackward0>)
每个 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)
d:
<AddBackward0 object at 0x7f6a9b88c100>
((<MulBackward0 object at 0x7f6acaa42b00>, 0), (None, 0))
((<SinBackward0 object at 0x7f6a9b88c100>, 0), (None, 0))
((<AccumulateGrad object at 0x7f6acaa42b00>, 0),)
()
c:
<MulBackward0 object at 0x7f6a9b88c100>
b:
<SinBackward0 object at 0x7f6a9b88c100>
a:
None
有了所有的这些机制,我们如何获取导数?您在输出上调用backward()方法,并检查输入的grad属性以查看梯度:
out.backward()
print(a.grad)
plt.plot(a.detach(), a.grad.detach())

tensor([ 2.0000e+00, 1.9319e+00, 1.7321e+00, 1.4142e+00, 1.0000e+00,
5.1764e-01, -8.7423e-08, -5.1764e-01, -1.0000e+00, -1.4142e+00,
-1.7321e+00, -1.9319e+00, -2.0000e+00, -1.9319e+00, -1.7321e+00,
-1.4142e+00, -1.0000e+00, -5.1764e-01, 2.3850e-08, 5.1764e-01,
1.0000e+00, 1.4142e+00, 1.7321e+00, 1.9319e+00, 2.0000e+00])
[<matplotlib.lines.Line2D object at 0x7f6a9b911870>]
回想一下我们到这里所进行的计算步骤:
添加一个常数,就像我们用来计算d的操作一样,不会改变导数。这留下了一个\(c = 2 * b = 2 * \sin(a)\),其导数应该是\(2 * \cos(a)\)。看看上面的图表,正是我们看到的。
请注意,只有计算的 叶节点 才会计算其梯度。例如,如果你尝试使用 print(c.grad),你会得到 None。在这个简单的例子中,只有输入是叶节点,因此只有它会计算梯度。
Autograd 在训练¶
我们简要了解了 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)
tensor([ 0.0920, 0.0916, 0.0121, 0.0083, -0.0055, 0.0367, 0.0221, -0.0276,
-0.0086, 0.0157], grad_fn=<SliceBackward0>)
None
让我们看看当我们运行一个训练批次时会发生什么变化。对于损失函数,我们将仅仅使用我们的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)
tensor(211.2634, grad_fn=<SumBackward0>)
现在,让我们调用 loss.backward() 看看会发生什么:
loss.backward()
print(model.layer2.weight[0][0:10])
print(model.layer2.weight.grad[0][0:10])
tensor([ 0.0920, 0.0916, 0.0121, 0.0083, -0.0055, 0.0367, 0.0221, -0.0276,
-0.0086, 0.0157], grad_fn=<SliceBackward0>)
tensor([12.8997, 2.9572, 2.3021, 1.8887, 5.0710, 7.3192, 3.5169, 2.4319,
0.1732, -5.3835])
我们可以看到已经为每个学习权重计算了梯度,但权重仍然没有改变,因为我们还没有运行优化器。优化器负责根据计算出的梯度来更新模型的权重。
optimizer.step()
print(model.layer2.weight[0][0:10])
print(model.layer2.weight.grad[0][0:10])
tensor([ 0.0791, 0.0886, 0.0098, 0.0064, -0.0106, 0.0293, 0.0186, -0.0300,
-0.0088, 0.0211], grad_fn=<SliceBackward0>)
tensor([12.8997, 2.9572, 2.3021, 1.8887, 5.0710, 7.3192, 3.5169, 2.4319,
0.1732, -5.3835])
你应该会看到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])
tensor([12.8997, 2.9572, 2.3021, 1.8887, 5.0710, 7.3192, 3.5169, 2.4319,
0.1732, -5.3835])
tensor([ 19.2095, -15.9459, 8.3306, 11.5096, 9.5471, 0.5391, -0.3370,
8.6386, -2.5141, -30.1419])
tensor([0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])
运行上面的单元格后,您应该会看到多次运行
loss.backward() 后,大多数梯度的大小将会大很多。在运行下一个训练批次之前没有清零梯度会导致梯度以这种方式爆炸,从而导致错误且不可预测的学习结果。
关闭和开启Autograd¶
在某些情况下,您可能需要对是否启用自动求导进行精细控制。根据具体情况,有多种方法可以实现这一点。
最简单的方法是直接更改张量上的 requires_grad 标志:
tensor([[1., 1., 1.],
[1., 1., 1.]], requires_grad=True)
tensor([[2., 2., 2.],
[2., 2., 2.]], grad_fn=<MulBackward0>)
tensor([[2., 2., 2.],
[2., 2., 2.]])
在上方的单元格中,我们看到 b1 有一个 grad_fn (即一个追踪的计算历史记录),这是我们期望的结果,因为它是从启用了自动求导的张量 a 推导出来的。当我们显式地关闭自动求导 a.requires_grad = False 后,计算历史记录不再被追踪,正如我们在计算 b2 时所见。
如果您只需要临时关闭自动求导,更好的方法是使用
torch.no_grad():
tensor([[5., 5., 5.],
[5., 5., 5.]], grad_fn=<AddBackward0>)
tensor([[5., 5., 5.],
[5., 5., 5.]])
tensor([[6., 6., 6.],
[6., 6., 6.]], grad_fn=<MulBackward0>)
torch.no_grad() 也可以用作函数或方法装饰器:
tensor([[5., 5., 5.],
[5., 5., 5.]], grad_fn=<AddBackward0>)
tensor([[5., 5., 5.],
[5., 5., 5.]])
有相应的上下文管理器 torch.enable_grad() 可以在autograd未开启时使用。它也可以用作一个装饰器。
最后,您可能有一个需要跟踪梯度的张量,但您想要一个不跟踪的副本。为此,我们有Tensor对象的detach()方法 - 它创建了一个与计算历史断开连接的张量副本:
tensor([0.0670, 0.3890, 0.7264, 0.3559, 0.6584], requires_grad=True)
tensor([0.0670, 0.3890, 0.7264, 0.3559, 0.6584])
我们在上面绘制某些张量时就是这样做的。这是因为matplotlib期望输入一个NumPy数组,而PyTorch张量与requires_grad=True相关的隐式转换到NumPy数组并未启用。创建一个分离的副本让我们能够继续前进。
自动微分和原地操作¶
在本笔记本中的每一个示例中,我们都使用变量来捕获计算过程中的中间值。Autograd 需要这些中间值来进行梯度计算。因此,在使用 Autograd 时,您必须小心不要使用原地操作。这样做可能会破坏您用于在 backward() 调用中计算导数所需的信息。即使您尝试对需要 Autograd 的叶子变量执行原地操作,PyTorch 也会阻止您,如下所示。
注意
The following code cell throws a runtime error. This is expected.
a = torch.linspace(0., 2. * math.pi, steps=25, requires_grad=True)
torch.sin_(a)
Autograd 跟踪器¶
Autograd 会详细跟踪你的每一步计算。这样的计算历史,结合时间信息,可以作为一个方便的性能分析器——而 Autograd 内置了这一功能。这里有一个快速的使用示例:
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'))
/var/lib/workspace/beginner_source/introyt/autogradyt_tutorial.py:485: FutureWarning:
The attribute `use_cuda` will be deprecated soon, please use ``use_device = 'cuda'`` instead.
------------------------- ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------
Name Self CPU % Self CPU CPU total % CPU total CPU time avg Self CUDA Self CUDA % CUDA total CUDA time avg # of Calls
------------------------- ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------
cudaEventRecord 43.35% 8.486ms 43.35% 8.486ms 2.122us 0.000us 0.00% 0.000us 0.000us 4000
aten::div 28.94% 5.665ms 28.94% 5.665ms 5.665us 9.991ms 50.69% 9.991ms 9.991us 1000
aten::mul 27.65% 5.412ms 27.65% 5.412ms 5.412us 9.719ms 49.31% 9.719ms 9.719us 1000
cudaDeviceSynchronize 0.06% 12.618us 0.06% 12.618us 12.618us 0.000us 0.00% 0.000us 0.000us 1
------------------------- ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------
Self CPU time total: 19.576ms
Self CUDA time total: 19.710ms
分析器还可以标记代码的各个子块,按输入张量形状分解数据,并将数据导出为Chrome跟踪工具文件。有关API的完整详细信息,请参阅文档。
高级主题:更多自动求导细节和高级API¶
如果您的函数具有n维输入和m维输出,\(\vec{y}=f(\vec{x})\),完整的梯度是一个矩阵,其中包含了每个输出相对于每个输入的导数,称为Jacobian:
如果您有一个第二函数,\(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() 函数也可以接受一个可选的向量输入。这个向量表示张量的一组梯度,这些梯度会乘以前一个由自动微分跟踪的张量的雅可比矩阵。让我们尝试一个具体的例子,使用一个小向量:
x = torch.randn(3, requires_grad=True)
y = x * 2
while y.data.norm() < 1000:
y = y * 2
print(y)
tensor([ 299.4868, 425.4009, -1082.9885], grad_fn=<MulBackward0>)
如果我们现在尝试调用 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)
tensor([1.0240e+02, 1.0240e+03, 1.0240e-01])
(请注意,输出梯度均为2的幂次方——这正是重复加倍操作所预期的结果。)
高级API¶
autograd有一个API,可以直接访问重要的导数矩阵和向量操作。特别是,它允许您为特定输入计算特定函数的雅可比矩阵和海森矩阵。(海森矩阵类似于雅可比矩阵,但表示所有部分二阶导数。)它还提供了这些矩阵进行向量乘积的方法。
让我们计算一个简单函数在单个元素输入下的雅可比矩阵:
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)
(tensor([0.7212]), tensor([0.2079]))
(tensor([[4.1137]]), tensor([[3.]]))
如果您仔细观察,第一个输出应该等于 \(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)
(tensor([0.2080, 0.2604, 0.4415]), tensor([0.5220, 0.9867, 0.4288]))
(tensor([[2.4623, 0.0000, 0.0000],
[0.0000, 2.5950, 0.0000],
[0.0000, 0.0000, 3.1102]]), tensor([[3., 0., 0.],
[0., 3., 0.],
[0., 0., 3.]]))
The 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)
(tensor([-665.7186, -866.7054, -58.4194]), tensor([1.0240e+02, 1.0240e+03, 1.0240e-01]))
The torch.autograd.functional.jvp() 方法执行与 vjp() 相同的矩阵乘法,但操作数顺序相反。The vhp()
和 hvp() 方法执行相同的操作,但针对向量-海森矩阵乘积。
有关更多信息,包括关于函数式API的文档中的性能说明
脚本总运行时间: ( 0 分钟 0.766 秒)