你打开PyCharm,装好了torch,准备跑一个PINN,但搜来搜去全是MNIST和房价预测,没有一篇把物理信息神经网络从头讲到能跑通Helmholtz方程的。这其实是PINN入门最尴尬的地方:原理人人都能说两句“把PDE放进损失函数”,但真到自己写代码,网络怎么搭、二阶导怎么求、边界条件怎么加权重、为什么loss降不下去,全是坑。
这篇文章我就用二维Helmholtz方程当靶子,把整个PINN的PyTorch实现从头到尾拆一遍。你不需要额外装任何求解器,只需要numpy、torch和matplotlib,就能看着神经网络一步步逼近解析解。这篇文章适合有一定PyTorch基础、想认真把PINN跑通并理解背后原理的人。
1. 物理信息神经网络的原理:不是“数据拟合”,而是“物理约束”
1.1 从传统数值方法到PINN
传统求解偏微分方程(PDE)的主流路线是把求解域网格化。有限差分法用差分公式近似导数,有限单元法把域切成小片,在每个单元上假设插值函数,然后组装一个大型代数方程组。这类方法非常成熟,但有一个天然瓶颈:网格生成本身就占掉大量前处理时间,一旦求解域形状不规则,或者需要对某个区域局部加密,网格处理的工作量会迅速失控。
PINN走的是完全不同的路径。它不画网格,而是用一个神经网络直接表示解函数 u(x,y)。网络的可训练参数是权重和偏置,输入是坐标 (x,y),输出是 u(x,y) 的近似值。训练时不需要任何真实的解数据——至少不需要内部点上的标签——只需要强制网络输出满足两件事:
- 在计算域内部,代入方程后残差等于零;
- 在边界上,满足给定的边界条件。
这种思路的本质是把微分方程求解变成一个优化问题。你不需要为整个域建立离散代数方程,只需要求神经网络在采样点上让物理方程成立即可。这个理念最早由Raissi等人系统化,已经在地球物理、流体力学、结构分析里有了大量应用。
1.2 一个简单直觉:把方程本身变成损失函数
普通的三层神经网络只能拟合数据映射,但是在PINN里,网络的输出是连续可导的,因为激活函数和线性变换的复合函数整体光滑。神经网络是一个无限可微函数,如果激活函数选tanh这种光滑函数,网络输出对输入坐标的偏导数就存在,而这个导数可以通过自动微分(autograd)精确计算,不需要差分近似,也没有网格。
我们定义损失函数为两部分之和:
- PDE残差损失:将网络输出代入原方程(比如 Helmholtz 方程),计算左右差值,目标是让这个差值在全域趋近于0。
- 边界损失:在求解域的边界上采样,让网络输出逼迫给定的边界条件。
训练目标就是最小化两者加权和。网络没有见过解析解,却能在训练结束后逼近真实解。这不是魔法,而是因为PDE已经提供了足够强的约束信息,神经网络拟合的是满足物理规律的函数,而不是单纯拟合标签。
1.3 为什么选择Helmholtz方程作为案例
Helmholtz方程实际上是声学、电磁学里波动方程做时谐假设后得到的频域方程。它的形式是:
∇²u + k²u = f
k 是波数,代表空间振荡频率。这个方程好在两点:
- 它有我们可以手写的精确解(比如三角函数组合),方便验证网络训练得对不对;
- 它的解是高频振荡的,这恰恰是神经网络最难拟合的情形之一,踩坑经验充分,最能体现PINN调参的细节。
如果能把这个方程跑通,再迁移到其他PDE,思路是完全一致的。所以这就是一个“会一个就会一串”的典型案例。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Helmholtz方程问题设定与真解构造
2.1 方程形式与定义域
我选的计算域是单位正方形 [0,1]×[0,1],边界条件是Dirichlet型,即边界上 u=0。
我们要求解的具体方程是:
∂²u/∂x² + ∂²u/∂y² + k²u = f(x,y), (x,y) ∈ (0,1)×(0,1)
u(x,y) = 0, (x,y) ∈ ∂Ω
注意这个 f 不是随便给定的,它由真解代入方程倒推出来。这样做的好处是:我们知道 u_true,可以算出精确的 f,然后用网络去预测 u,最后把网络输出和 u_true 对比,就能量化误差。
2.2 通过“人工真解”验证PINN的正确性
我选真解为:
u_true(x,y) = sin(mπx) sin(nπy)
这组函数天然满足边界上的零值,同时在定义域内部光滑。对它求二阶导:
∂²u/∂x² = -m²π² sin(mπx) sin(nπy)
∂²u/∂y² = -n²π² sin(mπx) sin(nπy)
所以左边等于:
(-(m²+n²)π² + k²) sin(mπx) sin(nπy)
为了让这个式子等于 f,于是:
f(x,y) = (k² - (m²+n²)π²) sin(mπx) sin(nπy)
就这么简单。取 k=2, m=1, n=1 的时候,k² - 2π² ≈ 4 - 19.74 = -15.74,所以 f 是负数乘一个正弦波。如果你想增加解的振荡频率,把 m、n 调大就行,这也为后面的高频测试留了伏笔。
2.3 边界条件的处理方式
对于 Dirichlet 边界,不需要在损失里搞复杂的外推。直接在四条边上均匀取点,比如每条边取 N_boundary 个点,总共 4×N_boundary 个点,用网络在这些点的输出与0的均方误差作为边界损失。边界点在代码里可以通过固定一个坐标为0或1,另一个坐标随机或均匀取值生成:
底部边 y=0,x∈[0,1];
顶部边 y=1,x∈[0,1];
左边 x=0,y∈[0,1];
右边 x=1,y∈[0,1]。
核心变量汇总如下表:
| 参数 | 取值 | 说明 |
|---|---|---|
| 计算域 | [0,1]×[0,1] | 单位正方形 |
| 波数 k | 2 | 空间振荡频率 |
| 真解参数 m,n | 1,1 | 基础频率组合 |
| 边界条件 | u=0 | Dirichlet型零边界 |
3. PyTorch中的网络搭建与自动微分细节
3.1 网络结构选择:几层多少神经元
PINN的典型网络就是全连接网络。输入维度2,输出维度1。隐层数量的常见实践是3到5层,每层50到100个神经元。不是说越深越好——网络太深,自动微分的计算图越复杂,训练反而容易暴走;网络太浅,表达复杂振荡解的能力又不够。
我这里选4层隐藏层,每层50个神经元,激活函数用tanh。选tanh而不是ReLU的原因很关键:ReLU在二阶求导后几乎处处为0,你拿一个二阶梯度的信息去训练,梯度根本传不回去。tanh、sin、sigmoid这类光滑激活函数二阶导数是存在的,而且能在损失函数里提供有效的梯度通道。
初始化用Xavier均匀分布。这里不能省事用默认的PyTorch初始化,实测下来,Xavier初始化在PINN里能让残差损失的初值小一个数量级,训练前期更稳定。
3.2 torch.autograd.grad计算二阶导数的坑
求二阶导是PINN实现里最容易翻车的地方。PyTorch的autograd会构建一个计算图,你需要设create_graph=True,否则你算出来的梯度只是一个数值,不能再对它求梯度,二阶导就取不到了。
具体做法是先对网络输出 u 求 x 的一阶导 u_x,然后再对 u_x 求 x 的导。注意第二次求导时,输入的u_x本身也是另一个计算图的输出,必须允许创建高阶图:
python复制import torch
import torch.nn as nn
# 二阶导数计算模板
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]
这里有一个新手常犯的错:把 x 传成了 x.detach()。如果传detach后的张量,计算图断掉了,后面对x求梯度时会报“element 0 of tensors does not require grad”之类的错误。所有参与求导的坐标张量,在进入网络之前必须保留梯度追踪属性。
还有一个细节:grad_outputs 的shape要和 u 一致,所以用 torch.ones_like(u)。这里的语义是:u对x的每个分量的梯度,需要有一个“上游梯度”乘上来,全1张量表示我们要最原始的雅可比向量积结果。
3.3 激活函数的选择对振荡解的影响
如果你用ReLU,残差损失面会变得非常难优化,因为ReLU的二阶导数几乎处处为0,相当于网络完全接收不到来自内部PDE残差的二阶导信号。用tanh则平滑很多,但在波数k比较大的时候(比如k=10以上),tanh在过零点附近需要非常陡峭的弯曲,往往需要很多神经单元才能描述一个完整的振荡周期。
这个问题不仅是网络容量问题,更是优化问题。后面我会展开讲怎么用傅里叶特征映射绕过这个限制。这里先把网络定义完整跑起来:
python复制class PINN(nn.Module):
def __init__(self, layers=[2, 50, 50, 50, 50, 1]):
super(PINN, self).__init__()
self.activation = nn.Tanh()
self.linears = nn.ModuleList()
for i in range(len(layers) - 1):
linear = nn.Linear(layers[i], layers[i+1])
nn.init.xavier_uniform_(linear.weight)
nn.init.zeros_(linear.bias)
self.linears.append(linear)
def forward(self, x):
for i, linear in enumerate(self.linears):
x = linear(x)
if i < len(self.linears) - 1:
x = self.activation(x)
return x
这个类简洁,训练时的前向计算和反向传播都在PyTorch框架内自动完成。输入x是(N,2)的张量,输出是(N,1)。
4. 采样策略与损失函数组装
4.1 内部点采样与边界点采样
PINN训练不需要结构化网格,但需要一组采样点。内部点我建议用随机采样或者拉丁超立方采样。随机采样的缺点是点可能聚在一起,覆盖不够均匀;拉丁超立方采样在低维下很有效,保证每一维度都被均匀分割。不过实际测试下来,随机采样配上足够多的点(比如5000到10000个),效果已经足够好。
边界点每条边取200个,总边界点800个。注意边界点不仅仅要覆盖边界,还要有一定的重复训练效果,每个epoch里重新生成采样点或者固定采样点都可以。我的经验是:先固定一份采样点,让训练过程更稳定,方便调试;调试通过后再尝试每个epoch重新采样,可以进一步提升精度。
4.2 PDE残差损失项的实现
核心函数是计算网络输出 u 对坐标 x 和 y 的二阶导数,然后代入方程。用前面的真解算出 f,最后让残差趋近于0。
python复制def pde_loss(model, x, y, k):
# x, y: (N,1) 的坐标张量
coords = torch.cat([x, y], dim=1)
coords.requires_grad_(True)
u = model(coords)
u_x = torch.autograd.grad(u, coords, grad_outputs=torch.ones_like(u), create_graph=True)[0][:, 0:1]
u_y = torch.autograd.grad(u, coords, grad_outputs=torch.ones_like(u), create_graph=True)[0][:, 1:2]
u_xx = torch.autograd.grad(u_x, coords, grad_outputs=torch.ones_like(u_x), create_graph=True)[0][:, 0:1]
u_yy = torch.autograd.grad(u_y, coords, grad_outputs=torch.ones_like(u_y), create_graph=True)[0][:, 1:2]
# 真解函数 f
m, n = 1.0, 1.0
pi = torch.pi
f = (k**2 - (m**2 + n**2) * pi**2) * torch.sin(m * pi * x) * torch.sin(n * pi * y)
residual = u_xx + u_yy + k**2 * u - f
return torch.mean(residual**2)
这里有几个值得解释的点。
第一,coords.requires_grad_(True) 必须在网络前向计算之前设置,否则autograd无法对坐标求导。我在代码里用 torch.cat 把 (N,1) 的x和y拼成 (N,2),然后一次性求坐标梯度。
第二,网络输出u的维度是 (N,1),对 (N,2) 的 coords 求梯度会得到 (N,2) 的雅可比矩阵。我们取第0列是 ∂u/∂x,取第1列是 ∂u/∂y。
第三,注意 f 的公式里,x 和 y 是原始传入的张量,必须和 coords 里的值对应一致,不能混用 detach 后的版本。
4.3 损失权重调整的经验
总损失写成:
L = L_pde + λ_bc * L_bc
边界损失 λ_bc 在实践里通常不能直接设成1。原因是PDE残差的量级和边界loss的量级经常差几个数量级。我的经验是先让边界条件训练得更“硬”一点,比如 λ_bc 取10甚至50。这样网络先学会边界上输出为0,再去调整内部满足方程,整体收敛更稳定。
有一个常见的失败模式:如果不加权重,边界loss会被PDE loss淹没在量级之外,训练生出来的解在边界完全不归零。此时如果直接看内部L2误差,会发现误差很大,而且主要集中在边界附近。
我建议把 λ_bc 做成一个可调参数,先固定epoch跑一轮,观察边界loss和PDE loss的绝对值,再回来调整。也可以使用学习率衰减机制,让PDE loss在后期逐渐占据主导。
两个loss的组装代码:
python复制def boundary_loss(model, x_bc, y_bc, u_bc):
coords = torch.cat([x_bc, y_bc], dim=1)
u_pred = model(coords)
return torch.mean((u_pred - u_bc)**2)
边界值 u_bc 在Dirichlet零边界条件下就是全零张量。
最终总损失:
python复制loss = pde_loss(model, x_in, y_in, k) + lambda_bc * boundary_loss(model, x_bc, y_bc, u_bc)
5. 训练过程:从Adam到LBFGS的优化组合
5.1 第一阶段:Adam快速粗调
训练PINN不能指望单靠一个优化器从头收到尾。经验上最有效的组合是“分阶段训练”:先用Adam把解的大致形态学出来,再用LBFGS把损失压到非常低。
Adam的好处是自适应学习率,对初始学习率不那么敏感,起步阶段即便是1e-3也能稳定下降。但它的问题是后期精度上不去,尤其是你需要二阶信息的时候,Adam基本都在一个平台期来回震荡。这里的做法是:
python复制optimizer_adam = torch.optim.Adam(model.parameters(), lr=1e-3)
第一阶段训练大约10000步。每1000步打印一次总loss、PDE loss和边界loss。通常在5000步左右,你会看到loss下降开始变得非常缓慢,这时候就可以切优化器了。
5.2 第二阶段:LBFGS精修
LBFGS是整个PINN训练里的经典利器。它是一种拟牛顿法,利用损失函数的一阶梯度来近似Hessian矩阵,因此具备二阶收敛特性,在光滑损失面上特别适合做最后的精细收敛。
LBFGS在PyTorch中的用法和Adam完全不同,它需要传入一个closure函数,这个函数每次被调用时,需要重新计算loss并进行反向传播:
python复制optimizer_lbfgs = torch.optim.LBFGS(model.parameters(), lr=1.0, max_iter=50, history_size=100)
def closure():
optimizer_lbfgs.zero_grad()
loss = compute_total_loss()
loss.backward()
return loss
optimizer_lbfgs.step(closure)
我建议不要直接设 max_iter=1000 然后祈祷一次跑完,而是把LBFGS的step放进一个外层循环,比如循环50次,每次 max_iter=50。这样每跑完一轮可以打印一次loss,看看有没有卡住,也方便中途调整。
LBFGS比Adam“凶猛”得多,loss会像跳水一样下降,但在边界和内部约束之间拉扯严重时,偶尔会跳到不理想的局部最小值。如果发现loss上升,可以降lr到0.1,或者回退到Adam再跑几百步,再切回来。
5.3 训练日志与loss曲线形态
训练过程中最好同时记录PDE loss和边界loss。正常收敛时,你会看到两者都在下降。如果PDE loss先降到很低,而边界loss却卡在一个平台,说明边界权重太低,网络选择了“内部正确但边界错误”的解。反之,如果边界loss很低但PDE loss迟迟下不去,可能是波数太高或者网络容量不足。
我打印loss时习惯直接看自然对数尺度下的值。PINN的问题里loss动态范围可能从1e-1下降到1e-6,线性坐标下后期完全看不出变化。用log_loss = torch.log10(loss)打印,能清晰看到每一个阶段的推进情况。
5.4 训练失败时的排查路线
我整理了几个最常见的翻车场景,按概率排序:
- 激活函数用了ReLU,二阶导数信息传不回来。特征就是loss降到一定程度后完全不动,或者训练刚开始就NaN。解决办法:换成tanh或sin。
- 边界条件权重太小。特征:边界loss比PDE loss高好几个数量级。解决办法:调大λ_bc。
- 学习率过小或过大。Adam阶段建议1e-3,LBFGS阶段建议从1.0开始,如果loss上升就降为0.1。
- 隐藏层太少或太窄,无法表达振荡解。特征:增加神经元数量后loss明显下降。
- 采样点太少,网络在某些区域“看不见”方程约束。内部点至少5000,边界点至少每条边100。
这条排查路线我用过很多次,基本能覆盖90%以上的PINN入门问题。
6. 结果验证与误差分析
6.1 测试点的网格预测
训练完成后,你可以用真解做一次全网格对比。在[0,1]×[0,1]上生成一个均匀网格,比如100×100个点,把坐标点喂给网络,得到预测解,再算真解。网格预测的代码很简单:
python复制x_test = torch.linspace(0, 1, 100)
y_test = torch.linspace(0, 1, 100)
X, Y = torch.meshgrid(x_test, y_test, indexing='ij')
coords_test = torch.stack([X.flatten(), Y.flatten()], dim=1)
u_pred = model(coords_test).detach().reshape(100, 100)
这里的 indexing='ij' 是为了让X和Y的维度对应x方向和y方向,做等高线图时不会出现转置混乱。
6.2 可视化解与真解对比
画出三张图:真解、PINN预测、误差。这是判断网络是否学到了物理规律的最直观方式。我直接用matplotlib的 contourf。
python复制import matplotlib.pyplot as plt
u_true = (torch.sin(torch.pi * X) * torch.sin(torch.pi * Y)).numpy()
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
ax = axes[0].contourf(X.numpy(), Y.numpy(), u_true, levels=100, cmap='RdBu')
axes[0].set_title('True Solution')
fig.colorbar(ax, ax=axes[0])
ax = axes[1].contourf(X.numpy(), Y.numpy(), u_pred.numpy(), levels=100, cmap='RdBu')
axes[1].set_title('PINN Prediction')
fig.colorbar(ax, ax=axes[1])
error = (u_pred.numpy() - u_true)
ax = axes[2].contourf(X.numpy(), Y.numpy(), error, levels=100, cmap='RdBu')
axes[2].set_title('Error')
fig.colorbar(ax, ax=axes[2])
plt.show()
正常情况下,预测解的等值线应该和真解基本重合,误差在1e-3甚至更小量级。如果看到误差图出现明显的条纹,而且条纹方向沿着边界,那通常说明边界约束没有被充分满足。
6.3 量化误差:L2相对误差怎么算
仅仅靠眼睛看图不够,还需要一个量化指标。常用的相对L2误差定义是:
L2_rel = ||u_pred - u_true||₂ / ||u_true||₂
PyTorch写法:
python复制l2_error = torch.norm(u_pred - u_true) / torch.norm(u_true)
这里的torch.norm默认是Frobenius范数,对展开后的向量等价于L2范数。低波数情形(k=2, m=1, n=1)下,训练良好的网络L2相对误差能做到1%甚至更低。这个指标的意义是:网络在没有任何内部标签数据的前提下,只靠PDE约束和边界条件,就复现出了解析解。
如果误差在5%以上,先别急着调模型,回头看一下训练loss有没有卡住,边界loss是不是还有较大残差。误差大很多时候是优化问题,不是网络结构问题。
7. 高频振荡下的翻车实录与调参技巧
7.1 波数增大后为什么训练崩掉
把 m,n 从1改成2或3,解的振荡频率变高,问题立刻变难。神经网络有个著名的光谱偏差特性:网络倾向于先学低频分量,再去补高频细节。Helmholtz方程里 k²u 这一项把高频振荡直接带入方程,网络在有限神经元容量下要同时拟合多个波峰波谷,优化难度剧增。
我实测 m=n=2、k=2 时,普通tanh MLP训练20000步后,L2相对误差仍然在8%到15%之间。m=n=3 时基本就训不动了,loss卡在1e-3下不去,误差图明显少了一两个“波瓣”。这不是模型代码写错了,而是神经网络的表达与优化能力在高频区间出现了瓶颈。
7.2 傅里叶特征映射是对付高频的利器
既然原生网络对高频不敏感,一个自然的想法就是把输入坐标先“升维”成高频特征,再送入网络。经典做法是随机傅里叶特征映射(Random Fourier Features, RFF):
γ(x) = [cos(Bx), sin(Bx)]
其中 B 是一个 2×M 的矩阵,元素从高斯分布采样。B 的标准差 σ 决定特征频率。σ越大,网络越容易拟合高频变化;但σ过大会引入大量噪声,反而让优化崩溃,所以要调。
代码实现:
python复制class FourierFeature(nn.Module):
def __init__(self, in_dim=2, mapping_dim=128, sigma=2.0):
super().__init__()
self.B = torch.randn(in_dim, mapping_dim // 2) * sigma
def forward(self, x):
proj = x @ self.B.to(x.device)
return torch.cat([torch.cos(proj), torch.sin(proj)], dim=-1)
然后把原来的二维输入换成这个高维特征再送入MLP。这个技巧最早在NeRF里被证明有效,在PINN高频问题上一样好用。m=n=3 这种以前训不动的情况,RFF配合σ=2到4,能明显改善误差。
7.3 多阶段训练与损失衰减策略
即使加了RFF,也不能一口气把σ设得非常大。我建议做一次“课程学习”:先让网络在一个低频辅助问题上训练到收敛,比如先用 k=1 或 m=n=1 训练几十个epoch,然后把m、n和k逐步提升,每个阶段用上一阶段训练好的网络做初始化,让新的高频分量在已有解的基础上做增量修补。
训练代码可以这样设计:
python复制frequencies = [(1, 1), (2, 2), (3, 3)]
for m, n in frequencies:
for epoch in range(2000):
loss = compute_loss_with_mn(model, m, n, k=2)
optimizer.zero_grad()
loss.backward()
optimizer.step()
这样做的好处是,网络在高频阶段前已经学到了一个光滑的初始解,梯度方向不会在刚开始就陷入混乱。实际效果上,多阶段训练比直接上高频最终误差能降低一个数量级。
另外,如果损失权重 λ_bc 在高频阶段变得不稳定,可以尝试让边界损失权重随epoch从10衰减到1,让训练后期网络有更多自由度去优化内部PDE残差。这个衰减策略类似模拟退火,能减少高频局部振荡带来的边界过冲。
我在实际调参中最大的体会是:PINN模型本身很简单,难的是控制优化过程。很多人第一次跑通低波数案例后,就认为PINN已经会了,结果把波数调高一倍立刻被打回原形。这时候不要怀疑原理,先尝试RFF、多阶段训练和损失权重衰减这三大件,大部分高频问题都能得到缓解。
最后再分享一个实用技巧:如果你想让训练更稳,可以把边界点设计成“每条边均匀分布+内部点随机分布”的组合。内部点随机分布能防止漏掉某些区域,边界点均匀分布能让边界约束稳定,两者结合比全部随机采样收敛速度明显快一些。这个细节在写论文或做仿真时容易忽略,但对复现结果非常关键。
