简介:这份资源围绕物理信息神经网络(PINN)求解微分方程展开,面向具备一定Python与深度学习基础、希望将神经网络用于科学计算的研究生、工程师及科研人员。内容覆盖常微分方程、扩散方程、泊松方程、拉普拉斯方程、欧拉梁及洛伦兹系统等典型问题,并涉及Dirichlet、Neumann、Robin、周期等不同边界条件的处理,同时包含Jacobian-Hessian方法在ODE与Laplace方程上的测试对比,便于读者理解PINN在正问题求解中的建模思路与实现细节。资源包共22个文件,以17个ipynb交互式笔记本为主体,配合3个py模块(如PDE、model、几何处理)与1个md说明文档、1张结果图,整体约889KB,结构紧凑、便于按案例逐个运行调试。目前已有2974人学习下载,适合作为PINN入门实践与微分方程数值求解的参考素材。
1. 从“解不动”到“学出来”:PINN 求解微分方程到底在干什么
做工程仿真的人大多遇到过这种局面:方程写得出来,边界条件也清楚,但网格一加密、维度一升高,传统数值方法的计算量就指数级往上蹿;换个几何形状、换组参数,又得重新剖网格、重新迭代。PINN(物理信息神经网络,Physics-Informed Neural Network)换了个思路——不去离散空间,而是用一个神经网络去逼近解函数本身,把微分方程、初值边界条件直接写进损失函数里,让网络在训练中“学会”满足物理规律。它最吸引人的地方是:一套代码框架,换个方程、换组边界条件就能复用,还能顺带把参数反演一起做了。这篇笔记就围绕“基于 PINN 物理信息网络求解微分方程(python)”这条线,把原理、选型、可复现的代码、必调参数和踩过的坑一次讲清楚,适合已经会一点 Python、想把这套方法真正跑起来的人。
先说清楚它解决什么问题。传统有限差分、有限元是“先离散、再求解”,精度靠网格,代价也靠网格;PINN 是“先假设一个光滑函数、再让残差最小”,精度靠网络容量和训练,代价主要花在优化上。它特别适合三类场景:高维 PDE(网格法维度灾难明显)、反问题(方程里有未知参数,观测数据稀疏)、以及几何或边界条件频繁变化的参数化求解。反过来说,如果是一维、低维、对精度要求到小数点后很多位、又追求极致速度的规则问题,老老实实上有限差分往往更划算。判断标准很简单:你的问题是不是“网格难剖、参数未知、要反复换条件”,是就往 PINN 上靠,不是就别硬套。
这篇的路线是:先把 PINN 的损失构造和自动微分机制讲透,再给一个能直接跑的 Python 最小实现,然后逐项拆解网络结构、配点采样、损失权重这几个真正决定成败的参数,接着专门用一章讲排查和避坑,最后落到进阶技巧和怎么验证结果可信。全程用 PyTorch,因为它对自动微分和高阶导的支持最顺手,社区里 pinn 相关代码也大多基于它。下面从原理开始,一层层往下走。
2. 损失函数怎么构造:把微分方程翻译成可优化的目标
2.1 从强形式到残差损失
PINN 的核心思想一句话能说完:用一个神经网络 $u_\theta(x,t)$ 去逼近真解,然后要求它在求解域内部满足微分方程、在边界上满足边界条件、在初始时刻满足初值条件。把这三件事写成“残差”,再让残差的平方和最小,问题就变成了一个无约束优化。
以最经典的一维热传导方程为例:
$$\frac{\partial u}{\partial t} = \alpha \frac{\partial^2 u}{\partial x^2}, \quad x \in [-1,1],\ t \in [0,1]$$
配上初值 $u(x,0) = -\sin(\pi x)$ 和边界 $u(-1,t)=u(1,t)=0$。真解是 $u(x,t) = -e^{-\alpha\pi^2 t}\sin(\pi x)$,正好可以拿来验证。
PINN 定义三个损失项:
- 方程残差损失 $L_r$:在域内随机采一批配点 $(x_i, t_i)$,计算 $\frac{\partial u_\theta}{\partial t} - \alpha \frac{\partial^2 u_\theta}{\partial x^2}$,希望它接近 0。
- 边界损失 $L_b$:在边界上采点,希望 $u_\theta$ 等于给定边界值。
- 初值损失 $L_i$:在 $t=0$ 上采点,希望 $u_\theta$ 等于初值。
总损失 $L = L_r + \lambda_b L_b + \lambda_i L_i$,$\lambda$ 是权重。训练就是最小化 $L$。
关键点在于:这里的导数不是差分出来的,而是靠自动微分精确算出来的。这是 PINN 能成立的技术前提——如果导数靠数值差分,误差会随阶数放大,二阶导基本没法用。PyTorch 的autograd.grad支持对输入求导,且可以链式求高阶导,这正是它比一般深度学习框架更适合做 PINN 的原因。
2.2 自动微分求高阶导的写法
很多人第一次写 PINN 会卡在“怎么对网络输入求导”上。普通训练里我们求的是 loss 对参数的梯度,而 PINN 要的是输出对输入(坐标)的导数。写法上要用torch.autograd.grad并开启create_graph=True,否则二阶导会断掉。
import torch import torch.nn as nn # 一个最简 MLP:输入 (x, t),输出 u class MLP(nn.Module): def __init__(self, width=32, depth=4): super().__init__() layers = [nn.Linear(2, width), nn.Tanh()] for _ in range(depth - 1): layers += [nn.Linear(width, width), nn.Tanh()] layers += [nn.Linear(width, 1)] self.net = nn.Sequential(*layers) def forward(self, x, t): # 把 x, t 拼成 (N, 2) 输入 inp = torch.cat([x, t], dim=1) return self.net(inp) def compute_residual(model, x, t, alpha): x = x.clone().requires_grad_(True) # 必须对输入开梯度 t = t.clone().requires_grad_(True) u = model(x, t) # 一阶导:u 对 t u_t = torch.autograd.grad(u, t, grad_outputs=torch.ones_like(u), create_graph=True)[0] # 一阶导:u 对 x u_x = torch.autograd.grad(u, x, grad_outputs=torch.ones_like(u), create_graph=True)[0] # 二阶导:u_x 再对 x 求导 u_xx = torch.autograd.grad(u_x, x, grad_outputs=torch.ones_like(u_x), create_graph=True)[0] # 热传导方程残差 residual = u_t - alpha * u_xx return residual逻辑说明:requires_grad_(True)让坐标成为可求导变量;create_graph=True保留计算图,才能继续对一阶导再求导得到u_xx;grad_outputs=torch.ones_like(u)是因为u是向量,求导时要给一个同形状的“种子向量”,等价于对每个输出分量分别求导后求和。
参数说明:alpha是热扩散系数,这里当已知量;如果要做反演,把它设成nn.Parameter并加进优化器即可,这是 PINN 做反问题的标准套路。width和depth是网络容量,后面单独讲。
提示:
create_graph=True会显著增加显存占用,因为整条求导链的计算图都要保留。配点数一多就容易 OOM,这是新手最常见的翻车点之一。
2.3 三类损失怎么采样、怎么加权
采样策略直接决定训练稳不稳。常见做法是:
- 域内配点:在 $[-1,1]\times[0,1]$ 上均匀或拉丁超立方采样,数量通常 1000~10000。均匀采样对规则域够用,复杂域建议用拉丁超立方或低差异序列。
- 边界点:在 $x=-1$ 和 $x=1$ 两条边上各采一批,数量几百即可。
- 初值点:在 $t=0$ 上采一批,数量几百。
权重 $\lambda_b, \lambda_i$ 的选取是 PINN 里最“玄学”的部分。经验上,如果边界/初值损失比方程残差损失大一到两个数量级,训练会先满足边界再慢慢压残差,比较稳;如果三者量级差太多,网络会只顾一头。一个实用技巧是训练前先跑几步,打印三项损失的量级,再手动把权重调到同一量级附近。更进阶的做法是自适应权重(比如基于梯度范数动态调整),但那是后话,先把固定权重调稳。
下面把数据生成和损失组装补全:
def sample_points(n_r=2000, n_b=200, n_i=200): # 域内配点 x_r = torch.rand(n_r, 1) * 2 - 1 # [-1, 1] t_r = torch.rand(n_r, 1) # [0, 1] # 边界点:x = -1 和 x = 1 t_b = torch.rand(n_b, 1) x_b = torch.cat([-torch.ones(n_b // 2, 1), torch.ones(n_b // 2, 1)], dim=0) t_b = torch.cat([t_b[:n_b // 2], t_b[n_b // 2:]], dim=0) # 初值点:t = 0 x_i = torch.rand(n_i, 1) * 2 - 1 t_i = torch.zeros(n_i, 1) return (x_r, t_r), (x_b, t_b), (x_i, t_i) def total_loss(model, alpha, lam_b=10.0, lam_i=10.0): (x_r, t_r), (x_b, t_b), (x_i, t_i) = sample_points() # 方程残差 res = compute_residual(model, x_r, t_r, alpha) loss_r = torch.mean(res ** 2) # 边界:u(-1,t)=u(1,t)=0 u_b = model(x_b, t_b) loss_b = torch.mean(u_b ** 2) # 初值:u(x,0) = -sin(pi x) u_i = model(x_i, t_i) target_i = -torch.sin(torch.pi * x_i) loss_i = torch.mean((u_i - target_i) ** 2) return loss_r + lam_b * loss_b + lam_i * loss_i逻辑说明:sample_points每次调用重新随机采样,相当于每个 epoch 换一批配点,这本身就是一种正则化,能防止网络在固定点上过拟合。total_loss把三项按权重相加,返回标量供反向传播。
参数说明:n_r是配点数,太小残差约束不足,太大显存吃紧;lam_b、lam_i是边界和初值权重,先设 10 试,再根据损失量级调。alpha这里取 0.1 左右比较稳,太大方程刚性增强,训练容易发散。
3. 用 PyTorch 跑通最小可复现例子:训练循环与结果验证
3.1 训练循环与优化器选择
有了损失函数,训练循环本身和普通深度学习没太大区别,但有几个细节要注意:优化器一般用 Adam 起步,学习率 1e-3 到 1e-4;训练几千到几万步;后期可以切 L-BFGS 精调,但 L-BFGS 对显存和 batch 处理更敏感,新手先用 Adam 跑通。
import torch.optim as optim torch.manual_seed(0) model = MLP(width=32, depth=4) optimizer = optim.Adam(model.parameters(), lr=1e-3) alpha = 0.1 for step in range(10000): optimizer.zero_grad() loss = total_loss(model, alpha, lam_b=10.0, lam_i=10.0) loss.backward() optimizer.step() if step % 1000 == 0: print(f"step {step:5d} loss {loss.item():.6e}")逻辑说明:每个 step 重新采样配点、算总损失、反向传播、更新参数。打印间隔取 1000,方便观察损失是否稳定下降。
参数说明:lr=1e-3是 Adam 的常用起点;如果损失震荡,降到 1e-4;如果下降太慢,可以试试 5e-3 但别更高。10000步对一维热传导够用,复杂方程要加到几万步。
3.2 结果验证:和解析解逐点对比
训练完不能只看损失小就完事,必须拿真解对比。热传导方程有解析解,正好用来验证。
import numpy as np def exact_u(x, t, alpha=0.1): return -np.exp(-alpha * np.pi ** 2 * t) * np.sin(np.pi * x) # 在网格上对比 model.eval() xs = np.linspace(-1, 1, 100) ts = np.linspace(0, 1, 100) X, T = np.meshgrid(xs, ts) x_flat = torch.tensor(X.reshape(-1, 1), dtype=torch.float32) t_flat = torch.tensor(T.reshape(-1, 1), dtype=torch.float32) with torch.no_grad(): u_pred = model(x_flat, t_flat).numpy().reshape(X.shape) u_true = exact_u(X, T) err = np.abs(u_pred - u_true) print(f"max abs error: {err.max():.4e}") print(f"relative L2 error: {np.linalg.norm(err) / np.linalg.norm(u_true):.4e}")逻辑说明:在 100×100 网格上算预测值和真值,统计最大绝对误差和相对 L2 误差。相对 L2 误差是 PINN 论文里最常用的指标,一般能压到 1e-3 到 1e-4 量级算合格。
参数说明:model.eval()关掉训练模式(本例没有 dropout/batchnorm,但养成习惯);torch.no_grad()省显存。如果误差在 1e-2 以上,先别急着调网络,回头查损失权重和配点数。
3.3 网络结构选型:为什么是 Tanh 而不是 ReLU
激活函数的选择在 PINN 里不是小事。ReLU 的二阶导几乎处处为 0,而热传导、波动方程都要二阶导,用 ReLU 会导致残差恒为 0 或剧烈跳变,训练直接崩。所以 PINN 默认用 Tanh、Sin 或 GELU 这类光滑激活。Tanh 最常用,因为导数有界、光滑性好;Sin 激活(SIREN)在拟合高频解时更强,但初始化要特殊处理。
网络宽度和深度也有讲究。太浅(2 层)容量不够,拟合不了复杂解;太深(8 层以上)训练慢且容易梯度问题。一维问题 4 层 32 宽通常够;二维、三维问题建议 4~6 层、64~128 宽。判断标准是:先跑小网络,如果损失降不下去再加宽加深,别一上来堆大网络。
# 换成 Sin 激活的版本,适合高频/振荡解 class SinMLP(nn.Module): def __init__(self, width=64, depth=4): super().__init__() layers = [nn.Linear(2, width), nn.Sin()] for _ in range(depth - 1): layers += [nn.Linear(width, width), nn.Sin()] layers += [nn.Linear(width, 1)] self.net = nn.Sequential(*layers) def forward(self, x, t): return self.net(torch.cat([x, t], dim=1))逻辑说明:nn.Sin()是 PyTorch 内置的正弦激活,直接替换 Tanh 即可。注意 Sin 激活对初始化敏感,权重建议用较小的均匀分布初始化,否则第一层输出会剧烈振荡。
参数说明:width=64比 Tanh 版本宽,因为 Sin 激活表达能力更强但需要更多通道;depth=4保持不变。如果解里有明显的高频成分(比如多模态),Sin 激活优势明显。
4. 参数怎么设:配点数、权重、学习率的实操边界
4.1 配点数与采样分布
配点数是 PINN 里性价比最高的参数。太少,方程约束不足,网络会在配点之间“乱跑”;太多,显存和计算量线性上涨。经验值:
| 问题维度 | 推荐配点数 | 说明 |
|---|---|---|
| 1D + 时间 | 1000~3000 | 本例 2000 够用 |
| 2D + 时间 | 5000~20000 | 显存允许就往上加 |
| 3D + 时间 | 20000~100000 | 建议分 batch 训练 |
采样分布上,均匀采样对规则域够用;如果解在某个区域变化剧烈(比如边界层),要在那里加密。一个实用技巧是“残差自适应采样”:每隔若干步,在残差大的位置多采点。这比盲目加总量有效得多。
def adaptive_sample(model, alpha, n_new=500): # 在域内随机采一批候选点,挑残差最大的 x_c = torch.rand(5000, 1) * 2 - 1 t_c = torch.rand(5000, 1) res = compute_residual(model, x_c, t_c, alpha).detach().abs().squeeze() idx = torch.topk(res, n_new).indices return x_c[idx], t_c[idx]逻辑说明:先大量候选,算残差,取残差最大的前n_new个点作为新增配点。这样网络会把注意力放在还没学好的区域。
参数说明:n_new每次新增的点数,一般取总配点数的 10%~20%;候选池5000要远大于n_new,否则筛选没意义。
4.2 损失权重的调法
前面说权重先设 10,但具体怎么调有章法。推荐流程:
- 初始 $\lambda_b=\lambda_i=1$,跑 100 步,打印三项损失。
- 如果边界损失比残差损失小两个数量级以上,说明边界约束太弱,把 $\lambda_b$ 调大 10 倍。
- 如果边界损失主导、残差降不下去,把 $\lambda_b$ 调小。
- 反复两三轮,让三项损失在同一量级。
更系统的做法是自适应权重,比如 NTK 方法或梯度归一化,但实现复杂,新手先把固定权重调稳。下面是一个打印损失分项的辅助函数:
def loss_breakdown(model, alpha, lam_b=10.0, lam_i=10.0): (x_r, t_r), (x_b, t_b), (x_i, t_i) = sample_points() res = compute_residual(model, x_r, t_r, alpha) l_r = torch.mean(res ** 2).item() l_b = torch.mean(model(x_b, t_b) ** 2).item() u_i = model(x_i, t_i) l_i = torch.mean((u_i + torch.sin(torch.pi * x_i)) ** 2).item() print(f"residual {l_r:.3e} boundary {l_b:.3e} initial {l_i:.3e}")逻辑说明:分别算三项损失的数值,不参与反向传播,纯诊断用。训练前跑一次,训练中每隔几百步跑一次,观察量级变化。
参数说明:lam_b、lam_i只影响打印时的加权,诊断时可以先看原始值再决定怎么调。
4.3 学习率与训练步数
Adam 的学习率 1e-3 是安全起点。如果损失在前几百步就卡住不动,多半是学习率太小或网络初始化不好;如果损失剧烈震荡甚至变 NaN,学习率太大或方程太刚。一个稳妥策略是分段:前 5000 步 1e-3,后 5000 步降到 1e-4 精调。
训练步数没有固定值,看损失曲线。残差损失降到 1e-5 以下、边界和初值损失降到 1e-6 以下,基本就收敛了。如果跑了两万步还在 1e-3 徘徊,别硬跑,回头查权重和网络容量。
注意:PINN 训练是“慢工出细活”,一维问题几分钟到几十分钟,三维问题可能几小时。别指望像普通深度学习那样几十秒出结果,时间预算要提前留够。
5. 避坑与排查:PINN 训练不收敛的五个典型现场
5.1 损失不降反升,最后变 NaN
现象:训练几百步后损失突然爆炸,打印出 NaN。
原因:多半是学习率太大,或者方程里有刚性项(比如大系数、高阶导),导致梯度爆炸。也有可能是create_graph=True下计算图太深,数值不稳定。
解决:先把学习率降到 1e-4 甚至 1e-5 试;如果还不行,检查方程系数是不是量级过大,考虑对方程做无量纲化,把系数压到 1 附近。无量纲化是处理刚性问题最有效的手段,别嫌麻烦。
5.2 边界条件满足得很好,但域内解完全不对
现象:边界损失降到 1e-7,残差损失却卡在 1e-2 下不去,画出来的解在域内乱飘。
原因:边界权重太大,网络只顾满足边界,忽略了方程本身。这是权重失衡的典型表现。
解决:把 $\lambda_b$ 调小,或者把残差损失的权重相对调大。更根本的办法是检查配点数够不够——配点太少,残差约束本来就弱,网络自然偏向边界。先把配点加到 5000 以上再看。
5.3 用 ReLU 激活,二阶导全是 0
现象:残差损失从一开始就不降,或者降到一个值后完全不动。
原因:ReLU 的二阶导几乎处处为 0,热传导、波动方程这类需要二阶导的方程根本算不出有效残差。
解决:换成 Tanh、Sin 或 GELU。这是新手最常踩的坑,记住一句话:PINN 里凡是需要二阶导的方程,激活函数必须光滑。
5.4 显存爆掉,配点一多就 OOM
现象:配点数加到 5000 以上,程序报 CUDA out of memory。
原因:create_graph=True保留了整条高阶导计算图,显存占用远大于普通训练。配点数、网络宽度、深度三者叠加,很容易超。
解决:三个方向——减小 batch(把配点分批算残差再平均)、降低网络宽度、或者用混合精度。分批是最实用的,把 10000 个配点分成 10 批,每批 1000,显存立刻下来,梯度累积效果一样。
def total_loss_batched(model, alpha, lam_b=10.0, lam_i=10.0, batch=1000): (x_r, t_r), (x_b, t_b), (x_i, t_i) = sample_points(n_r=10000) loss_r = 0.0 for i in range(0, x_r.shape[0], batch): res = compute_residual(model, x_r[i:i+batch], t_r[i:i+batch], alpha) loss_r = loss_r + torch.mean(res ** 2) loss_r = loss_r / (x_r.shape[0] // batch) loss_b = torch.mean(model(x_b, t_b) ** 2) u_i = model(x_i, t_i) loss_i = torch.mean((u_i + torch.sin(torch.pi * x_i)) ** 2) return loss_r + lam_b * loss_b + lam_i * loss_i逻辑说明:把域内配点分批算残差,累加后取平均,等效于全量计算但显存占用降到 1/batch。边界和初值点少,不用分批。
参数说明:batch取 500~2000,看显存定;n_r=10000是总量,分批不影响最终损失值。
5.5 换了方程就崩,代码复用性差
现象:热传导跑通了,换成波动方程或 Burgers 方程,怎么调都不收敛。
原因:不同方程的刚性、边界条件、解的光滑性差别很大,一套超参不可能通吃。Burgers 方程有激波,解接近间断,普通 MLP 很难拟合。
解决:针对具体方程调整。Burgers 方程要么加宽网络、要么用自适应采样在激波附近加密、要么改用更高级的结构(比如带 Fourier 特征的网络)。别指望一套参数打天下,每换一个方程都要重新调权重和配点。
6. 进阶技巧与结果可信度验证:让 PINN 从“能跑”到“敢用”
跑通最小例子只是起点,真正要拿 PINN 做工程,得解决两个问题:怎么提精度,以及怎么证明结果可信。
提精度上,最实用的三个技巧。第一是自适应采样,前面给过代码,把配点往残差大的地方堆,比盲目加总量有效得多。第二是网络结构改进,普通 MLP 对高频、多尺度解力不从心,可以加 Fourier 特征映射(把输入先过一组不同频率的正余弦),或者用 SIREN 初始化,对振荡解效果明显。第三是训练策略,先用 Adam 粗调,再切 L-BFGS 精调,L-BFGS 在损失接近收敛时能把误差再压一个数量级,但它对 batch 敏感,配点要固定下来别每步重采。
# Fourier 特征映射:把 (x,t) 映射到多频正余弦 class FourierFeatures(nn.Module): def __init__(self, num_freqs=16, sigma=1.0): super().__init__() # 固定频率,不参与训练 self.register_buffer("B", torch.randn(2, num_freqs) * sigma) def forward(self, x, t): inp = torch.cat([x, t], dim=1) # (N, 2) proj = inp @ self.B # (N, num_freqs) return torch.cat([torch.sin(proj), torch.cos(proj)], dim=1)逻辑说明:B是固定的随机频率矩阵,把二维输入投影到高维频率空间,再送进 MLP。这样网络能更容易拟合高频成分,对多模态解特别有效。
参数说明:num_freqs控制频率数量,16~64 常用;sigma控制频率尺度,解变化越快sigma越大。注意B用register_buffer注册,不参与梯度更新。
验证可信度上,别只看损失。损失小不代表解对,因为损失是配点上的残差,配点之间可能有问题。三个验证手段:一是和解析解对比(有解析解时必做),看相对 L2 误差;二是换一组更密的验证网格,检查误差分布是否均匀,如果某个区域误差特别大,说明那里采样不足;三是做收敛性测试,固定网络,逐步增加配点数,看误差是否随之下降,如果误差不降反升,说明训练没收敛或权重失衡。
| 验证手段 | 适用场景 | 合格标准 |
|---|---|---|
| 解析解对比 | 有解析解 | 相对 L2 误差 < 1e-3 |
| 加密网格检查 | 无解析解 | 误差分布均匀,无局部尖峰 |
| 配点收敛测试 | 所有场景 | 误差随配点增加单调下降 |
| 残差可视化 | 所有场景 | 残差在域内均匀小,无集中区域 |
最后说个我自己的习惯:每次换方程,先不急着调网络,而是把方程无量纲化、把系数压到 1 附近,再跑一个 2000 配点、4 层 32 宽的小网络看损失能不能降。如果小网络都降不下去,说明问题出在方程形式或权重,不是网络容量。这个“小网络探路”的习惯帮我省了大量瞎调超参的时间。PINN 不是万能锤,它适合网格难剖、参数未知、要反复换条件的场景,规则低维问题该用有限差分就用有限差分。把这套流程走顺,你手里就多了一个能处理传统方法棘手问题的工具。希望帮到你。
本文还有配套的精品资源,点击获取