2.5 自动微分

发布时间 2023-05-25 17:12:28作者: AncilunKiang

2.5.1 一个简单的例子

import torch

假设我们函数 \(y=2x^Tx\) 关于列向量 \(x\) 求导。

x = torch.arange(4.0)
x.requires_grad_(True)  # 自动求导机制的必要参数 此处两句等效于 x=torch.arange(4.0,requires_grad=True)
x, x.grad
(tensor([0., 1., 2., 3.], requires_grad=True), None)
y = 2 * torch.dot(x, x)  # 计算y
y
tensor(28., grad_fn=<MulBackward0>)
y.backward()  # 调用反向传播函数自动计算关于 x 的每个分量的梯度
x.grad
tensor([ 0.,  4.,  8., 12.])
x.grad == 4 * x   # 快速验证一下上面计算的梯度是否正确
tensor([True, True, True, True])

我对此的理解是 \(y=2x^Tx\) 关于列向量 \(x = tensor([0.0,\ 1.0,\ 2.0,\ 3.0])\) 求导,实际上就是求 \(y=2x^2\) 的导函数 \(y=4x\)\(x=0.0\)\(x=1.0\)\(x=2.0\)\(x=3.0\)处的值,梯度为 \(\nabla y=\left[4x\right]^T\)

不同于手工计算,先利用求导规则算出导函数 \(y=4x\),然后再分别将上述 \(x\) 值代入。

自动求导相当于把一个自变量 \(x\) 的不同值 \(x=0.0\)\(x=1.0\)\(x=2.0\)\(x=3.0\) 作为不同的自变量 \(x_1\)\(x_2\)\(x_3\)\(x_4\) 分别代入函数中再相加形成一个多元函数,也就是 \(y=2x^Tx=2(x_1^2+x_2^2+\dots+x_n^2)\),再借助求偏导时可以把其他自变量当常数这一点去分别求偏导,又因为是加法,因此对当前变量求偏导时其他自变量求导为0。对于这个由加法生成的多元函数的梯度则为 \(\nabla y=\left[4x_1,4x_2,\dots4x_n\right]^T\),刚好是一元函数的梯度组成的向量。

因此 y 最后一般都采取加法,例如本例和2.5.2中的例子实际上时一样的(就差个系数),因为2.5.2中的例子最后也要调用 sum 函数或者与全一同行矩阵做哈达玛积再求和,使非标量向量转换为标量向量。

'''再试一下另一个函数'''
x.grad.zero_()  # 默认情况下会累积梯度,在求新梯度之前需要进行清除
y = x.sum()  # 显然它的梯度应该是一串1
y.backward()
x.grad
tensor([1., 1., 1., 1.])

2.5.2 非标量变量的反向传播

PyTorch不让张量对张量求导,只允许标量对张量求导。因此,目标量对一个非标量调用 backward() 时需要传入一个 gradient 参数(该参数指定微分函数关于self的梯度,实际上就是 y 和 gradient 做一个点积,这里gradient 参数用的是全一向量,因此用sum是一样的),以使张量对张量的求导转换为标量对张量的求导。

x.grad.zero_()
y = x * x
y.sum().backward()  # 等价于 y.backward(torch.ones(len(x)))
y, y.sum(), x.grad
(tensor([0., 1., 4., 9.], grad_fn=<MulBackward0>),
 tensor(14., grad_fn=<SumBackward0>),
 tensor([0., 2., 4., 6.]))

2.5.1已经论述了为何使用加法,在此参照此文写个例子实验一下。

此例分别计算 \(n_1(m_1,m_2)=m_1^2+3m_2\)\(n_2(m_1,m_2)=2m_1+m_2^2\) 在点 \((m_1,m_2)=(2.0, 3.0)\)\((m_1,m_2)=(6.0, 7.0)\)\((m_1,m_2)=(4.0, 1.0)\) 处的偏导数。

手工计算易得二者的梯度表达式分别是:\(\nabla n_1=\left[2m_1,3\right]^T\)\(\nabla n_2=\left[2,2m_2\right]^T\)

代入数据得:

