深度学习框架通过自动计算导数,即自动微分(automatic differentiation)来加快求导。 实际中,根据我们设计的模型,系统会构建一个计算图(computational graph), 来跟踪计算是哪些数据通过哪些操作组合起来产生输出。 自动微分使系统能够随后反向传播梯度。 这里,反向传播(backpropagate)意味着跟踪整个计算图,填充关于每个参数的偏导数。
下面的求导计算多是采用分子布局的形式,关于分子布局和分母布局的说明可以看我的这篇文章:矩阵求导之布局分析
说明:
说明:
正向:从下(外层)往上(内层)执行图,需要存储中间结果,且不能剪“枝”——参数后续有用;
反向:从相反方向执行图,当层计算完后,就可以去除不需要的“枝”——已经使用完的参数;
1)左图中演示的是正向传播计算过程:
2)右图中演示的是反向传播求导过程:
正向累积(正向传播):
- 计算复杂度:O(n),n是操作子个数(类别计算图中层数);
- 通常正向和反向的代价类似;
- 内存复杂度:O(n),因为需要存储正向的所有中间结果。
反向累积(反向传播):
- 计算复杂度:O(n),每一个变量计算一次梯度;
- 内存复杂度:O(1),从头到尾依次计算结果。
PyTorch中主要使用的求梯度方法是反向传播
一个简单的例子:假设我们想对函数 关于列向量 求导。
1)首先,我们创建 变量x 并为其分配一个初始值:
import torch
x = torch.arange(4.0)
x
tensor([0., 1., 2., 3.]) |
2)为 变量x 分配一块内存空间来存储梯度:
注意,我们不会在对一个参数的每次求导时都分配新的内存, 因为我们经常会成千上万次地更新相同的参数,每次都分配新的内存可能很快就会将内存耗尽;
注意,一个 标量函数 关于 向量x 的梯度是向量,并且与x具有相同的形状。
x.requires_grad_(True) # 等价于x=torch.arange(4.0,requires_grad=True)
# 实质是申明变量x需要计算梯度,因此框架会为其分配空间
print(x.grad) # 默认值是None
None |
3)计算 函数y 的值:
y = 2 * torch.dot(x, x) # 1*1+2*2+3*3+4*4=28
y
tensor(28., grad_fn= |
4)接下来,我们通过调用反向传播函数 backward 来自动计算 y 关于 x 每个分量方向上的梯度值,并调用 x 的 grad 属性打印这些梯度:
y.backward() # 反向传播计算梯度
x.grad # 查看计算的梯度值
tensor([ 0., 4., 8., 12.]) |
5)验证梯度正确性:
x.grad == 4 * x
tensor([ 0., 4., 8., 12.]) |
6)紧接着计算关于 变量x 的另一个函数:
x.grad.zero_()
y = x.sum() # 0+1+2+3=6
print(y,x.grad)
tensor(6., grad_fn= |
y.backward()
x.grad
tensor([1., 1., 1., 1.]) |
当 函数y 不是标量时,向量y 关于 向量x 的导数的最自然解释是一个矩阵;
我们可以采用下面两种方式调用 backward ,正常求得梯度:
backword内存在一个权重参数,这个参数的长度对应输出的个数,元素值为每个输出对与其相关的输入求导后的权重值;
- backword在不手动设置权重参数时,默认值是标量1 ,表明只有一个输出项,且 该输出项 在对 与它相关的输入项 求梯度后,所得的结果*权重值1即可。因此我们可以使用sum函数来将向量函数y转换为标量函数,即将输出项的个数变为1。
- 我们自己指定backword的权重参数时,需要考虑的就是输出项的个数,以及我们希望每个输出项求得的梯度应当附加的权重值。
x.grad.zero_()
y = x * x # 注意这里不是内积运算,而是x内每个元素求平方,运算结果是一个向量
print(y) # 此时y是一个向量
y.sum().backward() # 等价于y.backward(torch.ones(len(x)))
# torch.ones(len(x)):生成与x长度相同的全1张量
x.grad
tensor([0., 1., 4., 9.], grad_fn= tensor([0., 2., 4., 6.]) |
下面用 的情况来简单演示上面求梯度的原理( 太占篇幅啦!):
注意:backward的权重参数是我们为相关输入项整体的梯度附上的权重,与神经网络计算输出值时使用的权重不一样!
x.grad.zero_()
y = x * x
y.backward(torch.ones(len(x)))
print(x.grad)
x.grad.zero_()
y = x * x
y.backward(torch.tensor([1,2,3,4]))
print(x.grad)
x.grad.zero_()
y = x * x
y.sum().backward(torch.tensor(10))
print(x.grad)
tensor([0., 2., 4., 6.]) tensor([ 0., 4., 12., 24.]) tensor([ 0., 20., 40., 60.]) |
有时,我们希望将某些计算移动到记录的计算图之外。 例如:
假设 y 是作为 x 的函数计算得到的;而 z 则是作为 y 和 x 的函数计算得到的;想象一下,我们想计算 z 关于 x 的梯度,但由于某种原因,我们希望将 y 视为一个常数, 并且只考虑到 x 在 y 被计算后发挥的作用。
在这里,我们可以使用 detach 分离 y 来返回一个新变量 u,该变量与 y 具有相同的值, 但 u 丢弃了计算图中如何计算 y 的全部信息。 换句话说,梯度不会向后流经 u 到 x。 因此,下面的反向传播函数 backward 计算 z=u*x 关于 x 的偏导数,同时将 u 作为常数处理, 而不是 z=x*x*x 关于 x 的偏导数。
x.grad.zero_()
y = x * x
u = y.detach()
z = u * x
z.sum().backward()
x.grad == u
tensor([True, True, True, True]) |
但 y 任然保留了自身的计算结果,我们可以随后在 y 上调用反向传播函数, 得到 y=x*x 关于的 x 的导数,即 2*x。
x.grad.zero_()
y.sum().backward()
x.grad == 2 * x
tensor([True, True, True, True]) |
Pytorch使用自动微分的一个好处是: 即使构建函数的计算图需要通过Python控制流(例如,条件、循环或任意函数调用),我们仍然可以计算得到的变量的梯度。
def f(a):
b = a * 2
# norm用于求范数,ord参数默认为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) # 生成一个随机整数a,申明需要计算梯度
d = f(a)
d.backward()
a.grad == d / a
tensor(True) |
a = torch.randn(size=(4,),requires_grad=True)
print(a)
d = f(a)
d.backward(torch.ones(len(a))) # 或d.sum().backward()
a.grad == d / a
tensor([-0.8285, -0.1860, -0.2410, -0.1880], requires_grad=True) tensor([True, True, True, True]) |
题5:使 f(x)=sin(x),绘制 f(x) 和 df(x)/dx 的图像,其中后者不使用 f′(x)=cos(x) 。
import torch
import matplotlib.pyplot as plt
x = torch.arange(-4000,4000)/1000
x.requires_grad=True
# 绘制函数:y=sin(x)
y = torch.sin(x)
plt.plot(x.detach().numpy().reshape(-1,1),y.detach().numpy().reshape(-1,1))
# 绘制导数:dy/dx=cos(x)
y.backward(torch.ones(len(x)))
plt.plot(x.detach().numpy().reshape(-1,1),x.grad.detach().numpy().reshape(-1,1))
plt.legend(["sin(x)","cos(x)"])
plt.show()