解决PINN中autograd.grad输出求和及低效问题
2025-02-28 14:18:08
解决 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 矩阵是比较直接且高效的。