注意
跳转至页面底部 下载完整示例代码。
雅可比矩阵、海森矩阵、hvp、vhp 等:组合函数变换#
创建日期:2023年3月15日 | 最后更新:2023年4月18日 | 最后验证:2024年11月5日
在许多非传统深度学习模型中,计算雅可比矩阵或海森矩阵非常有用。使用 PyTorch 的常规自动微分 API(Tensor.backward(), torch.autograd.grad)来高效计算这些量是比较困难(或繁琐)的。PyTorch 的 JAX 风格的 函数变换 API 提供了高效计算各种高阶自动微分量的方法。
注意
本教程需要 PyTorch 2.0.0 或更高版本。
计算雅可比矩阵#
import torch
import torch.nn.functional as F
from functools import partial
_ = torch.manual_seed(0)
让我们从一个想要计算其雅可比矩阵的函数开始。这是一个带有非线性激活函数的简单线性函数。
让我们添加一些虚拟数据:权重、偏置和一个特征向量 x。
D = 16
weight = torch.randn(D, D)
bias = torch.randn(D)
x = torch.randn(D) # feature vector
我们将 predict 视为一个将输入 x 从 \(R^D \to R^D\) 进行映射的函数。PyTorch Autograd 计算的是向量-雅可比积。为了计算这个 \(R^D \to R^D\) 函数的完整雅可比矩阵,我们必须通过每次使用不同的单位向量来逐行计算它。
def compute_jac(xp):
jacobian_rows = [torch.autograd.grad(predict(weight, bias, xp), xp, vec)[0]
for vec in unit_vectors]
return torch.stack(jacobian_rows)
xp = x.clone().requires_grad_()
unit_vectors = torch.eye(D)
jacobian = compute_jac(xp)
print(jacobian.shape)
print(jacobian[0]) # show first row
torch.Size([16, 16])
tensor([-0.5956, -0.6096, -0.1326, -0.2295, 0.4490, 0.3661, -0.1672, -1.1190,
0.1705, -0.6683, 0.1851, 0.1630, 0.0634, 0.6547, 0.5908, -0.1308])
与其逐行计算雅可比矩阵,不如使用 PyTorch 的 torch.vmap 函数变换来消除 for 循环并向量化计算。我们不能直接将 vmap 应用于 torch.autograd.grad;相反,PyTorch 提供了一个可以与 torch.vmap 组合的 torch.func.vjp 变换。
from torch.func import vmap, vjp
_, vjp_fn = vjp(partial(predict, weight, bias), x)
ft_jacobian, = vmap(vjp_fn)(unit_vectors)
# let's confirm both methods compute the same result
assert torch.allclose(ft_jacobian, jacobian)
在后面的教程中,反向模式自动微分(AD)与 vmap 的组合将使我们获得单样本梯度。在本教程中,反向模式 AD 与 vmap 的组合为我们提供了雅可比矩阵计算!vmap 与自动微分变换的各种组合可以产生各种有趣的量。
PyTorch 提供了 torch.func.jacrev 作为一种便捷函数,它执行 vmap-vjp 组合来计算雅可比矩阵。jacrev 接受一个 argnums 参数,用于指定我们要针对哪个参数计算雅可比矩阵。
from torch.func import jacrev
ft_jacobian = jacrev(predict, argnums=2)(weight, bias, x)
# Confirm by running the following:
assert torch.allclose(ft_jacobian, jacobian)
让我们比较一下这两种计算雅可比矩阵方法的性能。函数变换版本要快得多(而且输出越多,速度提升越明显)。
通常,我们预期通过 vmap 进行向量化有助于消除开销,并能更好地利用硬件。
vmap 通过将外部循环下推到函数的基本操作中来实现这种“魔法”,从而获得更好的性能。
让我们编写一个简单的函数来评估性能,并处理微秒和毫秒的测量。
def get_perf(first, first_descriptor, second, second_descriptor):
"""takes torch.benchmark objects and compares delta of second vs first."""
faster = second.times[0]
slower = first.times[0]
gain = (slower-faster)/slower
if gain < 0: gain *=-1
final_gain = gain*100
print(f" Performance delta: {final_gain:.4f} percent improvement with {second_descriptor} ")
然后运行性能比较。
from torch.utils.benchmark import Timer
without_vmap = Timer(stmt="compute_jac(xp)", globals=globals())
with_vmap = Timer(stmt="jacrev(predict, argnums=2)(weight, bias, x)", globals=globals())
no_vmap_timer = without_vmap.timeit(500)
with_vmap_timer = with_vmap.timeit(500)
print(no_vmap_timer)
print(with_vmap_timer)
<torch.utils.benchmark.utils.common.Measurement object at 0x7fab355254b0>
compute_jac(xp)
1.44 ms
1 measurement, 500 runs , 1 thread
<torch.utils.benchmark.utils.common.Measurement object at 0x7fab72cbc070>
jacrev(predict, argnums=2)(weight, bias, x)
406.69 us
1 measurement, 500 runs , 1 thread
让我们使用 get_perf 函数对上述方法进行相对性能比较。
get_perf(no_vmap_timer, "without vmap", with_vmap_timer, "vmap")
Performance delta: 71.7047 percent improvement with vmap
此外,转换问题视角也很容易,如果我们想计算模型参数(权重、偏置)的雅可比矩阵,而不是输入的雅可比矩阵,这同样简单。
# note the change in input via ``argnums`` parameters of 0,1 to map to weight and bias
ft_jac_weight, ft_jac_bias = jacrev(predict, argnums=(0, 1))(weight, bias, x)
反向模式雅可比矩阵 (jacrev) 与正向模式雅可比矩阵 (jacfwd)#
我们提供两种计算雅可比矩阵的 API:jacrev 和 jacfwd。
jacrev使用反向模式 AD。如上所述,它是我们的vjp和vmap变换的组合。jacfwd使用正向模式 AD。它实现为我们jvp和vmap变换的组合。
jacfwd 和 jacrev 可以相互替代,但它们具有不同的性能特征。
通俗的经验法则是:如果你正在计算 \(R^N \to R^M\) 函数的雅可比矩阵,且输出远多于输入(例如 \(M > N\)),则首选 jacfwd,否则使用 jacrev。虽然此规则存在例外,但其背后的一个非严格推导如下。
在反向模式 AD 中,我们逐行计算雅可比矩阵;而在正向模式 AD(计算雅可比-向量积)中,我们则是逐列计算。雅可比矩阵有 M 行 N 列,因此如果矩阵是“高”型或“宽”型,我们可能倾向于选择处理较少行数或列数的方法。
首先,让我们在输入多于输出的情况下进行基准测试。
Din = 32
Dout = 2048
weight = torch.randn(Dout, Din)
bias = torch.randn(Dout)
x = torch.randn(Din)
# remember the general rule about taller vs wider... here we have a taller matrix:
print(weight.shape)
using_fwd = Timer(stmt="jacfwd(predict, argnums=2)(weight, bias, x)", globals=globals())
using_bwd = Timer(stmt="jacrev(predict, argnums=2)(weight, bias, x)", globals=globals())
jacfwd_timing = using_fwd.timeit(500)
jacrev_timing = using_bwd.timeit(500)
print(f'jacfwd time: {jacfwd_timing}')
print(f'jacrev time: {jacrev_timing}')
torch.Size([2048, 32])
jacfwd time: <torch.utils.benchmark.utils.common.Measurement object at 0x7fab713c8820>
jacfwd(predict, argnums=2)(weight, bias, x)
786.60 us
1 measurement, 500 runs , 1 thread
jacrev time: <torch.utils.benchmark.utils.common.Measurement object at 0x7fab359567d0>
jacrev(predict, argnums=2)(weight, bias, x)
23.55 ms
1 measurement, 500 runs , 1 thread
然后进行相对基准测试。
get_perf(jacfwd_timing, "jacfwd", jacrev_timing, "jacrev", );
Performance delta: 2894.2667 percent improvement with jacrev
现在是反过来——输出 (M) 多于输入 (N)。
Din = 2048
Dout = 32
weight = torch.randn(Dout, Din)
bias = torch.randn(Dout)
x = torch.randn(Din)
using_fwd = Timer(stmt="jacfwd(predict, argnums=2)(weight, bias, x)", globals=globals())
using_bwd = Timer(stmt="jacrev(predict, argnums=2)(weight, bias, x)", globals=globals())
jacfwd_timing = using_fwd.timeit(500)
jacrev_timing = using_bwd.timeit(500)
print(f'jacfwd time: {jacfwd_timing}')
print(f'jacrev time: {jacrev_timing}')
jacfwd time: <torch.utils.benchmark.utils.common.Measurement object at 0x7fab712c86d0>
jacfwd(predict, argnums=2)(weight, bias, x)
6.85 ms
1 measurement, 500 runs , 1 thread
jacrev time: <torch.utils.benchmark.utils.common.Measurement object at 0x7fab359d9480>
jacrev(predict, argnums=2)(weight, bias, x)
480.92 us
1 measurement, 500 runs , 1 thread
以及相对性能比较。
get_perf(jacrev_timing, "jacrev", jacfwd_timing, "jacfwd")
Performance delta: 1323.7633 percent improvement with jacfwd
使用 functorch.hessian 计算海森矩阵#
我们提供了一个便捷 API 来计算海森矩阵:torch.func.hessian。海森矩阵是雅可比矩阵的雅可比矩阵(或偏导数的偏导数,即二阶导数)。
这表明人们可以直接组合 functorch 的雅可比变换来计算海森矩阵。实际上,在底层,hessian(f) 简单地等同于 jacfwd(jacrev(f))。
注意:为了提升性能,根据你的模型情况,你也可以利用上述关于宽/高矩阵的经验法则,选择使用 jacfwd(jacfwd(f)) 或 jacrev(jacrev(f)) 来计算海森矩阵。
from torch.func import hessian
# lets reduce the size in order not to overwhelm Colab. Hessians require
# significant memory:
Din = 512
Dout = 32
weight = torch.randn(Dout, Din)
bias = torch.randn(Dout)
x = torch.randn(Din)
hess_api = hessian(predict, argnums=2)(weight, bias, x)
hess_fwdfwd = jacfwd(jacfwd(predict, argnums=2), argnums=2)(weight, bias, x)
hess_revrev = jacrev(jacrev(predict, argnums=2), argnums=2)(weight, bias, x)
让我们验证一下,无论使用 hessian API 还是使用 jacfwd(jacfwd()),结果是否一致。
True
批量雅可比矩阵和批量海森矩阵#
在上述示例中,我们一直针对单个特征向量进行操作。在某些情况下,你可能希望计算一批输出相对于一批输入的雅可比矩阵。也就是说,给定形状为 (B, N) 的输入批次和从 \(R^N \to R^M\) 的函数,我们希望得到形状为 (B, M, N) 的雅可比矩阵。
实现这一点的最简单方法是使用 vmap。
batch_size = 64
Din = 31
Dout = 33
weight = torch.randn(Dout, Din)
print(f"weight shape = {weight.shape}")
bias = torch.randn(Dout)
x = torch.randn(batch_size, Din)
compute_batch_jacobian = vmap(jacrev(predict, argnums=2), in_dims=(None, None, 0))
batch_jacobian0 = compute_batch_jacobian(weight, bias, x)
weight shape = torch.Size([33, 31])
如果你拥有的函数是从 (B, N) -> (B, M) 映射,并且确定每个输入都会产生独立的输出,那么有时也可以不使用 vmap,只需将输出求和,然后计算该函数的雅可比矩阵即可。
def predict_with_output_summed(weight, bias, x):
return predict(weight, bias, x).sum(0)
batch_jacobian1 = jacrev(predict_with_output_summed, argnums=2)(weight, bias, x).movedim(1, 0)
assert torch.allclose(batch_jacobian0, batch_jacobian1)
如果你拥有的是从 \(R^N \to R^M\) 但输入是分批的函数,则可以将 vmap 与 jacrev 组合来计算批量雅可比矩阵。
最后,批量海森矩阵也可以类似地计算。最简单的思路是使用 vmap 来对海森矩阵计算进行批处理,但在某些情况下,“求和技巧”也同样适用。
compute_batch_hessian = vmap(hessian(predict, argnums=2), in_dims=(None, None, 0))
batch_hess = compute_batch_hessian(weight, bias, x)
batch_hess.shape
torch.Size([64, 33, 31, 31])
计算海森-向量积#
计算海森-向量积 (hvp) 的原始方法是实例化完整的海森矩阵,并将其与向量进行点积运算。我们可以做得更好:事实证明,我们不需要实例化完整海森矩阵即可完成此操作。我们将介绍计算海森-向量积的两种(众多方法中的)策略:- 将反向模式 AD 与反向模式 AD 组合 - 将反向模式 AD 与正向模式 AD 组合
将反向模式 AD 与正向模式 AD 组合(相对于反向与反向组合)通常是计算 hvp 更内存高效的方法,因为正向模式 AD 不需要构建自动微分图并保存反向传播的中间变量。
以下是一些使用示例。
def f(x):
return x.sin().sum()
x = torch.randn(2048)
tangent = torch.randn(2048)
result = hvp(f, (x,), (tangent,))
如果 PyTorch 正向 AD 尚未覆盖你的操作,那么我们可以改为将反向模式 AD 与反向模式 AD 进行组合。
脚本总运行时间: (0 分钟 17.964 秒)