简介:这份资源面向具备一定Python与深度学习基础、希望入门或实践物理信息神经网络(PINN)的开发者与研究者,聚焦于用神经网络求解微分方程这一交叉方向。内容围绕将控制方程、边界条件与初始条件嵌入损失函数的核心思路展开,覆盖前馈网络构建、自动微分求偏导、配置点采样与梯度下降训练等关键环节,并涉及高维问题、逆问题与数据同化等适用场景。资源包共27个文件,约1.02MB,以17个ipynb交互式笔记本为主体,辅以3个py源码模块、若干zbak备份文件及png示意图与md说明,笔记本按方程类型组织,涵盖欧拉梁、扩散方程、泊松方程多种边界条件、拉普拉斯方程、洛伦兹系统及ODE系统等实例,便于对照运行与修改。已有98人学习。读者可借此获得一套可直接运行的PINN求解范例,理解损失函数构造与网络调参方法,并借助模块化脚本快速迁移到自身方程求解任务中。
1. 从“算不动”到“算得准”:PINN 到底解决了什么麻烦
做数值求解的人大多有过这种体验:方程写出来了,网格也划好了,可一旦问题变成高维、不规则边界,或者参数需要反复反演,传统差分和有限元的时间成本就直线上升。基于 Python 的 PINN 物理信息神经网络,思路是把微分方程残差直接塞进损失函数,让神经网络在训练中同时拟合观测数据和物理规律,从而绕开网格划分这一步。它特别适合三类人:手里有方程但网格难划的工程仿真人员、想做参数反演却苦于反复求解的研究者,以及已经会 PyTorch 但没接触过物理约束的算法工程师。这一章先把“它凭什么能解方程”讲清楚,后面再落到能跑起来的代码。
PINN 的核心不是让网络去“背”答案,而是让它在自动微分下满足方程本身。传统神经网络需要大量标注数据,而 PINN 的监督信号来自方程残差、初边值条件,数据点可以很少甚至没有。这意味着你可以在正问题里求场分布,也可以在反问题里把未知参数设成可训练变量一起优化。代价是训练比普通网络更挑剔,损失权重、采样点分布、激活函数都会影响收敛,这也是后面几章要重点拆的部分。
2. 把方程变成损失:PINN 的四个组成件与最小实现
2.1 网络、自动微分、残差、边界条件各管什么
一个能跑通的 PINN,结构上就四块。第一块是近似解网络,输入坐标(一维是 x,二维是 x、t),输出待求场量 u。第二块是自动微分,用 PyTorch 的 autograd 对网络输出关于输入求导,得到 u_t、u_x、u_xx 这些项。第三块是残差计算,把求出来的导数代回原方程,得到应该恒等于零的表达式。第四块是损失组装,把残差项、初值项、边界项按权重加起来。
选型上有几个默认判断。网络深度一般 4 到 8 层,每层 32 到 128 个神经元,太浅拟合能力不够,太深梯度容易在残差回传时衰减。激活函数优先 tanh,因为它的二阶导数光滑且不为零,sin 也可以,ReLU 的二阶导几乎处处为零,求 u_xx 时会直接翻车。优化器先用 Adam 跑几千步把损失压下来,再切 L-BFGS 精调,这是目前比较稳的组合。
2.2 用 PyTorch 写一个一维热方程求解器
下面这段代码求解一维热方程 u_t = α·u_xx,定义域 x∈[0,1]、t∈[0,1],初值 u(x,0)=sin(πx),边界 u(0,t)=u(1,t)=0。这个例子足够小,能在单卡上几分钟跑完,适合验证流程是否通。
import torch import torch.nn as nn import numpy as np # 近似解网络:输入 (x, t),输出 u class PINN(nn.Module): def __init__(self, layers=[2, 64, 64, 64, 1]): super().__init__() modules = [] for i in range(len(layers) - 1): modules.append(nn.Linear(layers[i], layers[i + 1])) if i < len(layers) - 2: modules.append(nn.Tanh()) # tanh 二阶导光滑,适合求 u_xx self.net = nn.Sequential(*modules) def forward(self, x, t): inp = torch.cat([x, t], dim=1) return self.net(inp) alpha = 0.01 # 热扩散系数,可调 model = PINN() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) # 采样:内部残差点、初值点、边界点 def sample(n_int=2000, n_ic=200, n_bc=200): x_int = torch.rand(n_int, 1) t_int = torch.rand(n_int, 1) x_ic = torch.rand(n_ic, 1) t_ic = torch.zeros(n_ic, 1) u_ic = torch.sin(np.pi * x_ic) t_bc = torch.rand(n_bc, 1) x_bc0 = torch.zeros(n_bc, 1) x_bc1 = torch.ones(n_bc, 1) return x_int, t_int, x_ic, t_ic, u_ic, x_bc0, x_bc1, t_bc def compute_loss(model, data): x_int, t_int, x_ic, t_ic, u_ic, x_bc0, x_bc1, t_bc = data x_int.requires_grad_(True) t_int.requires_grad_(True) u = model(x_int, t_int) # 自动微分求 u_t 和 u_xx u_t = torch.autograd.grad(u, t_int, grad_outputs=torch.ones_like(u), create_graph=True)[0] u_x = torch.autograd.grad(u, x_int, grad_outputs=torch.ones_like(u), create_graph=True)[0] u_xx = torch.autograd.grad(u_x, x_int, grad_outputs=torch.ones_like(u_x), create_graph=True)[0] # 物理残差:u_t - alpha * u_xx 应恒为 0 res = u_t - alpha * u_xx loss_pde = torch.mean(res ** 2) # 初值损失 u_ic_pred = model(x_ic, t_ic) loss_ic = torch.mean((u_ic_pred - u_ic) ** 2) # 边界损失:两端 u 都为 0 loss_bc = torch.mean(model(x_bc0, t_bc) ** 2) + \ torch.mean(model(x_bc1, t_bc) ** 2) return loss_pde + loss_ic + loss_bc data = sample() for step in range(5000): optimizer.zero_grad() loss = compute_loss(model, data) loss.backward() optimizer.step() if step % 500 == 0: print(f"step {step}, loss {loss.item():.6f}")逻辑上分三步走。第一步,内部点同时开启requires_grad_,这样 autograd 才能对输入求导,注意create_graph=True必须开,否则二阶导会断。第二步,u_t和u_xx分别对 t 和 x 求导,残差写成u_t - alpha * u_xx,平方后取均值。第三步,初值和边界各自算 MSE,直接相加。参数方面,alpha是物理系数,改它就能换材料;n_int控制残差采样密度,一般比初边值点多一个量级;学习率 1e-3 是 Adam 的常用起点,损失震荡就降到 5e-4。
2.3 训练循环里必须盯住的三个量
跑起来之后不要只看总 loss,要拆开看。残差 loss 长期不降,多半是网络容量不够或采样点太稀;初值 loss 降不下去,检查初值点是不是被边界点覆盖冲突;边界 loss 卡住,往往是边界采样太少或者权重给小了。我一般每 500 步打印一次三项分量,哪项占比超过 90% 就说明权重失衡,需要手动调。另外,Adam 阶段结束后接 L-BFGS 时,要把create_graph保持为 True,否则精调阶段梯度会出错。
3. 损失权重与采样策略:决定收敛还是发散的两个旋钮
3.1 为什么你的 PINN 总在残差和边界之间反复横跳
PINN 训练不稳,八成是损失权重没配好。残差项和边界项量纲不同,残差是导数平方,边界是函数值平方,直接相加会让网络偏向数值大的那一项。常见做法是给边界和初值加一个放大系数,让它们在早期占主导,先把解的形状框住,再让残差去修内部。经验值是初值、边界权重取 10 到 100,残差权重保持 1,具体看方程阶数。
另一种更稳的做法是自适应权重。每隔若干步统计各项 loss 的量级,按比例反向缩放权重,让每项对梯度的贡献接近。这不是玄学,本质是平衡不同约束的梯度范数。下面是一个简化版实现。
# 自适应权重:按各项 loss 量级动态调整 w_pde, w_ic, w_bc = 1.0, 50.0, 50.0 for step in range(5000): optimizer.zero_grad() loss_pde, loss_ic, loss_bc = compute_loss_split(model, data) # 每 1000 步重新平衡一次 if step % 1000 == 0 and step > 0: total = loss_pde + loss_ic + loss_bc w_pde = total / (loss_pde + 1e-8) w_ic = total / (loss_ic + 1e-8) w_bc = total / (loss_bc + 1e-8) loss = w_pde * loss_pde + w_ic * loss_ic + w_bc * loss_bc loss.backward() optimizer.step()这里compute_loss_split是把三项分开返回的版本,逻辑和前面一致。加1e-8是防止某项已经降到零导致除零。注意权重更新不要太频繁,否则训练目标一直在变,网络会来回震荡,1000 步一次是比较保守的节奏。
3.2 采样点怎么撒:均匀、随机还是残差自适应
采样策略直接影响残差覆盖。均匀网格采样实现简单,但在解变化剧烈的地方点不够,容易欠拟合。随机采样每次迭代重新撒点,相当于一种正则,泛化更好,但单步方差大。残差自适应采样是进阶做法:先跑一段,找出残差大的区域,下一轮在这些区域加密。实现上可以按残差大小做加权重采样。
| 采样方式 | 实现难度 | 适合场景 | 注意点 |
|---|---|---|---|
| 均匀网格 | 低 | 解平滑、低维 | 剧变区需手动加密 |
| 随机采样 | 低 | 高维、不规则域 | 每步重撒,方差偏大 |
| 残差自适应 | 中 | 激波、边界层 | 需额外统计开销 |
我一般先用随机采样跑通,确认损失能降,再换自适应看精度有没有提升。如果随机采样都收敛不了,换采样策略也没用,问题在权重或网络结构。
3.3 从 Adam 切到 L-BFGS 的时机与写法
Adam 擅长快速下降但精度有限,L-BFGS 用二阶信息能压到更低损失,但它对初始点敏感,直接从头用容易卡。常见流程是 Adam 跑 3000 到 5000 步,损失降到 1e-3 量级,再切 L-BFGS 跑几百次迭代。切换时把优化器换掉,学习率不再需要,但create_graph=True必须保留。
# Adam 阶段结束后切 L-BFGS optimizer_lbfgs = torch.optim.LBFGS(model.parameters(), max_iter=500, tolerance_grad=1e-7, tolerance_change=1e-9) def closure(): optimizer_lbfgs.zero_grad() loss = compute_loss(model, data) loss.backward() return loss optimizer_lbfgs.step(closure)max_iter控制内层迭代次数,太大容易过拟合到残差而破坏边界,一般 200 到 500。tolerance_grad和tolerance_change是收敛判据,设太小会白跑,设太大提前停。切换后如果边界误差反而变大,说明 L-BFGS 把残差压过头了,回去把边界权重再调高。
4. 避坑与排查:PINN 训练里最常见的五类翻车
4.1 损失不降,先查这四处
现象是训练几千步总 loss 几乎不动。原因通常有四个:一是激活函数用了 ReLU,二阶导为零导致残差恒为零,网络学不到东西;二是内部点没开requires_grad_,autograd 求不出导数,残差项实际是常数;三是学习率太大,梯度直接爆炸;四是方程本身量纲悬殊,比如系数是 1e-6,残差项被淹没。解决方法是换 tanh、检查梯度开关、把学习率降到 1e-4、对系数做无量纲化。
4.2 边界条件被“忽略”的典型表现
现象是内部拟合得不错,但边界附近误差明显。原因是边界采样点太少,或者边界权重相对残差太小。解决方法是把边界点数提到内部点的十分之一以上,权重先设 50 再根据分量 loss 调整。还有一种隐蔽情况:边界点和初值点在角点重叠,两个约束打架,这时要检查角点是否被重复采样并给了矛盾目标。
4.3 二阶导求出来是 None 或报错
现象是torch.autograd.grad返回 None,或者提示元素不需要梯度。原因是第一次求导时没设create_graph=True,计算图在求一阶导后就被释放了,二阶导自然拿不到。解决方法是两次 grad 调用都带上create_graph=True,并且确保输入张量在求导前调用了requires_grad_。这个坑很常见,报错信息又不直观,值得单独记一笔。
4.4 训练后期 loss 突然变 NaN
现象是前几千步正常,突然 loss 变成 NaN。原因多半是学习率在后期偏大,或者 L-BFGS 的步长把参数推到了极端值,导致 tanh 饱和后梯度异常。解决方法是加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0),或者在切 L-BFGS 前先把学习率降一个量级。如果已经 NaN,回退到上一个检查点重跑,别硬撑。
4.5 换方程后精度骤降的排查顺序
现象是同一个网络结构,换个方程就解不准。排查顺序建议固定下来:先确认新方程的量纲和系数范围,做无量纲化;再检查残差表达式有没有写错符号或漏项;然后看采样域是否覆盖了所有边界;最后才动网络结构和权重。多数时候问题出在残差公式,而不是网络本身。我习惯把残差表达式单独打印几个点的数值,和解析解或已知特解对一下,能快速定位。
5. 从正问题到反问题:把未知参数交给网络一起学
正问题求场分布,反问题求方程里的未知系数,这是 PINN 相比传统方法最省事的地方。做法是把未知参数设成nn.Parameter,和网络权重一起进优化器,损失里除了残差和边界,再加一项观测数据拟合。下面以热方程里未知的alpha为例。
# 把 alpha 设为可训练参数,从观测数据反演 log_alpha = nn.Parameter(torch.tensor(0.0)) # 用 log 保证正数 alpha_learned = torch.exp(log_alpha) # 观测点:假设我们在若干位置测到了 u x_obs = torch.rand(50, 1) t_obs = torch.rand(50, 1) u_obs = true_solution(x_obs, t_obs) # 真实观测值 def compute_loss_inverse(model, data): loss_pde, loss_ic, loss_bc = compute_loss_split(model, data, alpha_learned) u_pred = model(x_obs, t_obs) loss_obs = torch.mean((u_pred - u_obs) ** 2) return loss_pde + loss_ic + loss_bc + 100.0 * loss_obs # 优化器要同时包含网络参数和 log_alpha optimizer = torch.optim.Adam( list(model.parameters()) + [log_alpha], lr=1e-3)关键点有三个。第一,用log_alpha再取 exp,保证反演出的系数始终为正,避免物理上无意义的负扩散。第二,观测损失权重要给大,通常 100 量级,否则网络会优先满足方程而忽略数据,反演结果漂移。第三,观测点数量不用多,几十个就够,但位置要覆盖解变化明显的区域,全挤在一角会导致参数不可辨识。
验证反演是否可信,不能只看 loss。我一般做两件事:一是把反演出的参数代回方程,用独立的一组观测点算预测误差;二是固定其他条件,换几组初值重跑,看反演结果是否稳定。如果每次跑出来差别很大,说明观测信息不足或权重失衡,需要加观测点或调权重。反问题比正问题更依赖数据质量,这一点在投入前要有预期。
最后说个习惯。我跑 PINN 一定先在一个有解析解的小方程上验证流程,确认网络、求导、损失组装都没问题,再换到真正要解的方程。这样出问题时能快速判断是代码 bug 还是方程本身难收敛。另外,训练日志一定把三项分量分开记,别只存总 loss,否则事后排查只能靠猜。这套流程不复杂,但能省下大量返工时间。希望帮到你。
本文还有配套的精品资源,点击获取