我最初接触PINN(Physics-Informed Neural Networks,物理信息神经网络)的时候,第一反应是这玩意儿肯定得博士起步、数学拉满才能碰。但真正动手做下来,发现它的核心思想其实非常朴素:让神经网络在学数据的同时,还要“遵守”物理方程。这篇文章我就用Python一步步拆给物理基础一般的读者看,PINN到底怎么给神经网络注入“物理常识”,以及我实际训练中踩过哪些坑。
先说清楚它能解决什么问题:当你的训练数据稀疏、实验条件不允许大量采样时,传统神经网络很容易学出违反物理直觉的结果——比如温度分布出现负值、流体速度违背质量守恒。PINN的做法是在损失函数里加入物理方程的残差项,让网络输出的解必须同时满足数据观测和物理定律,相当于免费获得一份来自物理世界的“先验知识”。
这篇文章适合三种人:一是被导师/老板要求用PINN但没人带的Python用户,二是熟悉传统神经网络、想给模型加点物理约束的从业者,三是物理课上睡得稀里糊涂、但想用代码理解偏微分方程的学生。我会从一个常微分方程例子开始,再上难度到二维偏微分方程,最后分享排查训练不收敛的经验。
1. 一句话说清PINN的原理:神经网络居然能“自己懂”物理方程
1.1 传统神经网络把物理定律当空气
想象你要预测一个单摆在空中的摆动角度。传统神经网络的做法是:给你一大堆时间点和对应的角度数据,网络通过调整权重去拟合这些点。数据密集的时候效果还行,一旦数据量不足或者噪声大,网络就会开始“放飞自我”,在没数据的地方画出匪夷所思的曲线——比如摆角直接冲上90度,完全不顾重力在拽着它。
问题出在哪儿?传统神经网络从头到尾只看数据,它根本不知道这个系统背后有一个单摆方程在约束着运动。数据是物理定律的“采样结果”,但网络只学了结果,没学定律本身。
PINN的思路是反过来的:我在训练的时候不仅喂数据,还把单摆方程本身作为一项“软约束”写进损失函数。如果网络输出一条违反单摆方程的曲线,哪怕它完美拟合了所有数据点,损失照样会很大。这样一来,网络的预测结果天然就“懂”物理了。
1.2 PINN的损失函数怎么写
PINN损失函数的基本形式可以用一句话归纳:数据损失 + 物理残差损失 + 初始/边界条件损失。假设我们要解决的物理问题控制方程是 (F(u)=0),其中 (u) 是待求解的物理场,那神经网络的输出 (u_{\theta}(x))((\theta) 是网络参数)就被要求满足三个东西:
- 在观测点 (x_{data}) 上靠近真实测量值 (u^*)
- 在内部采样点 (x_{pde}) 上让方程残差 (F(u_{\theta})) 接近于0
- 在边界/初始点 (x_{bc}) 上满足给定的边界或初始条件
打个比方,你教孩子投篮,传统方式只盯着“球进没进”这个数据结果;PINN的方式是既看命中率(数据损失),又给他一本物理手册,让他出手的角度、力度必须符合抛物线运动规律(方程残差),还得在起跳位置遵守身体重心不能出界的规则(边界条件)。三重约束加在一起,网络输出的轨迹自然又准又符合常识。
# PINN总损失的核心形态(伪代码) loss = loss_data + lambda_pde * loss_pde + lambda_bc * loss_bc这里的 (\lambda) 是权重系数,用来调节物理约束和边界约束的强弱。数学上这称为“软约束”,意思是方程不是被硬编码死,而是作为惩罚项引导网络参数更新。软约束的好处是灵活:哪怕你的方程模型有不确定性,也可以通过调低 (\lambda_{pde}) 让网络更信任数据。
1.3 自动微分是那个关键的“物理考试考官”
可能有读者会问:物理方程的残差 (F(u_{\theta})) 里通常带导数,比如单摆方程里有角度对时间的二阶导,神经网络输出的函数怎么求导?总不能离散步长用差分吧——差分误差大,而且网络是连续的,用差分简直暴殄天物。
答案就是自动微分(Automatic Differentiation)。这是深度学习框架(PyTorch/TensorFlow)内置的看家本领,本来的用途是反向传播算梯度,但我们可以借它来计算神经网络输出对任意输入的导数。你只需要定义 (u_{\theta}(t)),框架就能精确求出一阶导 (du_{\theta}/dt)、二阶导 (d^2u_{\theta}/dt^2),精度远高于数值差分。
所以“物理考试考官”其实是个数学工具:它拿过网络输出的函数,精确求导后带进物理方程,算出残差,再把残差交还损失函数去训练网络。没有自动微分,PINN不会像今天这么容易上手。
2. 开写之前,先把Python环境和库准备好
2.1 推荐环境组合
代码层面,我建议的黄金组合是:Anaconda + Python 3.9 + PyTorch + Matplotlib。如果你还装了VS Code的Python插件,调试会更顺手。装PyTorch的时候注意一下CPU/GPU版本,单摆这种小例子CPU就够了,二维泊松方程也勉强能跑;但如果要上到三维流场,GPU是刚需。
我在实践中的建议是先建一个干净的conda环境,别直接用base环境,因为后面装其他依赖的时候不会污染你的日常开发环境。
conda create -n pinn python=3.9 conda activate pinn pip install torch matplotlib notebook安装完成后,建议顺手测一下
import torch x = torch.linspace(0, 1, 10, requires_grad=True) y = torch.sin(x) dy = torch.autograd.grad(y, x, grad_outputs=torch.ones_like(y), create_graph=True)[0]能正常算出cos(x)的近似值,说明自动微分链路没问题。
2.2 为什么不建议直接上DeepXDE
网上搜PINN教程,大概率会看到DeepXDE这个第三方库,它的API封装得挺好,几行代码就能跑经典案例。但我个人建议第一遍学PINN时,不要直接用DeepXDE。
原因有两个。第一,DeepXDE封装得太黑盒,你很难直观感受损失函数里每一步在算什么,出了bug也难排查。第二,真实项目里你的物理方程往往不是标准形式,到时候你仍然要回到底层框架手写网络和损失。
我自己的做法是:先用PyTorch手写一遍核心训练循环,理解每一步在干什么;等真正需要处理复杂方程、频繁做实验时,再考虑用DeepXDE这类工具提升效率。新手阶段,手写是捷径。
2.3 你需要的最低数学门槛
这部分给“物理小白”一颗定心丸:你不是要把本科四大力学全学一遍才能动手。做PINN需要的数学底子,掰手指头数就这几个:
- 会看偏微分方程长什么样(比如 (\frac{\partial u}{\partial t} = \alpha \frac{\partial^2 u}{\partial x^2}) 是热传导方程)
- 理解导数的几何含义(斜率、变化率)
- 知道什么是初值条件和边界条件
- 对损失函数的最小化有直觉(梯度下降、Adam优化器)
足矣。不需要你手推解析解,因为PINN本身就是为了绕开传统数值方法的解析复杂度而生的。方程本身可以交给代码去“求解”,你的任务只是把方程形式翻译成代码里的残差表达式。
3. 第一个例子:让神经网络学会单摆的运动
3.1 单摆方程和它的难点
物理问题我选了单摆,因为每个人都见过荡秋千,直觉上亲近。理想单摆的运动方程是一个二阶非线性常微分方程:
[ \frac{d^2\theta}{dt^2} + \frac{g}{L} \sin\theta = 0 ]
其中 (\theta) 是摆角,(g) 是重力加速度,(L) 是摆长。初值条件我取:(\theta(0)=0.3) 弧度,初始角速度 (\frac{d\theta}{dt}(0)=0)。
这个方程有个特点:小角度近似下 (\sin\theta \approx \theta),解是简谐运动 (\theta(t)=\theta_0 \cos(\sqrt{g/L} , t))。但我们取0.3弧度(约17度),非线性效应已经有一定影响,解析解没那么容易写出来。这正是展示PINN能力的好机会——它不依赖你求出解析解,照样能得到高精度的数值解。
3.2 搭建网络并定义损失
网络结构非常简单,输入一个时间标量 (t),输出摆角 (\theta)。中间三层全连接,每层64个神经元,激活函数用Tanh。
为什么用Tanh而不是ReLU?这里要提前划重点:PINN损失里至少要算二阶导,ReLU的一阶导是阶跃函数、二阶导恒为0,压根没法表达动力学信息。Tanh平滑且导数有界,是求解这类方程时最稳的选择之一。
接下来是核心:怎么把物理方程翻译成损失函数。我需要三个损失项:
内部点残差:在时间域内随机采样 (t),用自动微分算出 (\frac{d^2\theta}{dt^2}),代入方程左边,希望结果尽量接近0。注意这里的“代入”不是解方程,而是验证 (- \frac{d^2\theta}{dt^2} - \frac{g}{L}\sin\theta) 这个残余量是否为0。
初始角度损失:(\theta(0)) 应该等于0.3弧度。
初始角速度损失:(\frac{d\theta}{dt}(0)) 应该等于0。
代码如下:
import torch import torch.nn as nn import numpy as np import matplotlib.pyplot as plt torch.manual_seed(42) g = 9.8 L = 1.0 theta0 = 0.3 class SinglePendulumPINN(nn.Module): def __init__(self): super().__init__() self.net = nn.Sequential( nn.Linear(1, 64), nn.Tanh(), nn.Linear(64, 64), nn.Tanh(), nn.Linear(64, 64), nn.Tanh(), nn.Linear(64, 1) ) def forward(self, x): return self.net(x) model = SinglePendulumPINN() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) def compute_loss(): optimizer.zero_grad() # 内部物理残差 t_f = torch.rand(2000, 1) * 5.0 t_f.requires_grad_(True) theta = model(t_f) theta_t = torch.autograd.grad(theta, t_f, grad_outputs=torch.ones_like(theta), create_graph=True)[0] theta_tt = torch.autograd.grad(theta_t, t_f, grad_outputs=torch.ones_like(theta_t), create_graph=True)[0] residual = theta_tt + (g / L) * torch.sin(theta) loss_pde = torch.mean(residual ** 2) # 初始条件 t0 = torch.zeros(100, 1) theta0_pred = model(t0) theta_t0 = torch.autograd.grad(theta0_pred, t0, grad_outputs=torch.ones_like(theta0_pred), create_graph=True)[0] loss_bc = torch.mean((theta0_pred - theta0) ** 2) + torch.mean(theta_t0 ** 2) return loss_pde + 10.0 * loss_bc for step in range(10000): loss = compute_loss() loss.backward() optimizer.step() if step % 1000 == 0: print(f"step {step:5d}, total loss = {loss.item():.3e}")在训练的过程中你会观察到几个现象:前几百步损失下降非常快,后面趋于平缓;初始条件的损失通常比内部残差下降更慢,所以我给边界损失加了10倍的权重。这是PINN调参的一个重要套路——边界条件的违反往往是总损失下降后依然存在的“漏网之鱼”,要舍得给权重。
3.3 训练结果与数值解对比
训练完成后,把时间区间([0, 5])均匀取500个点,用训练好的模型预测摆角,同时用经典的高精度数值解法(比如scipy的solve_ivp,四阶Runge-Kutta)算一条参考曲线做对照:
from scipy.integrate import solve_ivp def ode_func(t, y): # y = [theta, omega] return [y[1], -(g / L) * np.sin(y[0])] sol = solve_ivp(ode_func, [0, 5], [theta0, 0], t_eval=np.linspace(0, 5, 500), rtol=1e-10) t_test = torch.linspace(0, 5, 500).reshape(-1, 1) theta_pinn = model(t_test).detach().numpy() plt.figure(figsize=(10, 5)) plt.plot(sol.t, sol.y[0], 'k--', label='Runge-Kutta reference') plt.plot(t_test.numpy().ravel(), theta_pinn.ravel(), 'r-', label='PINN prediction') plt.xlabel('time (s)') plt.ylabel('angle (rad)') plt.legend() plt.grid(alpha=0.3) plt.show()以我的经验,训练到1万步,PINN曲线和Runge-Kutta参考曲线在视觉上几乎完全重合,最大绝对误差大约在 (10^{-3}) 到 (10^{-2}) 这个量级。对一个只有三层全连接、没有任何数据点输入的网络来说,这个精度已经相当惊艳——它纯粹是靠物理方程“自监督”学出来的。
4. 第二关:二维泊松方程,从常微分到偏微分
4.1 为什么加一个维度,难度就上去了
单摆问题其实还停留在常微分方程的世界里,输入是时间一维,输出是整个物理场。工程上更常见的是偏微分方程:空间里无数个点,每个点有一个未知数,且方程里出现对空间的偏导数。
我选的第二个基准问题是定义在单位正方形 ([0,1]\times[0,1]) 上的二维泊松方程:
[ -\Delta u = -\left( \frac{\partial^2 u}{\partial x^2} + \frac{\partial^2 u}{\partial y^2} \right) = \pi^2 \sin(\pi x)\sin(\pi y) ]
边界条件为四边 (u=0)。这个方程的妙处在于它有一个漂亮的手算解析解 (u_{exact}(x,y) = \sin(\pi x)\sin(\pi y)),方便我们严格评估PINN的表现。
难度上升体现在三处。第一,输入从一维变成二维,网络要学习的函数曲面更复杂,神经元数量要加。第二,对每个输入点要分别对(x)和(y)求二阶导,自动微分的调用次数变多。第三,边界从两个点变成整条边,边界采样的策略需要重新设计。
4.2 边界点的采集与损失设计
内部点我还是用均匀随机采样,边界点则沿着四条边随机采样。注意,边界采样必须覆盖一整圈,不能只采几条边——这是很多新手会忽略的,四条边各缺一个角都不行。
def sample_boundary(n_per_side): x_left = torch.zeros(n_per_side, 1) y_left = torch.rand(n_per_side, 1) x_right = torch.ones(n_per_side, 1) y_right = torch.rand(n_per_side, 1) x_bottom = torch.rand(n_per_side, 1) y_bottom = torch.zeros(n_per_side, 1) x_top = torch.rand(n_per_side, 1) y_top = torch.ones(n_per_side, 1) x_b = torch.cat([x_left, x_right, x_bottom, x_top], dim=0) y_b = torch.cat([y_left, y_right, y_bottom, y_top], dim=0) return x_b, y_b网络结构可以沿用三层、每层64神经元的结构,但激活函数依然是Tanh。残差表达式换成泊松方程:
def pde_residual(model, x, y): x = x.clone().requires_grad_(True) y = y.clone().requires_grad_(True) u = model(torch.cat([x, y], dim=1)) 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] rhs = (torch.pi ** 2) * torch.sin(torch.pi * x) * torch.sin(torch.pi * y) return -(u_xx + u_yy) - rhs这里有个细节值得多说一句:对(x)求偏导时,(y)要视作常数,自动微分在计算图里自然会做到这一点,因为x和y是独立的叶子张量。
4.3 训练结果与误差分析
训练到3万步左右,把预测值和解析解对比,最大绝对误差大概在 (10^{-2}) 量级,相对误差在5%以内。可视化时把PINN预测值和解析解画成两个子图并排对照,人眼几乎无法分辨。
不过要认真说的话,用PINN求泊松方程,暂时还卷不过有限差分法、有限元法这些经典数值方法,传统方法在网格剖分足够密时,速度和精度都更胜一筹。这恰恰点出了PINN的定位:它不是来“取代”传统求解器的,而是在传统方法不好使的场景中补位——比如反问题、部分数据缺失、需要连续可导场输出等。后面我会专门展开。
5. 我的踩坑记录:激活函数、损失权重与采样策略
5.1 用ReLU训练二阶方程,损失卡住不动
这是训练单摆时最典型的一个坑。我第一次图省事,直接用了ReLU作为激活函数,结果损失降到某一个值之后就纹丝不动了。查看各项损失,发现loss_pde始终在 (10^{-1}) 这个量级徘徊。
后来我想明白了:ReLU的二阶导几乎处处为0,当网络试图让二阶导 (\frac{d^2\theta}{dt^2}) 去匹配 (-\frac{g}{L}\sin\theta) 这个非零目标时,它根本没有表达空间,梯度于是失去作用。换成Tanh后,二阶导信息可以流畅地回传,问题立刻解决。
教训:PINN里的激活函数选择不是“偏好问题”,而是“能不能算”的硬性问题。涉及二阶及以上导数的方程,先老老实实用Tanh或Sigmoid。如果遇到一阶方程,尝试Swish/GELU这些也更平滑的激活函数也没有问题。
5.2 损失权重不是随便设的
单摆例子里我给初始条件损失乘了10,泊松例子里边界损失乘了50。这个权重怎么确定?
一个实用经验是:先让边界条件损失和内部残差损失在量级上差不多,再加倍强调边界条件。因为内部残差是对大量采样点取平均,天然会“稀释”掉单点误差;边界条件如果不够强,网络会在边界上“和稀泥”,导致整体解看起来光滑但边界值明显不满足要求。
如果觉得手调权重累,可以考虑用“梯度归一化”或者“自适应权重”(例如根据各损失项梯度的范数动态调整 (\lambda)),这类技巧在论文里叫“Self-Adaptive PINN”。新手阶段可以先手调,但心里要明白有更自动的方案。
5.3 随机采样和自适应加密
PINN训练时,内部点一般是每个训练步随机重采样的。这种“每步换点”的做法其实相当优雅,等效于在网络训练过程中不断提供新鲜的“考试题”,避免模型死记硬背一套固定的采样点。
不过均匀随机采样在峰值密集的区域可能不够用。比如泊松方程的解析解在中心区域变化比较剧烈,如果把采样点加密到中心,收敛速度会明显加快。工程上这叫残差自适应采样(RAR):训练一段后,找出残差最大的那些点,在它们附近增加采样密度,再继续训练。这个技巧在处理高梯度区域(如边界层、激波)时特别有用。
5.4 训练发散时的急救包
有一次做带周期性边界条件的例子,损失曲线直接冲上 (10^6),彻底爆炸。排查下来有两个帮凶:一是初始学习率太高(1e-2),二是有几个边界点采样重复导致梯度异常。
我的急救顺序是:
- 降低学习率到1e-4或更低,哪怕慢一点,先让loss恢复稳定;
- 检查采样代码有没有重复点、NaN;
- 给输入做归一化,让(t)或((x,y))落在[-1,1]区间;
- 换成更深的网络不一定有用,先试试加宽单层;
- 如果还不稳定,考虑梯度裁剪。
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)这套组合拳我试过很多次,至少能救回一半的发散案例。
6. PINN能做啥、不能做啥,以及下一步玩什么
6.1 最适合PINN的三个赛道
第一是反问题。现实中我们往往不知道方程里的某些系数,比如热传导系数、反应速率常数,但能测到部分场的数据。PINN可以把这个系数设成可训练参数,和网络一起优化,最终“反演”出未知参数。这是传统数值方法比较头疼的问题,PINN做起来却无比自然。
第二是数据太少的正问题。有些实验环境特殊,比如极端温度、有毒环境,实测数据点非常有限。依赖传统数据驱动模型很容易过拟合,PINN利用物理规律作约束,可以用很少的数据保持泛化能力。
第三是需要连续可导场输出的场景。传统数值方法给出的是离散网格上的解,如果需要任意一点的函数值和导数(比如做灵敏度分析),往往需要插值并误差传播;PINN输出天然为连续可导函数,拿来即用。
6.2 明确的短板
说实话,把PINN吹上天是不负责任的做法。它的短板也是明摆着的:
- 高维问题(维度大于10)训练成本爆炸,残差采样点呈指数级增长,目前很难跟传统方法争锋。
- 强间断问题(比如激波、材料断裂)会让Tanh网络难以捕捉尖锐跳变,需要考虑分区训练或特殊激活函数。
- 训练不稳定,超参数敏感,有时你会觉得调一个PINN比调三个普通神经网络还费劲。
- 精度和传统数值求解器比,目前没有绝对优势。网格剖分足够密时,有限差分/有限元仍然强劲。
6.3 值得你继续深入的方向
如果看完这篇,你决定深入PINN,我觉得接下来值得尝试的路线是:
- 用PINN做参数辨识,把第3节的单摆 (g/L) 当作未知参数,并用带噪声的观测数据去反演它。
- 尝试时间依赖的PDE,比如热传导方程,理解时间维度和空间维度在残差设计上的区别。
- 学习DeepXDE的API,手写经验积累之后再用工具提高生产力,效率会很不一样。
- 关注FNO(傅里叶神经算子)这类算子学习模型,它们和PINN不是竞争而是互补——算子学习追求更快地预测,PINN追求在数据稀疏时守住物理规律。
我在实际做PINN项目时最深的体会是:它并不会让你从此告别数值方法,但它确实提供了一种把物理知识结构化嵌入神经网络的干净思路。很多项目里,把PINN的物理残差项加进传统深度学习模型,即使收敛略慢,预测合理性和外推能力也能肉眼可见地变好。如果你手上正好有一个数据稀疏、又恰好有控制方程可用的预测问题,别犹豫,照着这篇文章把第一个例子跑通,你会很快感受到“物理常识”所带来的巨大压强。