注意
点击 此处 下载完整的示例代码
简介 || 张量 || 自动梯度 || 构建模型 || TensorBoard 支持 || 训练模型 || 模型理解
自动梯度基础¶
请按照以下视频或在 youtube 上观看。
PyTorch 的自动梯度功能是使 PyTorch 能够灵活快速地构建机器学习项目的一部分。它允许快速轻松地计算复杂计算中的多个偏导数(也称为梯度)。此操作是基于反向传播的神经网络学习的核心。
自动梯度的强大之处在于它会在运行时动态跟踪您的计算,这意味着如果您的模型具有决策分支或循环,其长度在运行时之前未知,则计算仍将被正确跟踪,并且您将获得正确的梯度来驱动学习。这与模型是用 Python 构建的事实相结合,提供了比依赖于更严格结构的模型的静态分析来计算梯度的框架更大的灵活性。
我们为什么要使用自动梯度?¶
机器学习模型是一个函数,具有输入和输出。对于本次讨论,我们将输入视为一个i维向量 \(\vec{x}\),其元素为 \(x_{i}\)。然后我们可以将模型M表示为输入的向量值函数:\(\vec{y} = \vec{M}(\vec{x})\)。(我们将 M 输出的值视为向量,因为通常,模型可能具有任意数量的输出。)
由于我们主要在训练的上下文中讨论自动微分(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}\)。根据微积分的链式法则,我们有 \(\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
选项。)设置此标志意味着在随后的每次计算中,自动微分将在该计算的输出张量中累积计算的历史记录。
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 0x7f6feb9315a0>]
让我们仔细看看张量 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 0x7f6feb925e40>
((<MulBackward0 object at 0x7f6feb926380>, 0), (None, 0))
((<SinBackward0 object at 0x7f6feb926380>, 0), (None, 0))
((<AccumulateGrad object at 0x7f6feb925e40>, 0),)
()
c:
<MulBackward0 object at 0x7f6feb926380>
b:
<SinBackward0 object at 0x7f6feb926380>
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 0x7f6fe8103dc0>]
回想一下我们为到达这里而采取的计算步骤
添加常数,就像我们在计算 d
时所做的那样,不会改变导数。这留下了 \(c = 2 * b = 2 * \sin(a)\),其导数应为 \(2 * \cos(a)\)。查看上面的图表,这正是我们看到的。
请注意,只有计算的叶子节点才会计算其梯度。例如,如果你尝试 print(c.grad)
,你将得到 None
。在这个简单的例子中,只有输入是叶子节点,所以只有它计算了梯度。
训练中的自动微分¶
我们简要地了解了自动微分的工作原理,但是当它用于其预期目的时,它是什么样子呢?让我们定义一个小型模型,并检查它在单个训练批次之后如何变化。首先,定义一些常量、我们的模型以及输入和输出的一些替身
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()
之后,大多数梯度的幅度会大得多。在运行下一个训练批次之前,如果未能将梯度归零,则会导致梯度以这种方式爆炸,从而导致学习结果不正确且不可预测。
打开和关闭自动微分¶
在某些情况下,你需要对是否启用自动微分进行细粒度的控制。有多种方法可以做到这一点,具体取决于情况。
最简单的方法是直接更改张量的 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()
,用于在尚未启用时打开自动微分。它也可以用作装饰器。
最后,你可能有一个需要梯度跟踪的张量,但你想要一个不需要跟踪的副本。为此,我们有 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 数组作为输入,并且对于 requires_grad=True
的张量,不会启用从 PyTorch 张量到 NumPy 数组的隐式转换。制作分离的副本让我们可以继续前进。
自动微分和就地操作¶
在本笔记本中到目前为止的每个示例中,我们都使用了变量来捕获计算的中间值。自动微分需要这些中间值来执行梯度计算。因此,在使用自动微分时,必须小心使用就地操作。这样做可能会破坏在 backward()
调用中计算导数所需的信息。如果尝试对需要自动微分的叶子变量执行就地操作,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'))
/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 42.58% 8.713ms 42.58% 8.713ms 2.178us 0.000us 0.00% 0.000us 0.000us 4000
aten::div 29.08% 5.952ms 29.08% 5.952ms 5.952us 10.480ms 50.84% 10.480ms 10.480us 1000
aten::mul 27.35% 5.598ms 27.35% 5.598ms 5.598us 10.133ms 49.16% 10.133ms 10.133us 1000
cudaGetDeviceProperties_v2 0.89% 182.179us 0.89% 182.179us 182.179us 0.000us 0.00% 0.000us 0.000us 1
cudaDeviceSynchronize 0.05% 11.191us 0.05% 11.191us 11.191us 0.000us 0.00% 0.000us 0.000us 1
cudaStreamIsCapturing 0.03% 5.969us 0.03% 5.969us 1.492us 0.000us 0.00% 0.000us 0.000us 4
cudaDeviceGetStreamPriorityRange 0.01% 2.221us 0.01% 2.221us 2.221us 0.000us 0.00% 0.000us 0.000us 1
cudaGetDeviceCount 0.00% 0.541us 0.00% 0.541us 0.270us 0.000us 0.00% 0.000us 0.000us 2
------------------------------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------ ------------
Self CPU time total: 20.464ms
Self CUDA time total: 20.613ms
分析器还可以标记代码的各个子块、根据输入张量形状分解数据,并将数据导出为 Chrome 追踪工具文件。有关 API 的完整详细信息,请参阅文档。
高级主题:更多 Autograd 细节和高级 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)
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 可以让您直接访问重要的微分矩阵和向量运算。特别是,它允许您计算特定函数在特定输入下的雅可比矩阵和Hessian 矩阵。(Hessian 矩阵类似于雅可比矩阵,但表示所有偏二阶导数。)它还提供了一些方法来使用这些矩阵进行向量积运算。
让我们取一个简单函数的雅可比矩阵,针对 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)
(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.]]))
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]))
torch.autograd.functional.jvp()
方法执行与 vjp()
相同的矩阵乘法,但操作数顺序相反。vhp()
和 hvp()
方法对向量-Hessian 积执行相同的操作。
有关更多信息,包括 功能 API 文档 中的性能说明
脚本的总运行时间:(0 分 0.816 秒)