自定义函数的二阶导数 (Double Backward)#
创建日期: 2021年8月13日 | 最后更新: 2021年8月13日 | 最后验证: 2024年11月5日
有时我们需要在反向传播图中再进行一次反向传播,例如计算高阶梯度。然而,这需要对 autograd 有深刻的理解,并且在支持二阶导数时需要格外小心。那些仅支持单次反向传播的函数并不一定能自动支持二阶导数。在本教程中,我们将展示如何编写一个支持二阶导数的自定义 autograd 函数,并指出一些需要注意的事项。
当编写一个需要进行两次反向传播的自定义 autograd 函数时,了解以下几点至关重要:自定义函数中的哪些操作会被 autograd 记录,哪些不会,以及最重要的是,save_for_backward 是如何与这些机制协同工作的。
自定义函数会在两个方面隐式影响梯度模式 (grad mode):
在正向传播过程中,autograd 不会记录正向函数内任何操作的计算图。当正向传播完成时,自定义函数的 backward 函数会成为正向传播每个输出的 grad_fn。
在反向传播过程中,如果指定了 create_graph=True,autograd 会记录用于计算反向传播的计算图。
接下来,为了理解 save_for_backward 是如何与上述机制交互的,我们可以探讨几个例子。
保存输入 (Saving the Inputs)#
考虑这个简单的平方函数。它保存了输入张量以便用于反向传播。当 autograd 能够记录反向传播中的操作时,二阶导数会自动工作。因此,当我们为了反向传播保存输入时,通常不需要担心,因为如果输入是任何需要梯度的张量的函数,那么该输入本身就应该有 grad_fn。这使得梯度能够被正确传播。
import torch
class Square(torch.autograd.Function):
@staticmethod
def forward(ctx, x):
# Because we are saving one of the inputs use `save_for_backward`
# Save non-tensors and non-inputs/non-outputs directly on ctx
ctx.save_for_backward(x)
return x**2
@staticmethod
def backward(ctx, grad_out):
# A function support double backward automatically if autograd
# is able to record the computations performed in backward
x, = ctx.saved_tensors
return grad_out * 2 * x
# Use double precision because finite differencing method magnifies errors
x = torch.rand(3, 3, requires_grad=True, dtype=torch.double)
torch.autograd.gradcheck(Square.apply, x)
# Use gradcheck to verify second-order derivatives
torch.autograd.gradgradcheck(Square.apply, x)
我们可以使用 torchviz 来可视化计算图,看看它是如何工作的。
import torchviz
x = torch.tensor(1., requires_grad=True).clone()
out = Square.apply(x)
grad_x, = torch.autograd.grad(out, x, create_graph=True)
torchviz.make_dot((grad_x, x, out), {"grad_x": grad_x, "x": x, "out": out})
我们可以看到,关于 x 的梯度本身就是 x 的函数 (dout/dx = 2x),并且该函数的计算图已经被正确构建。
保存输出 (Saving the Outputs)#
前一个例子的一个细微变化是保存输出而不是输入。其机制是相似的,因为输出也与 grad_fn 相关联。
class Exp(torch.autograd.Function):
# Simple case where everything goes well
@staticmethod
def forward(ctx, x):
# This time we save the output
result = torch.exp(x)
# Note that we should use `save_for_backward` here when
# the tensor saved is an ouptut (or an input).
ctx.save_for_backward(result)
return result
@staticmethod
def backward(ctx, grad_out):
result, = ctx.saved_tensors
return result * grad_out
x = torch.tensor(1., requires_grad=True, dtype=torch.double).clone()
# Validate our gradients using gradcheck
torch.autograd.gradcheck(Exp.apply, x)
torch.autograd.gradgradcheck(Exp.apply, x)
使用 torchviz 来可视化计算图。
out = Exp.apply(x)
grad_x, = torch.autograd.grad(out, x, create_graph=True)
torchviz.make_dot((grad_x, x, out), {"grad_x": grad_x, "x": x, "out": out})
保存中间结果 (Saving Intermediate Results)#
一个更棘手的情况是我们需要保存中间结果。我们通过实现以下函数来演示这种情况:
由于 sinh 的导数是 cosh,在反向传播计算中复用正向传播中的两个中间结果 exp(x) 和 exp(-x) 是很有用的。
但是,不应该直接保存并使用中间结果进行反向传播。因为正向传播是在无梯度模式 (no-grad mode) 下执行的,如果正向传播的中间结果被用于反向传播中来计算梯度,那么梯度的反向传播图将不会包含计算这些中间结果的操作。这会导致梯度计算不正确。
class Sinh(torch.autograd.Function):
@staticmethod
def forward(ctx, x):
expx = torch.exp(x)
expnegx = torch.exp(-x)
ctx.save_for_backward(expx, expnegx)
# In order to be able to save the intermediate results, a trick is to
# include them as our outputs, so that the backward graph is constructed
return (expx - expnegx) / 2, expx, expnegx
@staticmethod
def backward(ctx, grad_out, _grad_out_exp, _grad_out_negexp):
expx, expnegx = ctx.saved_tensors
grad_input = grad_out * (expx + expnegx) / 2
# We cannot skip accumulating these even though we won't use the outputs
# directly. They will be used later in the second backward.
grad_input += _grad_out_exp * expx
grad_input -= _grad_out_negexp * expnegx
return grad_input
def sinh(x):
# Create a wrapper that only returns the first output
return Sinh.apply(x)[0]
x = torch.rand(3, 3, requires_grad=True, dtype=torch.double)
torch.autograd.gradcheck(sinh, x)
torch.autograd.gradgradcheck(sinh, x)
使用 torchviz 来可视化计算图。
out = sinh(x)
grad_x, = torch.autograd.grad(out.sum(), x, create_graph=True)
torchviz.make_dot((grad_x, x, out), params={"grad_x": grad_x, "x": x, "out": out})
保存中间结果:错误做法 (Saving Intermediate Results: What not to do)#
现在我们展示当我们没有将中间结果作为输出返回时会发生什么:grad_x 甚至不会有一个反向传播图,因为它纯粹是 exp 和 expnegx 的函数,而这两个操作不需要梯度。
class SinhBad(torch.autograd.Function):
# This is an example of what NOT to do!
@staticmethod
def forward(ctx, x):
expx = torch.exp(x)
expnegx = torch.exp(-x)
ctx.expx = expx
ctx.expnegx = expnegx
return (expx - expnegx) / 2
@staticmethod
def backward(ctx, grad_out):
expx = ctx.expx
expnegx = ctx.expnegx
grad_input = grad_out * (expx + expnegx) / 2
return grad_input
使用 torchviz 可视化计算图。请注意,grad_x 并不在计算图中!
out = SinhBad.apply(x)
grad_x, = torch.autograd.grad(out.sum(), x, create_graph=True)
torchviz.make_dot((grad_x, x, out), params={"grad_x": grad_x, "x": x, "out": out})
当反向传播未被跟踪时 (When Backward is not Tracked)#
最后,让我们考虑一个 autograd 完全无法追踪函数反向传播的例子。我们可以设想 cube_backward 是一个可能需要非 PyTorch 库(如 SciPy 或 NumPy)或者是用 C++ 扩展编写的函数。这里展示的解决方法是创建另一个自定义函数 CubeBackward,并在其中手动指定 cube_backward 的反向传播过程!
def cube_forward(x):
return x**3
def cube_backward(grad_out, x):
return grad_out * 3 * x**2
def cube_backward_backward(grad_out, sav_grad_out, x):
return grad_out * sav_grad_out * 6 * x
def cube_backward_backward_grad_out(grad_out, x):
return grad_out * 3 * x**2
class Cube(torch.autograd.Function):
@staticmethod
def forward(ctx, x):
ctx.save_for_backward(x)
return cube_forward(x)
@staticmethod
def backward(ctx, grad_out):
x, = ctx.saved_tensors
return CubeBackward.apply(grad_out, x)
class CubeBackward(torch.autograd.Function):
@staticmethod
def forward(ctx, grad_out, x):
ctx.save_for_backward(x, grad_out)
return cube_backward(grad_out, x)
@staticmethod
def backward(ctx, grad_out):
x, sav_grad_out = ctx.saved_tensors
dx = cube_backward_backward(grad_out, sav_grad_out, x)
dgrad_out = cube_backward_backward_grad_out(grad_out, x)
return dgrad_out, dx
x = torch.tensor(2., requires_grad=True, dtype=torch.double)
torch.autograd.gradcheck(Cube.apply, x)
torch.autograd.gradgradcheck(Cube.apply, x)
使用 torchviz 来可视化计算图。
out = Cube.apply(x)
grad_x, = torch.autograd.grad(out, x, create_graph=True)
torchviz.make_dot((grad_x, x, out), params={"grad_x": grad_x, "x": x, "out": out})
总而言之,自定义函数是否支持二阶导数,仅仅取决于反向传播过程能否被 autograd 跟踪。通过前两个例子,我们展示了二阶导数可以“开箱即用”的情况。通过第三和第四个例子,我们演示了在反向传播函数原本无法被跟踪时,如何通过特定技术使其能够被跟踪。