简介:一套覆盖物理信息神经网络(PINN)离散时间识别、离散时间推理、连续时间识别、连续时间推理方法的Python源码包,内含4个独立代码,每个方法均配有相应数据与结果文件,适合研究科学计算、偏微分方程反演及时间序列建模的开发者与科研人员。包内共559个文件,压缩包约476MB,包含13个py源码脚本、503个txt日志与说明文本、12个csv误差对比表、7个mat实验数据文件,以及pdf和eps格式的结果图、yml配置文件等,文件类型覆盖源码、数据、图表与配置,组织较完整,便于分模块对照查阅。目前已有1546人学习或下载,关注度较高。借由源码、数据与误差表,可快速复现四类方法在离散与连续时间场景下的识别和推理全过程,并利用结果图观察误差演化与参数影响,为后续调整网络结构、损失函数及训练策略提供了直观的定量依据和实验基础。
1. PINN不是网络架构,而是损失函数设计思路
PINN(物理信息神经网络)这些年出镜率很高,但第一次接触的人容易把它想成一种新的深度学习骨架。实际上它没有特殊层、没有特殊激活函数,核心是损失函数设计:把偏微分方程的残差项直接塞进训练目标里,让网络不仅拟合观测数据,还满足物理规律。这套资源一共四个 python 代码,分别对应离散时间识别、离散时间推理、连续时间识别、连续时间推理,覆盖了 PINN 最常见的前向求解与参数反演场景。如果你手里有稀疏的时空观测数据,想知道背后 PDE 的未知参数,或者想在没有完整观测的情况下重建整个场,那这套代码就是最直接的参照物。适合已经在跑深度学习、但对物理约束如何落进训练循环还缺一次完整实操的人。
2. 离散时间识别:把 Runge-Kutta 写进网络,反演方程参数
2.1 为什么离散时间方向要单独做一版代码
连续时间 PINN 的做法是直接把空间坐标和时间坐标拼起来作为网络输入,用自动微分求 u_t、u_xx 这些偏导。思路直观,但有个实际问题:当数据只有几帧离散快照、时间间隔又比较大时,让网络自己学习跨大步长的时间演化很容易发散。离散时间方法换个思路,把 Runge-Kutta 这类时间积分格式直接嵌进网络结构,拿相邻几帧快照做约束,网络要学的实质上是「从 t_n 到 t_{n+1} 的状态转移」。
识别(identification)和推理(inference)在这里是两种诉求。推理是正问题:已知方程结构和参数,求给定初始边界条件下的解场;识别是反问题:方程结构已知、参数未知,要用观测数据把参数反推出来。离散时间识别之所以是四个方向里最常被复现的,是因为它把时间步进和参数反演耦合在一起,RK 积分器的系数同时参与网络输出和残差计算,参数能不能反准,直接反映在误差表里那几列 lambda 值上。
2.2 RK 离散的最小实现与参数设定
以 Burgers 方程 u_t + u·u_x − ν·u_xx = 0 为例,ν 是待识别参数。常见做法是构造一个以 (x, t) 为输入的网络,输出 u;再把 ν 设成 nn.Parameter,让优化器同时更新网络权重和 ν。四阶 Runge-Kutta 的格式要先备好,它把一步时间推进拆成四个 stage:
| stage | c | a 系数组合 |
|---|---|---|
| k1 | 0 | 0 |
| k2 | 1/2 | a21 = 1/2 |
| k3 | 1/2 | a31 = 0, a32 = 1/2 |
| k4 | 1 | a41 = 0, a42 = 0, a43 = 1 |
每个 stage 对应一个中间时刻,离散时间 PINN 会在这个时刻上计算 PDE 残差,让网络输出满足 RK 积分关系。下面是最小可运行的网络和参数声明:
import torch import torch.nn as nn class BurgersPINN(nn.Module): def __init__(self, n_hidden=4, n_width=50): super().__init__() layers = [nn.Linear(2, n_width)] for _ in range(n_hidden - 1): layers.append(nn.Linear(n_width, n_width)) layers.append(nn.Linear(n_width, 1)) self.net = nn.Sequential(*layers) # nu 作为可训练参数,这里就是“识别”的关键 self.nu = nn.Parameter(torch.tensor(0.01), requires_grad=True) def forward(self, x, t): xt = torch.cat([x, t], dim=1) return self.net(xt) model = BurgersPINN() opt = torch.optim.Adam([ {"params": model.net.parameters(), "lr": 1e-3}, {"params": [model.nu], "lr": 1e-3}, ])逻辑上要注意两件事。第一,ν 不能只被当作常数写死在残差公式里,必须放进参数组参与反向传播,否则梯度根本流不到它身上,优化器只会空转。第二,学习率拆成两个组是常见的稳健做法,因为网络权重和未知参数的量纲、梯度量级可能差很多,绑在一起用一个 lr 经常导致 ν 震荡。参数设定上,隐层 4 层、每层 50 个神经元是 PINN 论文里的常见配置,激活函数默认 tanh,因为它对自动微分二阶导更友好,ReLU 在求二阶导时会直接退化成 0。
训练循环里,离散时间识别要比连续时间多一步:把 t_n 和 t_{n+1} 两帧数据同时喂给网络,中间用 RK stage 生成若干辅助时间点,在这些点上计算残差并累加进 loss。代码骨架如下:
def rk4_stages(x, t_n, dt, model): # 四阶 RK 的四个时间偏移 c = [0.0, 0.5, 0.5, 1.0] stages = [] for ci in c: t_mid = t_n + ci * dt u_mid = model(x, t_mid) # 这里用当前 nu 计算 PDE 残差,后续累加到 loss stages.append((t_mid, u_mid)) return stages这里的核心是把 RK 系数当成配点生成器:每个 stage 时刻都参与残差计算,但最终数据损失只约束 t_n 和 t_{n+1} 两帧。你会发现网络没有显式存储时间演化,它是靠「所有中间时刻都满足同一个 PDE」这个约束把时间连续性学出来的。dt 的取值很敏感,我一般取观测时间间隔的一半作为初始值,然后看误差表里的残差列是否持续下降,再逐步加大。
2.3 从 error_lambda 表判断参数反演是否收敛
资源里带着几个 CSV 文件,error_table_1.csv、error_table_2.csv 和 error_lambda_1_table_1.csv、error_lambda_2_table_2.csv,这些就是训练过程里导出的误差快照。命名规律很清晰:带 lambda 的专门记录未知参数 ν 的估计误差,不带 lambda 的记录整体解的误差。实际读的时候至少关注三列:迭代轮数、PDE 残差 loss、lambda 相对误差。
判断收敛不要单看 loss 数值。PINN 训练里 loss 降到很低但参数反错了的情况非常常见,因为你可能把数据损失权重调得太低,网络学会了「假装满足物理」却牺牲了观测匹配。我一般会同时盯 lambda 相对误差那条曲线:它应该先快速下降,然后进入小幅波动。如果 lambda 曲线一直在单一方向漂移,说明学习率太大或者数据快照太少,属于典型的欠约束。
3. 连续时间推理:自动微分替代 PDE 残差计算
3.1 网络输入 (x, t) 到高阶导数的完整链路
连续时间方向的代码是四个里最容易理解的版本,也是首次接触 PINN 的人最该先跑通的。网络输入是 (x, t),输出是 u,PDE 里的时间导数和空间导数全部交给 torch.autograd.grad 去算。这个过程看着简单,坑全在细节里:第一次求导是 u 对 t 和 u 对 x,第二次求导是 u_x 再对 x 求一次,两次都必须要 create_graph=True,否则计算图被释放,后续的损失回传会直接报错。
def pde_residual(model, x, t, nu): x = x.clone().requires_grad_(True) t = t.clone().requires_grad_(True) u = model(x, t) u_t = torch.autograd.grad(u, t, grad_outputs=torch.ones_like(u), create_graph=True)[0] 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), create_graph=True)[0] # Burgers 方程残差,理想情况下应为 0 return u_t + u * u_x - nu * u_xx这段代码里最容易被忽略的是requires_grad_(True)必须作用在输入张量上,而不是等 model 输出后再临时设置。如果输入 x、t 本身没有开启梯度,自动微分返回的梯度就是 None,训练循环会在 loss.backward() 时报一个莫名其妙的 TypeError,这种报错最容易让人误判成网络结构问题。nu 在推理场景是固定常数,不需要梯度;但代码里通常保留成参数,方便你在同一套骨架上切换识别模式。
3.2 数据点、边界点、内部配点的配置顺序
连续时间 PINN 的 loss 由三个部分叠加:观测数据损失、边界条件损失、PDE 残差损失。数据点来自稀疏传感器或仿真快照,边界点在计算域边缘采样,内部配点是随机撒在 (x, t) 域里的点,专门用来计算 PDE 残差。这三个来源的采样数量配比直接决定训练成败。
常见做法是数据点保持原始观测量,边界点采样 100~200 个,内部配点采样 10000 个左右。配点数量不是越多越好,太多会导致单轮迭代变慢;太少则约束不足,解会偏向纯数据拟合。我一般用拉丁超立方采样替代纯随机均匀采样,这样能在同样点数下让配点覆盖更均匀,尤其当计算域时间跨度大、数据集中在某一小段时,纯随机采样很容易让长时间段出现约束空洞。
def sample_interior(n_points, x_range, t_range): x = torch.rand(n_points, 1) * (x_range[1] - x_range[0]) + x_range[0] t = torch.rand(n_points, 1) * (t_range[1] - t_range[0]) + t_range[0] return x, t边界点和内部配点分开采样后,loss 计算也要分开写。最常见的问题是把所有点混在一起算一个均方误差,结果边界约束被海量内部配点稀释,边界条件永远学不精确。我的习惯是每个 loss 分量单独乘一个权重系数,初始值分别设为数据 1.0、边界 1.0、残差 0.01,再根据训练初期的 loss 比例做调整。
3.3 识别与推理在损失函数上的三个差异
如果你把四个代码都跑一遍,会发现识别和推理的损失函数长得很像,但有三处关键差异。
第一,PDE 残差里的 ν 是否可训练。推理时 ν 是常量,残差计算只给网络提供物理约束;识别时 ν 是一个 nn.Parameter,优化器会更新它。第二,loss 记录方式不同。推理模式只需要记录解误差和残差,识别模式额外记录 ν 的估计值和真实值的相对误差,也就是 error_lambda 系列文件存在的原因。第三,收敛判据不同。推理模式看解场误差,识别模式必须同时看解误差和参数误差,而且参数误差往往滞后于解误差收敛,需要多训几轮才能稳定。
我见过不少人在识别模式里一直盯着 loss 曲线,看到它降到 1e-5 就认为成功,结果一查 error_lambda 表,ν 的估计值偏了 30%。这就是没理解第三点差异:解场拟合得好不代表参数反演得准,这是反问题的固有特性,必须靠专门的误差表来盯。
4. 四套代码的训练骨架:优化器、初始化和误差记录
4.1 网络宽度、层数与激活函数的常见选型
四个方向代码共享同一套网络骨架,差别只在损失函数和时间离散方式。默认参数是 4 层隐层、每层 50 个神经元、tanh 激活,这套配置我在多个方程上都验证过,收敛速度和稳定性比较平衡。想优先提速可以压到 3 层 30 宽,想处理更复杂的耦合方程可以加到 6 层 100 宽,但要注意宽度增加后 tanh 的梯度饱和问题会更明显,训练初期 loss 可能长时间不动。
def init_net(n_hidden=4, n_width=50): layers = [nn.Linear(2, n_width)] for _ in range(n_hidden - 1): layers.append(nn.Linear(n_width, n_width)) layers.append(nn.Linear(n_width, 1)) net = nn.Sequential(*layers) for m in net: if isinstance(m, nn.Linear): nn.init.xavier_normal_(m.weight) nn.init.zeros_(m.bias) return net激活函数方面,tanh 是 PINN 的默认选择,因为它各阶导数都连续且不会像 ReLU 那样让二阶导恒等于 0。GELU 有时在高频问题上表现更好,但稳定性差一些,我一般只在 tanh 收敛困难时才换。初始化用 Xavier 系列、偏置归零,这是避免前几轮 loss 变成 NaN 的最有效手段。
4.2 Adam + L-BFGS 双阶段:为什么先粗后精
PINN 的训练有个明显规律:用纯 Adam 训,loss 降到一定程度就进入平台期,梯度方向在小幅振荡里来回摇摆;这时候换成 L-BFGS,通常还能再降一两个数量级。原因在于 L-BFGS 利用了二阶曲率信息,在损失函数光滑的区域能精准地收敛到局部极小点。常见做法是 Adam 先跑 10000~20000 轮,把解从随机初始化拉到一个合理区域,再切 L-BFGS 跑到收敛。
# 阶段一:Adam 粗训 opt = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(20000): loss = compute_loss(...) opt.zero_grad() loss.backward() opt.step() # 阶段二:L-BFGS 精修 opt_lbfgs = torch.optim.LBFGS(model.parameters(), lr=1.0, max_iter=2000) def closure(): opt_lbfgs.zero_grad() loss = compute_loss(...) loss.backward() return loss opt_lbfgs.step(closure)切换优化器有一个必须注意的点:L-BFGS 对学习率极其敏感,默认 lr=1.0 在 PINN 场景下经常直接让 loss 炸掉。我一般从 0.1 起步,如果前几次迭代 loss 不降反升就继续调低。另一个坑是 closure 函数不能遗漏zero_grad(),否则梯度会跨迭代累积,L-BFGS 的线搜索条件被破坏,表现为 loss 忽高忽低。
4.3 批量读取误差表,快速对比四个方向的收敛轨迹
资源里的误差表文件分布在四个代码目录下,命名区分了不同的 lambda 权重设置。读表时直接用 pandas,一次性把所有表都读进来对比:
import pandas as pd import glob for f in glob.glob("**/error*.csv", recursive=True): df = pd.read_csv(f) print(f, df.shape, df.columns.tolist()) print(df.head(3))读表不是看下就行,要做两件事。第一,确认列名里有没有空值,CSV 导出时如果遇到 NaN,说明那一步训练可能崩溃过。第二,对比同一轮数下不同表的残差值,筛选出最稳定的参数组合。四套代码对应四个不同的方法分支,误差表其实是你判断哪个分支值得继续调参的第一手依据。文件名的 lambda_1、lambda_2 分别代表不同的正则权重或损失权重配置,对照实验时不要混用。
5. 避坑:PINN 训练里的五个翻车现场
现象一:loss 降到 1e-5,画出来的解却是一条直线。
原因:PDE 残差损失权重太大,数据损失占比太小,网络找到的「最优解」是让残差为零的平凡常数解,这个解确实满足 PDE,但完全不符合观测数据。
解决:把损失函数各个分量打印出来看比例,确保数据项和边界项加起来不低于残差项;同时把残差权重从 0.01 往下调,或者用带权重的 MAE 替代 MSE 观测损失。
现象二:识别模式里 error_lambda 表的数值前期下降很好,某一步突然跳成 NaN。
原因:ν 在更新过程中变成负值,而残差公式里 ν 参与了除法或开方运算,数值失去意义。
解决:对 ν 做 softplus 约束,保证它始终为正;或者在 optimizer.step() 之后手动 clamp 一下,例如model.nu.data.clamp_(min=1e-6)。这是参数反演最典型的数值事故,不是网络结构问题。
现象三:连续时间推理模式里,u_t 的梯度为 None,loss.backward() 直接报错。
原因:输入张量 x、t 没有在传给网络前设置 requires_grad_(True);或者 forward 里做了原地操作破坏了计算图。
解决:在残差函数开头用x.clone().requires_grad_(True)重建叶子张量,并确认所有自动微分调用都传了create_graph=True。
现象四:训练集误差很低,测试集误差却高一个数量级。
原因:内部配点采样范围没有覆盖完整计算域,尤其是时间轴只采了前半段;网络在没配点的区域自由发挥,自然泛化失败。
解决:用拉丁超立方重新采样,并在训练过程中每 5000 轮重新撒一次配点,避免网络过拟合固定配点位置。
现象五:L-BFGS 切换后 loss 不降反升,连续几步出现 inf。
原因:L-BFGS 的 lr 设置太激进,或者 closure 里漏了zero_grad(),导致线搜索失败。
解决:降低 lr 到 0.1 以下,closure 函数严格按照前面代码三分支来写,不要省略清零步骤;如果仍然不稳定,退回 Adam 训练几千轮再切换,确保进入 L-BFGS 时解已经比较光滑。
6. 验证技巧:先用合成解自测,再读误差表下结论
拿到这套代码别急着往自己的数据上套。我会先做一步合成解验证:自己设定一个解析解,比如 u = sin(x − t),代入 Burgers 方程算出对应的 ν,然后用这个 ν 生成合成观测数据,再把数据喂给识别代码。如果反演出的 ν 能回到设定值,误差表里的相对误差最终小于 1%,说明代码链路完整、参数设置合理,这时候才有信心换到真实数据。
def synthetic_check(model, x, t, true_nu): # 用解析解生成数据,并计算识别误差 with torch.no_grad(): # 假设解析解 u = sin(x - t) u_true = torch.sin(x - t) # 训练结束后读取模型中的 nu pred_nu = model.nu.item() rel_err = abs(pred_nu - true_nu) / true_nu return rel_err验证通过后,再回到误差表看整体趋势。注意 error_lambda 系列表的收敛速度通常慢于 error_table 系列,因为参数反演需要网络先拟合出合理的解场,才能从解场里反推参数。我习惯把两个表画在同一张图里,横轴是迭代数,纵轴取对数,观察两条曲线是否同步下降。如果解误差已经平稳但参数误差还在漂,说明需要增加数据快照或加大残差权重。
这套代码给我最大的教训是:PINN 的调参没有银弹,四个方向各有脾气。离散时间方向对 dt 敏感,连续时间方向对配点分布敏感,识别方向对参数初始化敏感。从那以后我每次跑新数据都强制走一遍合成解自测、误差表对比、再换数据的流程,能省下大量反复试错的时间。希望帮到你。
本文还有配套的精品资源,点击获取