\(n_1\) 在三个点的梯度分别为:\((4.0,3.0)^T\)\((12.0,3.0)^T\)\((8.0,3.0)^T\).

\(n_2\) 在三个点的梯度分别为:\((2.0,6.0)^T\)\((2.0,14.0)^T\)\((2.0,2.0)^T\)

以下是两种计算方式。

m = torch.tensor([[2., 3.], [6., 7.], [4., 1.]], requires_grad=True)  # 两个自变量的值列表
n = torch.zeros(m.shape[0], m.shape[1])  # 初始化目标张量
for i in range(m.shape[0]):
    n[i][0] = m[i][0] ** 2 + 3 * m[i][1]  # 定义映射关系1
    n[i][1] = 2 * m[i][0] + m[i][1] ** 2  # 定义映射关系2

# retain_graph=True是为了方便多次反向传播
n.backward(torch.Tensor([[1, 0], [1, 0], [1, 0]]), retain_graph=True)  # 此处相当于(n * torch.Tensor([[1, 0], [1, 0], [1, 0]])).sum().backward()
grad1 = m.grad.clone()  # 暂存函数1的求导结果
m.grad.zero_()
n.backward(torch.Tensor([[0, 1], [0, 1], [0, 1]]), retain_graph=True)  # 此处相当于(n * torch.Tensor([[0, 1], [0, 1], [0, 1]])).sum().backward()
torch.stack((grad1, m.grad))  # 扩维拼接
tensor([[[ 4.,  3.],
         [12.,  3.],
         [ 8.,  3.]],

        [[ 2.,  6.],
         [ 2., 14.],
         [ 2.,  2.]]])
m = torch.tensor([[2., 6., 4.], [3., 7., 1.]], requires_grad=True)  # 两个自变量的值列表
n = torch.zeros(m.shape[0], m.shape[1])  # 初始化目标张量
n[0] = m[0] ** 2 + 3 * m[1]  # 定义映射关系1
n[1] = 2 * m[0] + m[1] ** 2  # 定义映射关系2

# retain_graph=True是为了方便多次反向传播
n.backward(torch.Tensor([[1, 1, 1], [0, 0, 0]]), retain_graph=True)  # 此处相当于(n * torch.Tensor([[1, 1, 1], [0, 0, 0]])).sum().backward()
grad1 = m.grad.clone()  # 暂存函数1的求导结果
m.grad.zero_()
n.backward(torch.Tensor([[0, 0, 0], [1, 1, 1]]), retain_graph=True)  # 此处相当于(n * torch.Tensor([[0, 0, 0], [1, 1, 1]])).sum().backward()
torch.stack((grad1, m.grad), dim=0)  # 扩维拼接
tensor([[[ 4., 12.,  8.],
         [ 3.,  3.,  3.]],

        [[ 2.,  2.,  2.],
         [ 6., 14.,  2.]]])

2.5.3 分离计算

假设 \(y\) 是关于 \(x\) 的函数,而 \(z\) 是关于 \(x\)\(y\) 的函数,如果希望在计算 \(z\) 是关于 \(x\) 的梯度时将 \(y\) 视为一个常数且只考虑 \(x\)\(y\) 计算后发挥作用,则可以分离 \(y\) 来返回一个新变量 \(u\),该变量与 \(y\) 具有相同的值,但是丢弃了计算图中如何计算 \(y\) 的任何信息,梯度不会向后流经 \(u\)\(x\).

如下例,计算 \(z=u*x\) 关于 \(x\) 的偏导数时将 \(u\) 作为常数处理,所以偏导数 \(\frac{\partial z(u,x)}{\partial x}=u\),而不是计算 \(z=x*x*x\) 关于 x 的导数 \(\frac{\mathrm{d}z}{\mathrm{d}x}=3x^2\)

x.grad.zero_()
y = x * x
u = y.detach()
z= u * x

z.sum().backward()
x.grad == u
tensor([True, True, True, True])
# 随后再算y也不影响
x.grad.zero_()
y.sum().backward()
x.grad == 2 * x
tensor([True, True, True, True])

