自动微分
🏷sec_autograd
参考动手学深度学习教材对应章节:https://zh-v2.d2l.ai/
根据课程相关章节的Jupyter文件进行运行得到结果并导出。
正如我们在 :numref:sec_calculus
中所说的那样,求导是几乎所有深度学习优化算法的关键步骤。
虽然求导的计算很简单,只需要一些基本的微积分。
但对于复杂的模型,手工进行更新是一件很痛苦的事情(而且经常容易出错)。
深度学习框架通过自动计算导数,即自动微分(automatic differentiation)来加快求导。
实际中,根据我们设计的模型,系统会构建一个计算图(computational graph),
来跟踪计算是哪些数据通过哪些操作组合起来产生输出。
自动微分使系统能够随后反向传播梯度。
这里,反向传播(backpropagate)意味着跟踪整个计算图,填充关于每个参数的偏导数。
一个简单的例子
作为一个演示例子,(假设我们想对函数
y
=
2
x
⊤
x
y=2\mathbf{x}^{\top}\mathbf{x}
y=2x⊤x关于列向量
x
\mathbf{x}
x求导)。
首先,我们创建变量x
并为其分配一个初始值。
import torch
x = torch.arange(4.0)
x
tensor([0., 1., 2., 3.])
[在我们计算
y
y
y关于
x
\mathbf{x}
x的梯度之前,我们需要一个地方来存储梯度。]
重要的是,我们不会在每次对一个参数求导时都分配新的内存。
因为我们经常会成千上万次地更新相同的参数,每次都分配新的内存可能很快就会将内存耗尽。
注意,一个标量函数关于向量
x
\mathbf{x}
x的梯度是向量,并且与
x
\mathbf{x}
x具有相同的形状。
x.requires_grad_(True) # 等价于x=torch.arange(4.0,requires_grad=True) 请求存放梯度的内存空间
x.grad # 默认值是None
x.grad
(现在让我们计算 y y y。)
y = 2 * torch.dot(x, x) # 设置点乘
y # MulBackward0:乘法求导
tensor(28., grad_fn=<MulBackward0>)
x
是一个长度为4的向量,计算x
和x
的点积,得到了我们赋值给y
的标量输出。
接下来,我们[通过调用反向传播函数来自动计算y
关于x
每个分量的梯度],并打印这些梯度。
y.backward() # 反向传播自动求梯度
x.grad,y, x
(tensor([ 0., 4., 8., 12.]),
tensor(28., grad_fn=<MulBackward0>),
tensor([0., 1., 2., 3.], requires_grad=True))
函数
y
=
2
x
⊤
x
y=2\mathbf{x}^{\top}\mathbf{x}
y=2x⊤x关于
x
\mathbf{x}
x的梯度应为
4
x
4\mathbf{x}
4x。
让我们快速验证这个梯度是否计算正确。
x.grad == 4 * x
tensor([True, True, True, True])
[现在让我们计算x
的另一个函数。]
# 在默认情况下,PyTorch会累积梯度,我们需要清除之前的值
x.grad.zero_() # _下划线表示重写内容,zero表示重写内容为0
y = x.sum() # SumBackward0: 总和求导
y.backward()
x.grad, y, x
(tensor([1., 1., 1., 1.]),
tensor(6., grad_fn=<SumBackward0>),
tensor([0., 1., 2., 3.], requires_grad=True))
非标量变量的反向传播
当y
不是标量时,向量y
关于向量x
的导数的最自然解释是一个矩阵。
对于高阶和高维的y
和x
,求导的结果可以是一个高阶张量。
然而,虽然这些更奇特的对象确实出现在高级机器学习中(包括[深度学习中]),
但当我们调用向量的反向计算时,我们通常会试图计算一批训练样本中每个组成部分的损失函数的导数。
这里(,我们的目的不是计算微分矩阵,而是单独计算批量中每个样本的偏导数之和。)
# 对非标量调用backward需要传入一个gradient参数,该参数指定微分函数关于self的梯度。有待实现。
# 在我们的例子中,我们只想求偏导数的和,所以传递一个1的梯度是合适的
x.grad.zero_()
y = x * x
# 等价于y.backward(torch.ones(len(x)))
y.sum().backward()
x.grad, y, x
(tensor([0., 2., 4., 6.]),
tensor([0., 1., 4., 9.], grad_fn=<MulBackward0>),
tensor([0., 1., 2., 3.], requires_grad=True))
x = torch.arange(9,).reshape(3,3)
x1 = x.float()
x1.requires_grad_(True)
y = x1 * x1
y.sum(),y, y.sum(axis=1,keepdims=True),torch.ones(len(x)) # 求导的结果是一个高阶张量
y.sum().backward()
x1.grad, x1
(tensor([[ 0., 2., 4.],
[ 6., 8., 10.],
[12., 14., 16.]]),
tensor([[0., 1., 2.],
[3., 4., 5.],
[6., 7., 8.]], requires_grad=True))
分离计算
有时,我们希望[将某些计算移动到记录的计算图之外]。
例如,假设y
是作为x
的函数计算的,而z
则是作为y
和x
的函数计算的。
想象一下,我们想计算z
关于x
的梯度,但由于某种原因,我们希望将y
视为一个常数,
并且只考虑到x
在y
被计算后发挥的作用。
在这里,我们可以分离y
来返回一个新变量u
,该变量与y
具有相同的值,
但丢弃计算图中如何计算y
的任何信息。
换句话说,梯度不会向后流经u
到x
。
因此,下面的反向传播函数计算z=u*x
关于x
的偏导数,同时将u
作为常数处理,
而不是z=x*x*x
关于x
的偏导数。
x = torch.arange(4.0)
x.requires_grad_()
y = x * x
u = y.detach() # 分离计算:将u作为常数
z = u * x
z.sum().backward()
x.grad == u, u, x.grad
(tensor([True, True, True, True]),
tensor([0., 1., 4., 9.]),
tensor([0., 1., 4., 9.]))
由于记录了y
的计算结果,我们可以随后在y
上调用反向传播,
得到y=x*x
关于的x
的导数,即2*x
。
x.grad.zero_()
y.sum().backward()
x.grad == 2 * x,x.grad
(tensor([True, True, True, True]), tensor([0., 2., 4., 6.]))
x, x.norm()
(tensor([0., 1., 2., 3.], requires_grad=True),
tensor(3.7417, grad_fn=<CopyBackwards>))
Python控制流的梯度计算
使用自动微分的一个好处是:
[即使构建函数的计算图需要通过Python控制流(例如,条件、循环或任意函数调用),我们仍然可以计算得到的变量的梯度]。
在下面的代码中,while
循环的迭代次数和if
语句的结果都取决于输入a
的值。
def f(a):
b = a * 2
while b.norm() < 1000: # norm()求L2范数
b = b * 2
if b.sum() > 0:
c = b
else:
c = 100 * b
return c
让我们计算梯度。
a = torch.randn(size=(),requires_grad=True) # randn表示正态分布,size=()默认值为1]
d = f(a)
d.backward(retain_graph=True)
a, d, a.grad # 梯度等于所有常系数相乘
(tensor(1.2870, requires_grad=True),
tensor(1317.8727, grad_fn=<MulBackward0>),
tensor(1024.))
# 练习二:如果重复执行反向传播函数backward会发生什么?如何处理这种情况呢?
d.backward(retain_graph=True)
a, d, a.grad # 梯度等于所有常系数相乘
(tensor(1.2870, requires_grad=True),
tensor(1317.8727, grad_fn=<MulBackward0>),
tensor(2048.))
# 练习四:重新设计一个控制流的例子。求梯度需要指定自变量x和因变量grad_fn
a = torch.tensor([1.0,2.0,3.0,4.0],requires_grad=True)
d = f(a)
d.sum().backward()
a, d, d.sum(), a.grad
(tensor([1., 2., 3., 4.], requires_grad=True),
tensor([ 256., 512., 768., 1024.], grad_fn=<MulBackward0>),
tensor(2560., grad_fn=<SumBackward0>),
tensor([256., 256., 256., 256.]))
我们现在可以分析上面定义的f
函数。
请注意,它在其输入a
中是分段线性的。
换言之,对于任何a
,存在某个常量标量k
,使得f(a)=k*a
,其中k
的值取决于输入a
。
因此,我们可以用d/a
验证梯度是否正确。
a.grad == d / a,a.grad, d/a # 除法反向传播
(tensor([True, True, True, True]),
tensor([256., 256., 256., 256.]),
tensor([256., 256., 256., 256.], grad_fn=<DivBackward0>))
# 练习五:绘制正弦函数及其导数的图像
import numpy as np
import torch
import matplotlib.pyplot as plt
x = np.arange(-5, 5, 0.02)
f = np.sin(x)
df = []
for i in x:
# 对于x中的每个值,都求一下导
v = torch.tensor(i, requires_grad=True)
y = torch.sin(v)
y.backward()
df.append(v.grad)
# Create plots with pre-defined labels.
fig, ax = plt.subplots()
ax.plot(x, f, 'g', label='f(x)')
ax.plot(x, df, 'b:', label='df(x)')
legend = ax.legend(loc='upper left', shadow=True, fontsize='x-large')
# Put a nicer background color on the legend.
legend.get_frame().set_facecolor('w')
plt.show()
小结
- 深度学习框架可以自动计算导数:我们首先将梯度附加到想要对其计算偏导数的变量上。然后我们记录目标值的计算,执行它的反向传播函数,并访问得到的梯度。
练习
- 为什么计算二阶导数比一阶导数的开销要更大?
答:二阶导数是在一阶导数的基础上进行在求导,自然比一阶导数开销更大,这个开销可以说是时间开销或者空间开销。 - 在运行反向传播函数之后,立即再次运行它,看看会发生什么。
答:发生运行时错误,对于Pytorch来说,前向过程建立计算图,反向传播后释放。因为计算图的中间结果已经被释放了,所以第二次运行反向传播就会出错。这时在 backward 函数中加上参数 retain_graph=True,就能两次运行反向传播了。
梯度会呈现累加现象。pytorch默认每重复计算一遍梯度会进行叠加一次,可以通过x.grad.zero_()函数对梯度进行归零去除这累加机制。那pytorch官方设计时为什么要这样设计呢? 简单言之,这种做法能够对计算机的内存充分利用。 - 在控制流的例子中,我们计算
d
关于a
的导数,如果我们将变量a
更改为随机向量或矩阵,会发生什么?
答:发生运行时错误,在Pytorch中,不让张量对张量求导,只允许标量对张量求导。如果想对一个非标量调用backward(),则需要传入一个gradient参数。 - 重新设计一个求控制流梯度的例子,运行并分析结果。
答:练习4。 - 使
f
(
x
)
=
sin
(
x
)
f(x)=\sin(x)
f(x)=sin(x),绘制
f
(
x
)
f(x)
f(x)和
d
f
(
x
)
d
x
\frac{df(x)}{dx}
dxdf(x)的图像,其中后者不使用
f
′
(
x
)
=
cos
(
x
)
f'(x)=\cos(x)
f′(x)=cos(x)。
答:练习5。