返回

解决PINN中autograd.grad输出求和及低效问题

Ai

解决 PINN 中 autograd.grad 输出求和问题

搞物理信息神经网络 (PINN) 的时候,经常需要对输出相对于输入的导数进行计算,并把这些导数用到损失函数里。碰到的一个麻烦事是,当神经网络有多个输出时,用 autograd.grad 计算导数会把所有输出的贡献加起来。比方说,如果输出 u 的形状是 [batch_size, n_output],那导数 dudx 的形状就会变成 [batch_size, 1],而不是想要的 [batch_size, n_output]

因为这个求和,导数就没法直接用在损失函数里了。我试过用 for 循环一个个算导数,但训练起来慢得要死。所以想看看有没有什么好办法解决这个问题。

问题原因分析

torch.autograd.grad 在默认情况下,会将多个输出对输入的梯度加起来。这是因为 autograd.grad 的设计初衷是处理标量输出的损失函数。当我们有一个向量输出时,它会将所有元素的梯度求和,得到一个标量梯度。在 PINN 的多输出场景中,我们需要每个输出对输入的独立梯度,而不是它们的和。

循环计算虽然能得到正确的结果,但效率太低。vmap 好像挺适合做并行计算,可惜它又不支持 autograd.grad,而保留计算图对输入的操作又很重要。

解决方案

下面提供几种可行的解决思路,解决输出梯度自动求和,以及 for 循环效率低下的问题:

1. 使用 Jacobian 矩阵 (推荐)

Jacobian 矩阵包含了输出向量的每个元素对输入向量的每个元素的导数。通过计算 Jacobian 矩阵,我们可以一次性获得所有需要的偏导数,并且避免了求和操作。

原理和作用:

Jacobian 矩阵是一个 m×n 的矩阵(m 是输出数量,n 是输入数量),其中每个元素 (i, j) 表示第 i 个输出对第 j 个输入的偏导数。利用 PyTorch 的 torch.func.jacrev 或者 torch.func.jacobian 函数能够有效计算出 Jacobian 矩阵。

代码示例:

import torch
from torch.func import jacrev

def compute_derivatives_jacobian(model, x, y):
    inputs = torch.cat((x, y), dim=1)
    #修改 compute_all_derivatives 函数,以适应雅可比矩阵计算需求.
    def model_wrapper(inputs):
        x = inputs[:, :x.shape[1]]
        y = inputs[:, x.shape[1]:]
        return model(x, y)

    jacobian_matrix = jacrev(model_wrapper)(inputs) # model 的输出 u = [batch_size,n_outputs]
    # Jacobian_matrix: [batch_size,n_outputs,batch_size,input_dim]

    # 取对角元素
    jacobian_matrix = torch.diagonal(jacobian_matrix, dim1=0, dim2=2)
    # jacobian_matrix  [n_outputs,batch_size,input_dim]

    jacobian_matrix = jacobian_matrix.permute(1,0,2) #调换顺序
    # 此时, jacobian_matrix :[batch, n_out, input_dim ]

    dudx = jacobian_matrix[:,:,0:x.shape[1]]
    dudy = jacobian_matrix[:,:,x.shape[1]:]

    return dudx, dudy
# 假定一个网络模型, x的维度为1,y的维度为1,model 的输出是 [batch_size,2]

model = torch.nn.Linear(2, 2)

x = torch.randn(10, 1, requires_grad=True)
y = torch.randn(10, 1, requires_grad=True)

dudx,dudy = compute_derivatives_jacobian(model, x,y)
print("dudx.shape:", dudx.shape)  # 输出: dudx.shape: torch.Size([10, 2, 1])
print("dudy.shape:", dudy.shape) # 输出: dudy.shape: torch.Size([10, 2, 1])

进阶技巧:高阶导数

如果要计算更高阶的导数, 可以重复利用jacrev 函数嵌套计算。
如果追求极致性能, 或许可以深入了解 torch.func 的底层实现。

2. 批量处理 autograd.grad

这种方法的核心思想是,我们可以通过构造一个“批量”的 grad_outputs 来一次性计算多个输出的梯度,从而避免循环。

原理和作用:

关键在于构建一个单位矩阵I,然后根据输出分别计算每个分量的梯度。

代码示例:

import torch

def gradient_batch(y, x, n_outputs):
    grads = []
    for i in range(n_outputs):
      grad_outputs = torch.zeros_like(y)
      grad_outputs[:, i] = 1
      grad = torch.autograd.grad(y, [x], grad_outputs=grad_outputs, create_graph=True)[0]
      grads.append(grad)
    return torch.stack(grads, dim=1)

def compute_derivatives_batch(model,x, y):

    u = model(x, y)
    n_outputs = u.shape[1]
    dudx = gradient_batch(u, x,n_outputs) # [batch,n_outputs, x_dim]
    dudy = gradient_batch(u, y,n_outputs) # [batch,n_outputs, y_dim]

    return dudx, dudy

# 假定一个网络模型, x的维度为1,y的维度为1,model 的输出是 [batch_size,2]

model = torch.nn.Linear(2, 2)
x = torch.randn(10, 1, requires_grad=True)
y = torch.randn(10, 1, requires_grad=True)

dudx,dudy = compute_derivatives_batch(model, x,y)
print("dudx.shape:", dudx.shape)
print("dudy.shape:", dudy.shape)

说明:
此方法避免了求和,并利用矩阵计算, 一定程度提高了并行程度, 加速计算.
但本质还是通过构造多个 grad_output 来分别求, 有优化空间。

3. 手动实现梯度计算 (进阶,不推荐新手)

对于一些简单的网络结构和激活函数,我们可以手动推导出梯度计算公式,并直接用 PyTorch 的张量运算来实现。

原理及说明:

这种方法需要对网络结构和反向传播算法有深入的了解。手动实现时,必须仔细检查每个操作的梯度计算是否正确。好处就是不用 autograd.grad,完全手动控制。缺点是不通用, 要是网络一改, 推导很麻烦。
代码实现的部分省略。

安全建议 (通用)

  • 梯度爆炸/消失: 训练 PINN 时,尤其是在计算高阶导数时,很容易遇到梯度爆炸或消失的问题。可以尝试使用梯度裁剪 (gradient clipping)、权重初始化、合适的激活函数 (如 Swish, Tanh) 等技巧来缓解。
  • 数值稳定性: 高阶导数的计算可能会导致数值不稳定。注意输入数据的范围,可以进行适当的归一化或标准化。

通过这些方法,大家就可以搞定 PINN 训练中多输出梯度的问题,不用再被求和坑了,也能避免慢吞吞的 for 循环。选择哪种方法取决于你的具体需求和对性能的要求。一般来说, 直接计算 Jacobian 矩阵是比较直接且高效的。