动手学深度学习-预备知识5-自动微分

196 阅读5分钟

1. 自动微分:让计算变得简单

在深度学习中,求导 是几乎所有优化算法的核心步骤。虽然在简单模型中,求导往往只需要一些基本的微积分技巧,但随着模型复杂度的增加,手动求导变得异常繁琐且容易出错。为了解决这个问题,自动微分(Automatic Differentiation,简称 AD)应运而生。自动微分能够高效地帮助我们计算复杂模型中的梯度,极大地简化了深度学习中的优化过程。

2. 自动微分是如何工作的?

当我们设计一个模型时,深度学习框架会构建一个计算图(Computational Graph)。这个图记录了从输入数据到输出结果的所有操作步骤。随后,通过 反向传播(Backpropagation),框架能够自动计算出目标函数关于模型参数的导数。这个过程就是 自动微分

简单来说,反向传播会遍历整个计算图,并且通过链式法则依次计算每个参数的偏导数,从而更新模型参数。

3. 一个简单的例子

为了更好地理解自动微分,下面是一个简单的例子。假设我们有一个向量 𝑥\mathbf{𝑥} ,想要计算一个关于 𝑥\mathbf{𝑥} 的函数,并求出其梯度。

  1. 首先,我们定义向量 x\mathbf{x}:
import torch

x = torch.arange(4.0, requires_grad=True)
print(x)  # tensor([0., 1., 2., 3.], requires_grad=True)
  1. 然后,我们定义函数 y=2xTx\mathbf{y}=2\cdot \mathbf{x}^T \cdot\mathbf{x},也就是 𝑦\mathbf{𝑦}𝑥\mathbf{𝑥} 的自乘积再乘以 2:
y = 2 * torch.dot(x, x)
print(y)  # tensor(28., grad_fn=<MulBackward0>)
  1. 我们现在对 𝑦𝑦 进行 反向传播,并查看 𝑦𝑦 关于 𝑥𝑥 的梯度:
y.backward()
print(x.grad)  # tensor([ 0.,  4.,  8., 12.])

手动验证一下这个梯度:对于函数 y=2xTx\mathbf{y}=2\cdot \mathbf{x}^T \cdot\mathbf{x},其梯度应该为 4x4\mathbf{x},我们可以用代码验证这一点:

print(x.grad == 4 * x)  # tensor([True, True, True, True])

4. 非标量输出的梯度

当函数的输出 𝑦\mathbf{𝑦} 不是标量时,反向传播计算的结果会是一个矩阵。这时,我们需要对输出进行进一步处理。

例如,假设我们有:

x = torch.arange(4.0, requires_grad=True)
print(x)  # tensor([0., 1., 2., 3.], requires_grad=True)

y = x * x
print(y)  # tensor([0., 1., 4., 9.], grad_fn=<MulBackward0>)

在这种情况下,输出 𝑦𝑦 不是标量,而是一个向量。我们可以对其进行求和操作,然后计算梯度:

y.sum().backward()
print(x.grad)  # tensor([0., 2., 4., 6.])

这表明每个元素的梯度与输入 𝑥𝑥 的导数是一一对应的。

5. 分离计算图

在深度学习中,当我们进行反向传播时,系统会计算我们指定变量的梯度。这个过程依赖于一个计算图,它会记录从输入到输出的所有运算。这让我们可以在反向传播时追踪变量之间的依赖关系,自动计算每个参数的导数。

然而,有些情况下我们并不希望所有计算都记录在计算图中。比如,在某个计算步骤中,我们不希望“追踪”变量的变化——我们想把它当作一个常量来使用,而不是用来计算梯度的变量。这时,就可以用“分离计算图”的方法,把计算图中的一部分“切断”,让它不参与反向传播。

为什么要分离?

  • 计算效率:减少不必要的计算,节省资源。
  • 灵活性:在复杂模型中,可能需要对某些变量的梯度进行手动控制或不计算梯度,以便实现特定需求。

假设我们有一个向量 𝑥𝑥,我们想计算两个变量 𝑦\mathbf{𝑦}𝑧\mathbf{𝑧},并在某些情况下只对 x\mathbf{x} 的一部分操作求导。

  1. 初始化向量 x\mathbf{x}
import torch

x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)  # 向量 x
  1. 计算 y\mathbf{y}z\mathbf{z}
    • 计算 y=x2\mathbf{y} = \mathbf{x}^2
    • 分离 𝑦\mathbf{𝑦}:我们通过 .detach()𝑦\mathbf{𝑦} 从计算图中“分离”出来,生成一个新的变量 𝑢\mathbf{𝑢}
y = x * x
print(y)  # tensor([1., 4., 9.], grad_fn=<MulBackward0>)

u = y.detach()  # 将 y 的值分离到 u 中,u 不会记录计算图信息
  1. 计算 z=u×x\mathbf{z} = \mathbf{u} \times \mathbf{x}
# 现在我们用分离后的 𝑢 来计算 𝑧,得到:
z = u * x
print(z)  # tensor([ 1.,  8., 27.], grad_fn=<MulBackward0>)
  1. 反向传播

当我们调用 z.sum().backward() 时,计算图只会对 𝑥\mathbf{𝑥} 的导数进行计算,并且在计算 𝑧\mathbf{𝑧} 的时候,不会追踪 𝑢\mathbf{𝑢} 是如何通过 𝑥\mathbf{𝑥} 计算得来的。

z.sum().backward()
print(x.grad)  # tensor([1., 4., 9.])
print(x.grad == u)  # tensor([True, True, True])

结果:反向传播过程中,梯度只会在 z=u×x\mathbf{z} = \mathbf{u} \times \mathbf{x} 处停止,不会计算 y=x2\mathbf{y} = \mathbf{x}^2𝑥\mathbf{𝑥} 的影响。

  1. 不分离时的情况
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)  # 向量 x

y = x * x
print(y)  # tensor([1., 4., 9.], grad_fn=<MulBackward0>)

u = y.clone()  # 这里并没有分离出 u

# 现在我们用未分离的 𝑢 来计算 𝑧,得到:
z = y * x
print(z)  # tensor([ 1.,  8., 27.], grad_fn=<MulBackward0>)

z.sum().backward()
print(x.grad)  # tensor([ 3., 12., 27.])

6. 控制流中的自动微分

自动微分的强大之处在于,即使我们的函数包含复杂的控制流结构(如条件判断或循环),我们依然能够计算出梯度。来看一个使用 while 循环的例子:

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


torch.manual_seed(49)

a = torch.randn(size=(), requires_grad=True)
print(a)  # tensor(-2.0157, requires_grad=True)
d = f(a)
print(d)  # tensor(-103203.8359, grad_fn=<MulBackward0>)
d.backward()
print(a.grad)  # tensor(51200.)

我们现在可以分析上面定义的 ff 函数。 请注意,它在其输入 aa 中是分段线性的。 换言之,对于任何 aa,存在某个常量标量 kk,使得 f(a)=kaf(a)=k*a,其中 kk 的值取决于输入 aa,因此可以用 d/ad/a 验证梯度是否正确。

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

小结

通过自动微分,我们能够极大地简化深度学习中的梯度计算。无论是复杂的计算图,还是带有控制流的函数,自动微分都能轻松应对。这使得我们可以专注于模型设计,而不必担心繁琐的数学推导。