2.5.4 Python控制流的梯度计算

自动微分可以兼容一些需要使用Python控制流的复杂函数。

def f(a):
    b = a * 2
    while b.norm()  < 1000:
        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()
a, d, a.grad
(tensor(1.4756, requires_grad=True),
 tensor(1510.9863, grad_fn=<MulBackward0>),
 tensor(1024.))

虽然上述函数很复杂,但是我们至少知道它是线性的,也就是说 \(f(a) = k*a\),所以可以使用 \(d/a\) 验证梯度是否正确。

a.grad == d / a
tensor(True)

练习

(1)为什么计算二阶导数比一阶导数的开销要更大?

二阶那不得多导一遍嘛,肯定开销大。


(2)在运行反向传播函数之后,立即再次运行它,看看会发生什么。

# d.backward() # 会报 Trying to backward through the graph a second time 错,需要加 retain_graph=True

(3)在控制流的例子中,我们计算 \(d\) 关于 \(a\) 的导数,如果将变量 \(a\) 更改为随机向量或矩阵,会发生什么?

a = torch.randn(size=(1, 4), requires_grad=True)  # 换向量的话给 d 调用一下 sum() 即可
d = f(a)
d.sum().backward()
a, d, a.grad, a.grad == d / a  # 每个梯度都一样诶
(tensor([[ 0.4611, -1.3128,  0.9323,  0.0999]], requires_grad=True),
 tensor([[  472.1350, -1344.2858,   954.7211,   102.3088]],
        grad_fn=<MulBackward0>),
 tensor([[1024., 1024., 1024., 1024.]]),
 tensor([[True, True, True, True]]))
a = torch.randn(size=(3, 4), requires_grad=True)  # 换矩阵也是给 d 调用一下 sum() 即可
d = f(a)
d.sum().backward()
a, d, a.grad, a.grad == d / a  # 每个梯度都一样诶
(tensor([[-0.6832, -0.9762, -0.9894, -1.1655],
         [ 0.6858,  0.7037, -0.7619, -0.5135],
         [ 0.0172,  1.4394,  1.6180,  0.8451]], requires_grad=True),
 tensor([[-349.7996, -499.8079, -506.5559, -596.7241],
         [ 351.1539,  360.2910, -390.0962, -262.9074],
         [   8.7919,  736.9765,  828.3993,  432.6812]], grad_fn=<MulBackward0>),
 tensor([[512., 512., 512., 512.],
         [512., 512., 512., 512.],
         [512., 512., 512., 512.]]),
 tensor([[True, True, True, True],
         [True, True, True, True],
         [True, True, True, True]]))

(4)重新设计一个求控制流梯度的例子,运行并分析结果。

试一下导数不存在点求导会怎样。

def g(x):
    if x < 1:  # 可以换 <= 试试
        return x+1
    else:
        return 2*x

x = torch.tensor(1., requires_grad=True)
g1 = g(x)
g1.backward()
x.grad  # 看来是不在意什么分段不分段的
tensor(2.)
def h(x):  # y=|x|
    return abs(x)

x1 = torch.tensor(0., requires_grad=True)
x2 = torch.tensor(1., requires_grad=True)
h1 = h(x1)
h2 = h(x2)
h1.backward()
h2.backward()
x1.grad, x2.grad  # 在不可导点能求出来一个无意义值
(tensor(0.), tensor(1.))

(5)使 \(f(x)=\sin(x)\),绘制 \(f(x)\)\(\frac{\mathrm{d}f(x)}{\mathrm{d}x}\) 的图像,其中后者不使用 \(f'(x)=\cos(x)\)

from d2l import torch as d2l  # 方便调用前一节那个 plot 函数
x = torch.arange(-6.5, 6.5, 0.1, requires_grad=True)
y = torch.sin(x)
y.sum().backward()

# 注意,需要先用 tensor.detach().numpy() 把 tensor 转 array 才能使用
d2l.plot(x.detach().numpy(), [y.detach().numpy(), x.grad.detach().numpy()], 'x', 'f(x)', legend=['f(x)', "f'(x)"])


image