求导是几乎所有深度学习优化算法的关键步骤。虽然求导的计算很简单,只需要一些基本的微积分,但对于复杂的模型,手工进行更新是一件很痛苦的事情(而且经常容易出错)。
深度学习框架通过自动计算导数,即自动微分(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)
- 在我们计算
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
- 计算y。
y = 2 * torch.dot(x, x)
print(y)
x
是一个长度为4的向量,计算x
和x
的点积,得到了我们赋值给y
的标量输出,接下来通过调用反向传播函数来自动计算y
关于x
每个分量的梯度,并打印这些梯度。
print(y.backward())
print(x.grad)
- 函数 y = 2 x ⊤ x y=2\mathbf{x}^{\top}\mathbf{x} y=2x⊤x关于 x \mathbf{x} x的梯度应为 4 x 4\mathbf{x} 4x,验证这个梯度是否计算正确。
print(x.grad == 4 * x)
- 计算
x
的另一个函数。
x.grad.zero_()
y = x.sum()
print(y.backward())
print(x.grad)
完整代码:
import torch
x = torch.arange(4.0)
print(x) # tensor([0., 1., 2., 3.])
x.requires_grad_(True) # 等价于x=torch.arange(4.0,requires_grad=True)
x.grad # 默认值是None
y = 2 * torch.dot(x, x) # 计算x的点积
print(y) # tensor(28., grad_fn=<MulBackward0>)
y.backward() # 反向传播
print(x.grad) # tensor([ 0., 4., 8., 12.])
print(x.grad == 4 * x) # tensor([True, True, True, True])
x.grad.zero_()
y = x.sum()
y.backward() # 反向传播
print(x.grad) # tensor([1., 1., 1., 1.])
非标量变量的反向传播
- 当
y
不是标量时,向量y
关于向量x
的导数的最自然解释是一个矩阵,对于高阶和高维的y
和x
,求导的结果可以是一个高阶张量。 - 然而,虽然这些更奇特的对象确实出现在高级机器学习中(包括[深度学习中]),但当调用向量的反向计算时,我们通常会试图计算一批训练样本中每个组成部分的损失函数的导数,这里(我们的目的不是计算微分矩阵,而是单独计算批量中每个样本的偏导数之和。)
import torch
x=torch.arange(4.0,requires_grad=True)
# 对非标量调用backward需要传入一个gradient参数,该参数指定微分函数关于self的梯度。
# 本例只想求偏导数的和,所以传递一个1的梯度是合适的
y = x * x
# 等价于y.backward(torch.ones(len(x)))
y.sum().backward()
print(x.grad) # tensor([0., 2., 4., 6.])
分离计算
有时,我们希望将某些计算移动到记录的计算图之外。
例如,假设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
的偏导数。
import torch
x=torch.arange(4.0,requires_grad=True)
# 对非标量调用backward需要传入一个gradient参数,该参数指定微分函数关于self的梯度。
# 本例只想求偏导数的和,所以传递一个1的梯度是合适的
y = x * x
# 等价于y.backward(torch.ones(len(x)))
y.sum().backward()
print(x.grad) # tensor([0., 2., 4., 6.])
x.grad.zero_()
y = x * x
u = y.detach()
z = u * x
z.sum().backward()
print(x.grad == u) # tensor([True, True, True, True])
# 由于记录了y的计算结果,我们可以随后在y上调用反向传播, 得到y=x*x关于的x的导数,即2*x。
x.grad.zero_()
y.sum().backward()
print(x.grad == 2 * x) # tensor([True, True, True, True])
Python控制流的梯度计算
使用自动微分的一个好处是:即使构建函数的计算图需要通过Python控制流(例如,条件、循环或任意函数调用),我们仍然可以计算得到的变量的梯度。
在下面的代码中,while
循环的迭代次数和if
语句的结果都取决于输入a
的值。
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
让我们计算梯度。
import torch
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
a = torch.randn(size=(), requires_grad=True)
d = f(a)
d.backward()
#我们现在可以分析上面定义的f函数。 请注意,它在其输入a中是分段线性的。 换言之,对于任何a,存在某个常量标量k,使得f(a)=k*a,其中k的值取决于输入a,因此可以用d/a验证梯度是否正确。
print(a.grad == d / a) # tensor(True)
小结
深度学习框架可以自动计算导数:我们首先将梯度附加到想要对其计算偏导数的变量上,然后记录目标值的计算,执行它的反向传播函数,并访问得到的梯度。
练习
- 为什么计算二阶导数比一阶导数的开销要更大?
- 在运行反向传播函数之后,立即再次运行它,看看会发生什么。
- 在控制流的例子中,我们计算
d
关于a
的导数,如果将变量a
更改为随机向量或矩阵,会发生什么? - 重新设计一个求控制流梯度的例子,运行并分析结果。
- 使 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)。
练习答案
- 二阶导数是一阶导数的导数,计算是需要用到一阶导数,因此开销会比计算一阶导数大。
- 会报错,进行一次backward后,计算过程中的中间变量在计算完后马上被释放,无法进行二次backward,如果想进行第二次backward,可以将retain_graph置为True
- 可以看出第二次的backward其实是将梯度再回传一遍叠加在第一次backward上面
import torch
x = torch.arange(4.0)
x.requires_grad_(True) # 等价于x=torch.arange(4.0,requires_grad=True)
y = 2 * torch.dot(x, x) # 计算x的点积
#----------------------------------------------------------------------------------------------------------------------------------------------------
# y.backward() # 反向传播
# print(x.grad) # tensor([ 0., 4., 8., 12.])
# y.backward()
# # RuntimeError: Trying to backward through the graph a second time (or directly access saved tensors after they have already been freed).
# # Saved intermediate values of the graph are freed when you call .backward() or autograd.grad().
# # Specify retain_graph=True if you need to backward through the graph a second time or if you need to access saved tensors after calling backward.
#----------------------------------------------------------------------------------------------------------------------------------------------------
y.backward(retain_graph= True)
print(x.grad) # tensor([ 0., 4., 8., 12.])
y.backward()
print(x.grad) # tensor([ 0., 8., 16., 24.])
- 会报 RuntimeError: grad can be implicitly created only for scalar outputs 错误。
import torch
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
a = torch.randn((4), requires_grad=True)
d = f(a)
d.backward()
# RuntimeError: grad can be implicitly created only for scalar outputs
# 因为此时的y并不是一个标量(即它包含一个元素的数据)
# 意思是只有对标量输出它才会计算梯度,而求一个矩阵对另一矩阵的导数束手无策。
根据文档 如果 Tensor 是一个标量(即它包含一个元素的数据),则不需要为 backward() 指定任何参数,但是如果它有更多的元素,则需要指定一个 gradient 参数,该参数是形状匹配的张量。(也就是对于矩阵求导来说,需要一个额外的参数矩阵和需要求导的矩阵做点乘。 一般点乘的矩为全1的对应形状的矩阵。 也就是乘以全1的矩阵,等价于sum() )
import torch
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
a = torch.randn((4), requires_grad=True)
d = f(a)
d.backward(torch.ones(4))
print(a.grad) # tensor([1024., 1024., 1024., 1024.])
详细解释请参考这篇博文:grad can be implicitly created only for scalar outputs
- 重新设计一个求控制流梯度的例子,运行并分析结果。
import torch
def f(a, choice):
a = a * 2
if(choice == 0):
b = a * a
elif(choice == 1):
b = torch.sqrt(a)
else:
return a
return b
a = torch.rand(size=(), requires_grad=True)
print(a) # tensor(0.7559, requires_grad=True)
d = f(a, 0)
d.backward()
print(a.grad) # tensor(6.0473) d=4a^2 d'= 8a=8*0.7559
a.grad.zero_()
d = f(a, 1)
d.backward()
print(a.grad) # tensor(0.8133) d=sqrt(2a) d'=(2a)^(-1/2) = 1/sqrt(2*0.7559)
a.grad.zero_()
d = f(a, 2)
d.backward()
print(a.grad) # tensor(2.) d=2a d'=2
- 使 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)。
import torch
import matplotlib.pyplot as plt
import numpy as np
# [-3π,3π]之间100个点
tx = np.linspace(-3 * np.pi,3 * np.pi,100)
# 转化为张量
x= torch.tensor(tx, requires_grad=True)
# 获取y轴数据
y = torch.sin(x)
# 求x对应点的导数值
y.backward(torch.ones_like(x))
# 创建画布
f,ax=plt.subplots(1)
# 画图
ax.plot(tx,np.sin(tx),label="sin(x)")
ax.plot(tx,x.grad,label="cos(x)")
ax.legend(loc='upper right', shadow=True)
plt.show()
居中并且带尺寸的图片: