PINN这两年算是科学计算与深度学习交叉领域里最热的关键词之一。用torch从零实现一个能求解二维Helmholtz方程的物理信息神经网络,说起来就三件事:把网络搭出来、把方程残差写进损失函数、把训练跑通。但真正动手之后,你会发现文档里没写的坑比想象中多,尤其是方程带有波数(频率)的时候,网络会表现得很“偏科”——先学低频轮廓,高频振荡部分怎么训都学不动。这篇文章就把我实际跑通的经验完整复盘一遍,包括可复现的Python代码、损失函数构造逻辑、以及高频问题下的调试手段。无论你是刚接触PINN的新手,还是已经在其他方程上跑过、想转向Helmholtz方程的读者,这篇文章都能让你少走不少弯路。
1. 为什么“有物理约束”的神经网络能解偏微分方程
很多人第一次听到PINN时会有一个疑问:神经网络不是靠数据训练吗?求解偏微分方程哪来的标签数据?这个问题的答案,恰恰是PINN整个方法最核心的出发点——它不依赖标签数据,而是把物理方程本身变成监督信号。你不需要事先知道解在某个点是多少,只需要要求神经网络输出的函数在任意一个点上“尽可能满足物理规律”。
我刚接触这个概念时,觉得最妙的地方在于:传统数值方法(比如有限差分、有限元)是把求解区域划分成网格,然后在每个网格节点上构造离散方程;PINN则是把解表示成一个连续函数,这个函数由神经网络的权重参数决定。你不需要网格,只需要在区域内采样点,然后把采样点上的方程残差算出来,让残差尽量小。这本质上是一个优化问题,不是离散逼近问题。
1.1 PINN和普通神经网络回归的本质区别
普通神经网络做回归时,输入是$x$,输出是$y$,训练数据是$(x_i, y_i)$这样的成对样本,损失函数一般是预测值和真实值之间的均方误差。这种模式在数据充足时很好用,但遇到偏微分方程求解就尴尬了:解析解往往是未知的,你没有大量精确的标签。
PINN换了个思路。它仍然用一个神经网络$u_{\theta}(x, y)$来表示解,但不需要解的真实值作为标签。对于含源项的Helmholtz方程,我们可以构造这样的损失:
[
\mathcal{L} = \frac{1}{N_f}\sum_{i=1}^{N_f} \left( \nabla^2 u_{\theta}(x_i, y_i) + k^2 u_{\theta}(x_i, y_i) - f(x_i, y_i) \right)^2 + \frac{1}{N_b}\sum_{j=1}^{N_b} \left( u_{\theta}(x_j, y_j) - g(x_j, y_j) \right)^2
]
第一项是内部点的PDE残差,第二项是边界条件的残差。网络在优化过程中不断调整参数,使得它输出的函数在所有采样点上同时满足方程和边界条件。这个思路的关键在于:我们不需要知道解本身,只需要知道方程告诉我们的“解应该满足什么规律”。
1.2 万能逼近定理与自动微分:两个轮子缺一不可
光有想法还不够,两个技术前提缺一不可。第一个是神经网络本身具有强大的函数逼近能力。万能逼近定理告诉我们,一个足够宽的双层前馈神经网络可以以任意精度逼近任意连续函数。这意味着只要参数足够多、训练足够充分,网络完全有能力表达Helmholtz方程的解函数。
第二个是自动微分。方程里带了拉普拉斯算子$\nabla^2$,也就是二阶偏导数。传统数值方法用差分近似来算导数,但PINN不需要做任何离散近似,而是直接对神经网络的输出做自动微分,精确到机器精度。PyTorch的torch.autograd.grad接口可以很轻松地对网络输出求一阶、二阶偏导,这就是用torch实现PINN特别顺手的原因。
我们可以用一个生活化类比来理解:有限差分方法就像拿尺子量一条曲线的斜率,你只能靠很近的两个点近似;自动微分则是直接根据函数表达式算出准确的导数公式,然后把数值代进去,得到的值是精确的。对PINN来说,高阶导数的精确计算至关重要,因为损失函数里一旦出现二阶导,误差会指数级放大的。
1.3 损失函数是怎么把物理方程“翻译”成优化的
PINN的损失函数设计是整个方法的地基。从数学上理解,求解PDE的过程就是在满足边界条件的函数空间中找一个让方程成立的函数。PINN把这个问题转化成一个无约束优化问题:我们不再要求每个点上方程严格成立(那太难了),而是让方程残差的均方误差最小化。这个思路和最小二乘法如出一辙。
具体到代码层面,最关键的步骤是:
- 在求解区域内部随机采样$N_f$个点,计算
pde_loss。 - 在边界上采样$N_b$个点,计算
bc_loss。 - 两个损失相加,做梯度下降更新网络参数。
我在实际使用中会监控两个loss分量的比例。如果pde_loss占绝对主导,说明边界条件还没学好,模型会“自由发挥”;如果bc_loss占主导,说明内部方程还没被满足。两者的相对大小本身就是一张很好的体检报告。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 二维Helmholtz方程对PINN来说难过在哪
Helmholtz方程在物理里非常常见,声波传播、电磁场模式分析、地震波模拟都会遇到。二维无源Helmholtz方程标准形式是:
[
-\nabla^2 u - k^2 u = f(x, y)
]
$k$是波数,代表波的振荡快慢。$k$越大,解的空间振荡越剧烈。加源项$f(x, y)$之后,它就有了解析解存在的可能,方便我们验证结果。
2.1 方程长什么样,符号约定先说清楚
我见过不少文章在符号上有出入,有的写$\nabla^2 u + k^2 u = f$,有的写$-\nabla^2 u = k^2 u$。不同符号约定会改变源项$f$和边界条件的正负号,初次接触很容易被绕进去。为避免歧义,本文采用我实际测试用的形式:
[
\nabla^2 u + k^2 u = f(x, y)
]
二维情况下$\nabla^2 u = \frac{\partial^2 u}{\partial x^2} + \frac{\partial^2 u}{\partial y^2}$。求解区域取单位正方形$[0,1] \times [0,1]$,边界条件这里用最简单的Dirichlet齐次边界:四条边上$u=0$。
2.2 高频振荡:从频谱偏置看PINN的“偏科”
大多数PINN入门教程用一维Poisson方程做演示,那个方程的解非常光滑,网络学起来很轻松。可一旦切换到Helmholtz方程,很多人会发现loss怎么都降不下去。原因在于Helmholtz方程的解带有明显的周期振荡,而神经网络在拟合高频函数时存在一个臭名昭著的缺陷——频谱偏置,或者说谱偏置。
简单来说,神经网络训练过程本质上是一个按频率分量的学习过程。低频谱波(光滑变化的部分)梯度信号强、学得快;高频谱波(快速振荡的部分)梯度信号弱,要么学得极慢,要么干脆学不动。这就像一个人先学会了画出山峦的大致轮廓,但始终调不好那些细碎的锯齿边缘。波数$k$越大,解的振荡频率越高,这个现象越严重。
所以PDE本身的难解程度和PINN能不能学好,不完全是一回事。方程在数学上有很好的解析解,但神经网络不一定能学出来,这个断层是许多新手困惑的根源。
2.3 构造有解析解的测试算例,验证才靠谱
要训练一个PINN并且评估效果,必须有一个可以对比真值的测试问题。最常用的技巧是制造解。我们假设解析解是:
[
u(x,y) = \sin(m\pi x) \sin(n\pi y)
]
其中$m$和$n$是正整数,决定了振荡频率。它在四条边界上都是0,满足齐次Dirichlet边界条件。求二阶导:
[
\frac{\partial^2 u}{\partial x^2} = -(m\pi)^2 \sin(m\pi x)\sin(n\pi y)
]
[
\frac{\partial^2 u}{\partial y^2} = -(n\pi)^2 \sin(m\pi x)\sin(n\pi y)
]
代入原方程,就能算出对应的源项:
[
f(x,y) = \left( k^2 - \pi^2(m^2+n^2) \right) \sin(m\pi x) \sin(n\pi y)
]
把$f$作为已知量喂给模型,真正的$u$就是我们设定的解析解。跑完后对比网络预测值和$\sin(m\pi x)\sin(n\pi y)$,误差一目了然。这在数值计算领域叫制造解方法,做PINN验证一定要掌握。
3. 环境准备与PyTorch最小搭建
在实际写代码之前,环境搭好是第一道坎。我见过不少人在这一步就卡了很久,尤其是PyTorch安装问题,网上搜出来的答案五花八门,有些还会误导你,所以这里单独开一节比较重要。
3.1 torch安装与虚拟环境
我最推荐的方式是先用conda创建一个干净的虚拟环境,Python版本选3.10。为什么是3.10?因为很多国内镜像和旧版本PyTorch在Python 3.12上会出现兼容问题,而3.10既能兼容Torch 2.x,又没有老版本Python的兼容包袱。创建命令:
bash复制conda create -n pinn python=3.10 -y
conda activate pinn
然后安装PyTorch。如果机器有NVIDIA显卡并且想用GPU训练,到PyTorch官网根据CUDA版本选对应命令,比如:
bash复制pip install torch --index-url https://download.pytorch.org/whl/cu118
如果你的机器没有独立显卡,或者暂时不想折腾CUDA,直接安装CPU版本也能运行本文的所有代码:
bash复制pip install torch
CPU版本对这个规模的PINN完全够用,训练几千轮也就一两分钟的事。
3.2 常见安装报错自查
我在热词里看到大量关于torch安装的报错,这里总结最典型的几种,方便你对照排查。
第一种是ModuleNotFoundError: No module named 'torch'。这个基本是没装进当前环境。常见原因是你确实装了torch,但装到了另一个虚拟环境里,当前终端激活的是别的环境。解决办法就是先确认conda activate pinn执行成功,再在终端里运行python -c "import torch; print(torch.__version__)"验证。
第二种是安装时ERROR: Could not find a version that satisfies the requirement torch。这通常是镜像源没有同步完整的PyTorch包,或者Python版本和PyTorch版本不匹配。建议优先使用PyTorch官方源或者pip install torch -i https://pypi.org/simple,不要用不完整的加速镜像。
第三种是安装成功后import torch报错说缺少某个动态链接库。这类问题在Windows上常见,通常是缺少Visual C++ Redistributable运行库;Linux上则是CUDA相关Library缺失。你如果只是跑CPU版本,建议直接重装CPU版,能省去大部分显卡驱动带来的麻烦。
3.3 网络结构设计:为什么激活函数选tanh
PINN里网络结构的选择直接影响训练成败。我建议用一种非常简单但有讲究的结构:三到四层全连接网络,隐藏层宽度64到128,激活函数用tanh。这里需要解释一下为什么不用ReLU:ReLU在正区间是线性的,它的二阶导数恒等于0。一旦我们计算PDE残差,损失函数里就会包含二阶导数项,而ReLU的二阶导数为0,网络根本无法表达非零的二阶导数信息,PINN训练就直接废了。
tanh的好处是无穷光滑,任何阶导数都存在且不为常数,这保证了网络可以表达方程中需要的二阶导信息。另一个常见选择是Sin激活函数,它在波动问题上表现很惊艳,但初值敏感,需要专门的初始化策略,不适合作为第一个跑通的版本。
所以我这里给出的基础结构是:
python复制class PINN(torch.nn.Module):
def __init__(self, hidden_layers=3, hidden_width=64):
super(PINN, self).__init__()
layers = []
layers.append(torch.nn.Linear(2, hidden_width))
for _ in range(hidden_layers - 1):
layers.append(torch.nn.Linear(hidden_width, hidden_width))
layers.append(torch.nn.Tanh())
layers.append(torch.nn.Linear(hidden_width, 1))
self.net = torch.nn.Sequential(*layers)
for m in self.net:
if isinstance(m, torch.nn.Linear):
torch.nn.init.xavier_uniform_(m.weight)
torch.nn.init.zeros_(m.bias)
def forward(self, x):
return self.net(x)
这个网络结构加上Xavier初始化,在大多数Helmholtz测试算例里都能有一个不错的起点。
4. 完整代码:从数据生成到训练循环
下面进入正题。整个代码我不拆成碎片,直接按脚本顺序完整走一遍,每个模块都给出原因和注意事项。你拿到以后改改参数就能跑。
4.1 数据生成:内部点、边界点、源项
数据生成似乎是最没技术含量的一步,但它的设计会直接影响训练的稳定性。我采取的策略是:边界点每条边各采样$N_b$个,内部点在区域内部随机采样$N_f$个。所有坐标需要设置requires_grad=True,否则自动微分时无法追踪梯度。更重要的是,我上面提到的制造解技巧在这里派上了用场:源项$f$不是手推公式,而是直接对解析解做自动微分算出来的。这样既避免手推符号出错,又提供了一个天然的函数式代码风格。
python复制import torch
import torch.nn as nn
import numpy as np
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("Using device:", device)
# 解析解:u = sin(m*pi*x) * sin(n*pi*y)
m = 1
n = 1
k = 4.0
pi = torch.pi
def u_true(x, y):
return torch.sin(m * pi * x) * torch.sin(n * pi * y)
def source(x, y):
x = x.clone().requires_grad_(True)
y = y.clone().requires_grad_(True)
u = u_true(x, y)
u_x = torch.autograd.grad(u, x, grad_outputs=torch.ones_like(u), create_graph=True)[0]
u_xx = torch.autograd.grad(u_x, x, grad_outputs=torch.ones_like(u_x), create_graph=True)[0]
u_y = torch.autograd.grad(u, y, grad_outputs=torch.ones_like(u), create_graph=True)[0]
u_yy = torch.autograd.grad(u_y, y, grad_outputs=torch.ones_like(u_y), create_graph=True)[0]
return u_xx + u_yy + k ** 2 * u
def sample_points(N_f=10000, N_b=500):
# 内部点
x_f = torch.rand(N_f, 1, device=device) # [0,1]区间
y_f = torch.rand(N_f, 1, device=device)
# 边界点:四条边各自采样
t_b1 = torch.rand(N_b, 1, device=device)
t_b2 = torch.rand(N_b, 1, device=device)
x_b = torch.cat([t_b1, t_b1, torch.zeros_like(t_b2), torch.ones_like(t_b2)], dim=0)
y_b = torch.cat([torch.zeros_like(t_b1), torch.ones_like(t_b1), t_b2, t_b2], dim=0)
return x_f, y_f, x_b, y_b
这里有一个容易被忽略的细节:我在计算源项时,是让x和y分别requires_grad,而不是让一个形状为(N,2)的坐标张量整体求梯度。这样能避免二阶梯度计算时的维度混淆,代码可读性也好很多。
4.2 网络、损失函数、训练主循环
有了网络结构和数据,下一步就是实现损失函数和训练主循环。损失函数这一块需要注意的一件事是:PDE残差用的是一阶偏导的自动微分,但计算二阶导时需要对一阶导再求一次梯度,这要求第一次求梯度时带上create_graph=True,否则计算图被释放,第二次求梯度会报错。代码里处处都要注意这个细节。
python复制def loss_fn(model, x_f, y_f, x_b, y_b, k):
# 内部点
x_f = x_f.clone().requires_grad_(True)
y_f = y_f.clone().requires_grad_(True)
u = model(torch.cat([x_f, y_f], dim=1))
# 一阶导
u_x = torch.autograd.grad(u, x_f, grad_outputs=torch.ones_like(u), create_graph=True)[0]
u_y = torch.autograd.grad(u, y_f, grad_outputs=torch.ones_like(u), create_graph=True)[0]
# 二阶导
u_xx = torch.autograd.grad(u_x, x_f, grad_outputs=torch.ones_like(u_x), create_graph=True)[0]
u_yy = torch.autograd.grad(u_y, y_f, grad_outputs=torch.ones_like(u_y), create_graph=True)[0]
# Helmholtz方程残差
f_val = source(x_f, y_f)
pde_residual = u_xx + u_yy + k ** 2 * u - f_val
pde_loss = torch.mean(pde_residual ** 2)
# 边界条件残差:齐次Dirichlet边界
u_b = model(torch.cat([x_b, y_b], dim=1))
bc_loss = torch.mean(u_b ** 2)
return pde_loss, bc_loss, pde_loss + bc_loss
训练主循环也很简单,用Adam优化器,加一个StepLR学习率调度器,每1000轮把学习率衰减一半。完整代码如下:
python复制model = PINN().to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=1000, gamma=0.5)
x_f, y_f, x_b, y_b = sample_points(N_f=10000, N_b=500)
epochs = 5000
report_every = 500
for epoch in range(epochs):
model.train()
optimizer.zero_grad()
pde_loss, bc_loss, total_loss = loss_fn(model, x_f, y_f, x_b, y_b, k)
total_loss.backward()
optimizer.step()
scheduler.step()
if (epoch + 1) % report_every == 0:
print(f"Epoch {epoch + 1:5d} | PDE Loss: {pde_loss.item():.3e} | BC Loss: {bc_loss.item():.3e}")
你会看到PDE loss和BC loss都在稳步下降。如果BC loss掉到很低而PDE loss还在原地,说明网络只学会了边界条件,内部方程没有学习信号,后面第五节会专门讲这个问题。
4.3 结果评估:算L2误差并可视化
训练结束后,评估是必须的,否则你怎么知道模型学得好不好。这里我用相对$L_2$误差,公式是:
[
\text{Rel. L2 Error} = \frac{\sqrt{\sum (u_{pred} - u_{true})^2}}{\sqrt{\sum (u_{true})^2}}
]
可视化我直接用matplotlib画两个子图,左边是解析解,右边是PINN预测解,颜色条范围保持一致,这样高频部分学没学到一目了然。
python复制import matplotlib.pyplot as plt
with torch.no_grad():
x_plot = torch.linspace(0, 1, 100, device=device)
y_plot = torch.linspace(0, 1, 100, device=device)
X, Y = torch.meshgrid(x_plot, y_plot)
X_flat = X.reshape(-1, 1)
Y_flat = Y.reshape(-1, 1)
u_pred = model(torch.cat([X_flat, Y_flat], dim=1)).reshape(100, 100).cpu().numpy()
u_true_plot = u_true(X, Y).cpu().numpy()
print("相对L2误差: {:.4e}".format(np.linalg.norm(u_pred - u_true_plot) / np.linalg.norm(u_true_plot)))
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
im0 = axes[0].imshow(u_true_plot.T, origin='lower', extent=[0, 1, 0, 1], cmap='RdBu')
axes[0].set_title("True Solution")
im1 = axes[1].imshow(u_pred.T, origin='lower', extent=[0, 1, 0, 1], cmap='RdBu')
axes[1].set_title("PINN Prediction")
fig.colorbar(im1, ax=axes[:])
plt.show()
当你第一次看到两张图几乎一致时,那种感觉还是很爽的。但这个测试里的$k=4, m=n=1$属于比较温和的情况,我把波数调大之后,就开始踩到高频振荡的坑了。
5. 实验复盘:高频振荡为什么失败,以及特征频率分离怎么救场
代码跑通只是第一步,真正有意思的是调整波数$k$、观察模型怎么从一个成功案例慢慢变成失败案例。这个复盘的起点是我把$k$从4逐步调到8、再调到12时的现象。
5.1 标准PINN在k增大时的表现
当$k$还比较小(比如$k=2$到$4$)时,标准PINN表现不错,相对$L_2$误差可以压到$10^{-3}$量级。但随着$k$增大,特别是$k=8$以上,你会观察到几个典型的异常信号:
- 总loss下降到一定程度后就进入平台期,不再明显下降。
- PDE loss和BC loss的比例发生了倾斜,边界条件学得很好,内部震荡区域一直有残差。
- 可视化的结果里,“山峰”和“山谷”处的颜色明显比解析解浅,说明振幅被压低。
这个现象的原因就是之前提到的频谱偏置。网络优化时梯度主要被低频成分主导,高频成分的信号被淹没。你拼命增大网络宽度、增加训练轮数,效果往往也很有限,因为问题的根源不在网络容量不够,而在训练过程的频率偏好上。
5.2 特征频率分离的具体改法与代码
解决高频振荡的一个常用技巧是特征频率分离。思路非常直接:既然网络学不好高频输入,那我们就把输入坐标映射到和问题频率匹配的高频特征空间,把“高频学习”转化为“低频学习”。具体做法是扩展网络输入:
[
x_{feat} = \left[ \sin(k\pi x), \cos(k\pi x), \sin(k\pi y), \cos(k\pi y), x, y \right]
]
这里的$k$就是Helmholtz方程中的波数。这个映射把坐标$x$重新编码成正弦和余弦形式,而正弦和余弦函数本身是光滑的,网络拟合它们的组合比直接拟合振荡函数要容易得多。我加了原始坐标$x, y$作为补充,是为了保留低频空间位置信息,防止网络失去对边界位置的感知。
实现时只需要在数据输入网络之前做一层变换,不需要改损失函数:
python复制def feature_map(x, y, k):
x_feat = torch.cat([
torch.sin(k * pi * x),
torch.cos(k * pi * x),
torch.sin(k * pi * y),
torch.cos(k * pi * y),
x,
y
], dim=1)
return x_feat
再把模型的第一层输入维度从2改成6,然后训练。我实际测试过来,同样的网络在$k=12$时,这个特征映射可以把相对$L_2$误差从百分之几十降低到个位数百分比。虽然还会有一点误差,但已经能肉眼看出波形结构被还原了。
5.3 其他实战调参指南
特征频率分离不是唯一救场手段,下面这几个调参方向是我试过之后觉得同样有效的,按推荐程度排序:
一是调整损失函数中PDE项和边界项的权重。当边界条件太好、内部方程不行时,可以给bc_loss乘以一个小于1的权重,比如0.1,让梯度信号更均衡。反之亦然。这个“权重平衡”是PINN调参最重要的杠杆之一。
二是换用L-BFGS优化器收尾。Adam擅长快速下降,但最终精度一般;L-BFGS是二阶优化算法,在PINN里经常能在Adam跑到平台期后进一步把loss压下去几个量级。实用做法是先用Adam训3000轮,再切换L-BFGS微调几百步。
三是增加采样点数量。内部点从10000加到50000,对精度提升效果明显,但显存占用和训练时间也会同步上升,需要量力而行。
四是采用残差自适应采样。训练过程中,每500轮重新采样一次,让新采样点更多地落在当前残差大的区域。这个方法在问题特别复杂时效果好,但实现起来增加了代码复杂度,建议先把前几条做完再考虑。
五是尝试正弦激活函数。SIREN系列工作已经证明,在波动方程和时间相关问题上,正弦激活要优于tanh,但它对初始化极其敏感,需要用专门的初始化方式(第一层权重均匀分布在$[0, 1/\text{fan_in}]$,其余层均匀分布在$[-\sqrt{6/(6\cdot\text{fan_in})}, \sqrt{6/(6\cdot\text{fan_in})}]$)。新手刚开始不建议直接上手,容易把问题搞得更复杂。
6. 从二维Helmholtz方程向更多方程扩展
代码跑通之后,你会发现PINN最大的优势其实是“改造成本极低”。换一个方程,大部分代码都不用动,改的只是损失函数里PDE残差的表达式和边界条件。
6.1 改方程其实只改损失函数
比如你把Helmholtz方程换成Poisson方程$\nabla^2 u = f$,只需要把pde_residual = u_xx + u_yy + k**2 * u - f_val改成pde_residual = u_xx + u_yy - f_val。换成对流扩散方程,就多一项一阶导的线性组合。如果你处理的是含时间的方程,只需要把时间$t$也作为一个输入特征,用自动微分算$u_t$,再放进PDE残差。
这是PINN作为“方程求解器”的一个重要工程优势——数值方法切换方程往往要重新推导离散格式,而PINN只需要修改几行残差公式。这也是为什么它特别适合作为科研中的快速原型工具。
6.2 低频波、复杂源项、非规则区域怎么处理
这篇文章的测试案例用的是单频正弦解析解,实际工程中的源项和边界条件要复杂得多。如果源项$f$是多个频率的叠加,一个直接的建议是不要让单一网络硬扛所有频率,可以考虑用多尺度PINN或者把几个不同特征映射的网络输出相加。如果求解区域不是简单矩形,比如圆形域或带有复杂边界,可以在边界上做更精细的参数化采样,网络输入除了坐标外加一个区域内部标记,或者用带符号距离函数约束边界条件。
我自己现在跑任何PINN问题的流程基本固定:先小参数跑通,确认代码链路没问题,再逐步加大难度,每次只改一个变量。这个习惯帮我省掉了大量来回试错的成本。希望这篇基于torch的二维Helmholtz方程实现复盘,也能帮你少走一些弯路。
