news 2026/10/11 19:55:41

PINN物理信息神经网络求解微分方程:从损失函数设计到PyTorch实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PINN物理信息神经网络求解微分方程:从损失函数设计到PyTorch实战

简介:围绕物理信息神经网络(PINN),提供了一套基于Python实现微分方程求解的实践资料,面向科研人员与深度学习、数值计算交叉方向的学习者。其核心思路是把控制方程、边界条件及初始条件嵌入神经网络损失函数,借助自动微分技术计算偏导数,从而将求解问题转化为参数优化问题。 压缩包共含27个文件,以17个Jupyter Notebook为主,覆盖常微分方程、偏微分方程、泊松方程、拉普拉斯方程、扩散方程、Lorenz系统、欧拉梁等经典案例;另有model.py、PDE.py、geometry.py等Python模块说明建模过程,并配有示意图与README,合计约1.02MB,便于快速下载。 每个notebook都展示网络结构设计、损失函数构造、训练调参与结果可视化,读者可对照代码逐步复现。已有97人学习下载,适合想快速上手PINN或寻找可运行算例模板的研究者,能有效降低环境搭建与调试成本。

1. PINN 物理信息神经网络解微分方程,先想明白它补的是哪块短板

遇到复杂边界、反问题或者区域形状不规则的微分方程时,传统有限差分和有限元要先生成网格,网格质量直接决定解的质量;反过来,如果方程参数是未知的,想靠观测数据反推参数,传统方法往往要把正问题求解器反复套在一个优化循环里,跑一轮要半天,改一次初值又要重来。PINN 物理信息神经网络的做法是把微分方程本身当作监督信号写进损失函数,让神经网络在拟合数据的同时满足方程残差、边界条件和初始条件。这一招对高维问题、逆问题和不规则区域非常友好,也不需要专门做网格剖分。

但别把 PINN 当成万金油。它在 1D、2D 的常规问题上很难拼过打磨了几十年的数值求解器,你要的是 9 位有效数字的精度时,PINN 大概率给不了。这篇文章我按自己复现一维热传导方程和 Burgers 方程的经验,把损失函数怎么设计、代码怎么写、参数怎么调、哪些地方最容易翻车一次讲清楚。适合两类人:一类是被网格和反问题折磨的仿真工程师,另一类是刚入门 Python、想找一个能练手又能出成果的深度学习方向。

2. 损失函数怎么把“物理”塞进神经网络:PINN 的核心公式与自动微分

2.1 一个通用损失模板:PDE 残差 + 边界条件 + 初始条件

把微分方程交给神经网络去解,思路不是让网络“背答案”,而是让网络猜一个函数,然后检查这个函数代入方程后误差有多大。假设要求解的方程是:

∂u/∂t = α ∂²u/∂x²

设神经网络输出为 u_θ(x, t),θ 是网络权重。把 u_θ 代入方程后,左右两边的差就叫 PDE 残差:

res = ∂u_θ/∂t - α ∂²u_θ/∂x²

如果 u_θ 刚好是真解,res 处处为 0。于是训练目标就变成:找一组 θ,让 res 的平方和尽量小。同时还得满足边界条件和初始条件,所以 PINN 的损失函数是一个三项加权和:

L = L_pde + w_bc * L_bc + w_ic * L_ic

其中 L_pde 是内部配点上的残差平方均值,L_bc 是边界点上的输出与给定边界值的偏差,L_ic 是初始时刻的输出与初值函数的偏差。w_bc 和 w_ic 是权重,默认可以都设为 1,实际调的时候边界和初始条件通常需要给更大权重,后面会专门讲。

这里最关键的一个认知转变是:整条网络的监督信号不是人工标注好的“正确答案”,而是方程本身。所以 PINN 属于无监督或者半监督的思路,特别适合给那些只有边界条件和部分观测数据的反问题用。你不需要生成大量标签数据,只需要提供方程的数学表达式、定义域、初边值条件,再加上一批采样点坐标。

2.2 二阶自动微分:PINN 的求导地基

要让方程残差能被反向传播,就得对网络输出求输入坐标的偏导数,而且很多时候是二阶偏导。PyTorch 的 autograd 可以做到这一点,但写法上有几个容易被忽略的细节。先看一个最小示例:

import torch x = torch.tensor([0.2, 0.5], requires_grad=True) u = torch.sin(x * torch.pi) # 一阶导 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] print(u_xx)

autograd.grad 计算的是标量对输入的梯度,u 是向量时需要通过 grad_outputs 传入一个与 u 同形状的权重向量,通常就是全 1 向量。create_graph=True 表示把求导过程也保留在计算图里,这样后续才能继续对 u_x 求导得到二阶导。第二个 autograd.grad 的 grad_outputs 用的是 ones_like(u_x),不是 ones_like(u),这个细节写错会报形状不匹配的错误。

提示:对 x 和 t 两个变量分别求导时,要各调用一次 autograd.grad,并且两次都要 create_graph=True,否则后续损失反向传播时梯度链会断掉。

2.3 选 PyTorch 而不是从零手写梯度的三个理由

我见过有人为了不装深度学习框架,手写数值微分来实现 PINN,结果残差算出来误差大得没法看。数值差分在高阶导上精度不够,而且每一步都要重新算前向,慢且不稳定。选 PyTorch 的理由很直接:autograd 给出的是解析形式的链式求导结果,精度接近机器精度;反向传播和优化器都现成;调试时函数式交互比 TensorFlow 的静态图直观得多。

如果你不想从头搭网络,也可以直接用 DeepXDE 这类封装库,它会自动处理配点采样、边界条件施加和常见方程模板,几分钟能跑通一个标准算例。但从学习角度,我还是建议先裸写 PyTorch 版本,因为后面碰到损失不收敛、某一项梯度消失时,你得能看懂损失函数内部发生了什么。裸写一遍之后再用 DeepXDE,你会很清楚它帮你省了哪些步骤。

3. 用 PyTorch 求解一维热传导方程:复现流程与参数表

3.1 方程与真解怎么定

这一章我们用一维热传导方程作为跑通案例,因为它有解析解,方便验证网络学得对不对。方程是:

∂u/∂t = α ∂²u/∂x², x ∈ [0, 1], t ∈ [0, 1]

边界条件取两端固定为零:u(0, t) = 0,u(1, t) = 0。初始条件取 u(x, 0) = sin(πx)。取 α = 0.1,这时真解是:

u_true(x, t) = exp(-α π² t) * sin(π x)

可以看到,随着 t 增大,幅度按指数衰减。这个衰减速度对 PINN 不是特别难,但已经能暴露时间跨度带来的问题,留到第 4 章细说。求解这个方程不需要任何标签数据,只需要按方程计算残差和初边值损失。

环境准备按常见做法来就行,挂代理装包没必要,直接本地装:

pip install torch numpy matplotlib

装好后把随机种子固定,后面所有采样和初始化都基于同一个种子,方便复现。

3.2 采样:内部点、边界点、初始点怎么分配

PINN 的训练数据不是“样本对”,而是一堆坐标点。内部配点用来计算 PDE 残差,边界点和初始点分别用来计算边界损失和初始损失。数量分配我一般用 10:1:1 的比例起步:

import torch def sample_points(n_f=10000, n_b=200, n_ic=200, seed=42): torch.manual_seed(seed) # 内部配点:均匀随机采样于 (0,1)x(0,1) x_f = torch.rand(n_f, 1) t_f = torch.rand(n_f, 1) # 边界 x=0 和 x=1 t_b0 = torch.rand(n_b, 1) x_b0 = torch.zeros_like(t_b0) t_b1 = torch.rand(n_b, 1) x_b1 = torch.ones_like(t_b1) # 初始时刻 t=0 x_ic = torch.rand(n_ic, 1) t_ic = torch.zeros_like(x_ic) return (x_f, t_f), (x_b0, t_b0), (x_b1, t_b1), (x_ic, t_ic) (x_f, t_f), (x_b0, t_b0), (x_b1, t_b1), (x_ic, t_ic) = sample_points()

内部点数太少,残差场的空间分辨率不够,网络会“绕过”方程去拟合个别点。点数太多,单步训练变慢,但也不一定提升精度。10000 个内部点对这个一维案例足够。边界点和初始点各 200 个是因为它们是一维流形上的点,点数需求比二维内部区域小一个量级。

3.3 网络结构与初始化,为什么用 Xavier

网络本身就是一个普通的多层感知机,输入是 x 和 t 两个标量,输出是 u。隐藏层数量和宽度不用太夸张,4 层 64 个神经元在这个案例里够用。激活函数选 tanh,因为 PINN 要求输出和导数都光滑,ReLU 的导数不连续,在二阶导上会出问题。

import torch.nn as nn class PINN(nn.Module): def __init__(self, layers=[2, 64, 64, 64, 64, 1]): super().__init__() self.net = nn.Sequential() for i in range(len(layers) - 1): self.net.append(nn.Linear(layers[i], layers[i + 1])) if i < len(layers) - 2: self.net.append(nn.Tanh()) # Xavier 初始化:tanh 激活的标准搭配 for m in self.net.modules(): if isinstance(m, nn.Linear): nn.init.xavier_normal_(m.weight) nn.init.zeros_(m.bias) def forward(self, x, t): return self.net(torch.cat([x, t], dim=1))

Xavier 初始化在这里不是玄学而是刚需。tanh 在零附近近似线性,如果权重初始化太大,输入进激活函数后直接饱和,梯度趋近于零,网络早期根本学不动。PyTorch 默认的 init 方式对普通分类任务够用,但对 PINN 这种每个点都要计算高阶导的任务,初始化直接决定起步阶段的梯度质量。

3.4 训练循环:Adam 预热 + L-BFGS 精修

损失计算要把 PDE 残差、边界损失、初始损失分开算再求和,这样每一部分可以单独观察。核心代码:

def compute_loss(model, data, alpha=0.1, w_bc=5.0, w_ic=5.0): (x_f, t_f), (x_b0, t_b0), (x_b1, t_b1), (x_ic, t_ic) = data # PDE 残差 x_f.requires_grad_(True) t_f.requires_grad_(True) u_f = model(x_f, t_f) u_t = torch.autograd.grad(u_f, t_f, grad_outputs=torch.ones_like(u_f), create_graph=True)[0] u_x = torch.autograd.grad(u_f, x_f, grad_outputs=torch.ones_like(u_f), create_graph=True)[0] u_xx = torch.autograd.grad(u_x, x_f, grad_outputs=torch.ones_like(u_x), create_graph=True)[0] pde_res = u_t - alpha * u_xx loss_pde = torch.mean(pde_res ** 2) # 边界条件 loss_bc = torch.mean(model(x_b0, t_b0) ** 2) + torch.mean(model(x_b1, t_b1) ** 2) # 初始条件 ic_target = torch.sin(torch.pi * x_ic) loss_ic = torch.mean((model(x_ic, t_ic) - ic_target) ** 2) return loss_pde + w_bc * loss_bc + w_ic * loss_ic, (loss_pde, loss_bc, loss_ic)

训练阶段我习惯分两步:先用 Adam 大范围搜索,再切到 L-BFGS 做局部精修。Adam 适合前期的稳定收敛,但最后的收敛精度往往不如 L-BFGS。L-BFGS 在 PINN 场景里几乎是标配,它对光滑损失函数的二阶信息利用得更好。

model = PINN() data = sample_points() # 第一阶段:Adam 预热 optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(5000): optimizer.zero_grad() loss, _ = compute_loss(model, data) loss.backward() optimizer.step() if epoch % 1000 == 0: print(f"Adam epoch {epoch}: loss = {loss.item():.6e}") # 第二阶段:L-BFGS 精修 optimizer = torch.optim.LBFGS(model.parameters(), lr=0.8, max_iter=1000, history_size=50) def closure(): optimizer.zero_grad() loss, _ = compute_loss(model, data) loss.backward() return loss optimizer.step(closure)

L-BFGS 的使用方式和 Adam 有本质区别:step 接收一个闭包函数,每次内部迭代都会重新计算损失和梯度。闭包里必须调用 zero_grad,而且要返回 loss 对象,漏掉任何一步都会导致优化器行为异常。lr 参数不要照搬 Adam 的 1e-3,L-BFGS 默认 0.8 到 1.0 通常表现更好。

提示:如果你的显存有限,可以适当减少内部配点数到 5000,L-BFGS 阶段对显存占用比 Adam 高一截,因为它在每次迭代要保存历史梯度信息。

3.5 快速评估:相对 L2 误差和可视化

训练完不能只看 loss,必须把网络输出和解析解对比。用密集网格采样计算相对 L2 误差:

def relative_l2_error(model, alpha=0.1): x = torch.linspace(0, 1, 200).view(-1, 1) t = torch.linspace(0, 1, 200).view(-1, 1) X, T = torch.meshgrid(x[:, 0], t[:, 0], indexing='ij') X = X.reshape(-1, 1) T = T.reshape(-1, 1) u_pred = model(X, T).detach() u_true = torch.exp(-alpha * torch.pi**2 * T) * torch.sin(torch.pi * X) err = torch.norm(u_pred - u_true) / torch.norm(u_true) return err.item() print("相对L2误差:", relative_l2_error(model))

我的经验是,这个案例跑完相对 L2 误差在 1% 到 5% 之间都是正常范围。如果大于 5%,先回去查损失函数是不是某项没加,再看权重设置和训练迭代次数。可视化时用 pcolormesh 画 u(x, t) 的云图,对比网络输出和真解,肉眼看形状一致基本就达标了。

4. PINN 求解微分方程的常见坑与排查思路

4.1 二阶导返回 None 或者损失 NaN

现象:训练刚开始,损失直接变成 nan,或者计算 u_xx 时结果全是 None。这类问题在 PINN 踩坑里排第一。

原因:最常见的是第一次调 autograd.grad 时没有加 create_graph=True,导致 u_x 脱离计算图,后续对它求导时梯度链断开,返回 None。另一个常见原因是网络权重初始化不当,tanh 输入过大,导致输出和导数出现极大值,残差平方爆炸。

解决:所有 autograd.grad 调用统一加 create_graph=True,并且按照“先求一阶,再对一阶结果求二阶”的顺序逐步计算。初始化统一换 Xavier,并在训练前跑一两次前向,检查输出的量级是否在合理范围。如果输入坐标范围很大,比如时间到 100,先做归一化到 [0,1],否则神经网络很难兜住这么大的数值波动。

4.2 边界条件学不进去,解一直在飘

现象:内部残差已经降到很低,但画出来的解整体被抬高或压低,边界处 u(0,t) 明显不为零。

原因:这是典型的损失“偏科”。初始阶段 PDE 残差的量级可能远大于边界损失,反向传播时梯度被残差项主导,边界条件对应的梯度被淹没。另一个原因是配点里边界点太少,网络在这些点上的约束不足。

解决:把 w_bc 和 w_ic 从 1 提到 5 或者 10,单独观察三个损失项的变化。我一般会在每个 epoch 打印出 loss_pde、loss_bc、loss_ic 三个数值,如果 loss_bc 一直不降,直接加大它的系数。还有一种硬办法:修改网络结构,让输出恒满足边界条件,比如对两端为零的边界,直接令 u(x,t) = x * (1-x) * N(x,t),这样边界条件被数学上强制满足,不用再训练。但这种方法对复杂边界条件不通用,软约束加权重才是常规方案。

4.3 损失很漂亮但解答非所问

现象:训练结束后 loss 很低,但相对 L2 误差很大,画出来的解和真解完全对不上。

原因:你要意识到,神经网络拟合的是一个函数,而不仅是一堆点。如果内部采样点太少,或者采样点分布不均匀,网络可以找到一个在采样点上残差很小、但点与点之间剧烈震荡的函数。这就是“过拟合配点”。另一个可能是 PDE 权重被调得过小,边界和初始损失占绝对主导,网络干脆学着满足边界和初值,中间过程完全交给自由发挥。

解决:先增加内部配点数量,从 5000 增到 20000 看看误差是否变化。然后在测试网格上重新计算残差分布,如果某些区域的残差特别大,说明配点在这些区域覆盖不足,需要用自适应采样补充点位。最后检查三个损失的量级,如果 loss_bc 比 loss_pde 小几个数量级,权重就要重新平衡。

4.4 时间范围拉长后段完全失效

现象:把 t 从 [0,1] 改成 [0,5],前面一小段拟合得还行,后面时间段的解几乎全部衰减为零或者乱跳。

原因:这涉及 PINN 的谱偏差和因果学习问题。神经网络倾向于先学习低频分量,热传导问题的解随时间推移越来越平滑,后段频率低、能量也低,网络很难从损失函数里学到足够的梯度信号;同时方程本身具有因果关系,t 时刻的解依赖于之前时刻的状态,普通配点方式没有显式建模这种因果结构。

解决:我用的最多的是时间分段训练。把时间区间切成 [0,1]、[1,2]、[2,3] 等若干段,逐段训练,后一段用前一段的输出作为初始参考。还有一种技巧是给残差损失按时间做因果加权,让早时刻的残差权重更大。简单版本就是在损失里乘以一个和 t 相关的衰减系数,或者直接用分段策略,工程上更可控。

5. 让解更可信:验证技巧、自适应采样与权重调优

5.1 验证解的可信度:除了 L2 误差还要看什么

相对 L2 误差是全局指标,但它会掩盖局部问题。我一般会额外检查两点:一是边界处和初始时刻的最大绝对误差,这两个地方是约束项的直接作用点,如果边界处误差大到 1e-2 级别,说明约束没有真正吃进去;二是在固定 t 时刻画 u(x) 剖面曲线,和真解叠在一起看趋势,特别是曲线有没有不正常的抖动。

另一个很关键的做法是把损失曲线分开画。很多人只盯着总损失,其实 PINN 的三类损失经常互相打架,总损失下降不代表每一项都在健康收敛。画出 loss_pde、loss_bc、loss_ic 三条曲线,如果某一条长时间持平,说明对应约束项没有学到东西,需要调权重或加采样。

5.2 自适应采样:把配点放到残差大的地方

均匀采样在简单问题上够用,但遇到激波、边界层这类局部剧烈变化区域,均匀采样会浪费大量配点在平坦区域。常见做法是残差自适应细化,也叫 RAR:训练一段时间后,用当前模型在所有配点上计算残差绝对值,残差大的点说明网络在这里还没学好,把那些点附近补充一批新点,替换掉一部分旧点。

def refine_points(model, x_f, t_f, n_add=1000, noise=0.02): # 用当前模型评估残差分布 x_f.requires_grad_(True) t_f.requires_grad_(True) u = model(x_f, t_f) u_x = torch.autograd.grad(u, x_f, grad_outputs=torch.ones_like(u), create_graph=True)[0] u_xx = torch.autograd.grad(u_x, x_f, grad_outputs=torch.ones_like(u_x), create_graph=False)[0] u_t = torch.autograd.grad(u, t_f, grad_outputs=torch.ones_like(u), create_graph=False)[0] res = torch.abs(u_t - 0.1 * u_xx).detach() # 取残差最大的前 n_add 个点,加小扰动生成新点 _, idx = torch.topk(res.view(-1), n_add) x_new = x_f[idx] + noise * torch.randn(n_add, 1) t_new = t_f[idx] + noise * torch.randn(n_add, 1) # 新点替换旧点,保持总数不变 x_f = torch.cat([x_f[:n_add], x_new], dim=0) t_f = torch.cat([t_f[:n_add], t_new], dim=0) return x_f.detach(), t_f.detach()

这段代码的注意点是取前 n_add 个最大残差点后,把它们的邻域作为新采样区域,而不是直接用原坐标,避免网络死记硬背这些点。替换而不是追加,是为了控制总配点数量不变,训练开销不会线性增长。这个技巧在 Burgers 方程产生激波时效果尤其明显,能把激波附近的误差压掉一大截。

5.3 权重怎么调:手动优先,必要时用残差加权

调权重是 PINN 里最像玄学的部分。我自己的流程是:先全用 1 跑一遍,看哪项损失降得最慢;然后给降得慢的那项加权重,一次加 5 倍,不要一次加到 100。边界和初始条件通常需要比 PDE 残差更大的权重,因为它们是“硬约束”,错了整个解就偏了。

手动调不动的时候,再考虑残差加权。最简单的实现是把每个配点的残差平方按残差大小加权:

# 在 compute_loss 内部 pde_res = u_t - alpha * u_xx w = torch.abs(pde_res).detach() w = w / w.mean() loss_pde = torch.mean(w * pde_res ** 2)

这样的效果是让残差大的点获得更大梯度,和自适应采样思路一致,但作用在损失层面。注意 w 要用 detach 截断,否则权重本身参与求导,梯度计算会乱掉。这个技巧适合固定配点不想换点的场景。

最后说一点个人习惯:我每次跑 PINN 都会固定随机种子并保存模型参数,否则复现别人的结果简直像抽卡。做对比实验时,只改一个变量,其他全部保持不动。这个方向值不值得投入,我的判断标准很简单:如果你要解的问题传统方法要画网格铺半天、或者问题是高维反演,那 PINN 值得认真投入;如果只是常规边界条件下求个高精度数值解,老老实实去用有限元或者谱方法,别跟自己过不去。希望这几章的经验能帮你少走几趟弯路。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/11 19:55:01

可穿戴传感器时间序列数据增强:Python代码实现与避坑指南

简介&#xff1a;这份资源面向从事可穿戴传感器、人体活动识别与帕金森病监测等方向的研究者和开发者&#xff0c;提供时间序列数据增强的示例代码。其思路源自TT Um等人发表于ICMI 2017的论文&#xff0c;通过对原始信号施加多种失真变换来扩充样本&#xff0c;从而为识别模型…

作者头像 李华
网站建设 2026/10/11 19:54:22

期货自动交易软件横向实测:五款主流平台深度评测

这两年期货市场的波动越来越大&#xff0c;身边不少做手动交易的朋友都在聊同一个话题&#xff1a;要不要上自动化交易系统。说实话&#xff0c;我从2020年开始就在断断续续使用各类期货自动交易软件&#xff0c;从最基础的量化回测平台到直接对接实盘的交易终端都接触过一些。…

作者头像 李华
网站建设 2026/10/11 19:49:55

Hadoop+Spark+Hive招聘推荐系统设计与实现

1. 项目概述与选题思路如果你正在为计算机毕业设计选题发愁&#xff0c;又不想做那种前台页面加张数据库表糊弄事的“管理系统”&#xff0c;那“HadoopSparkHive招聘推荐系统”这个方向真的值得认真看一眼。招聘大数据分析这个题目&#xff0c;看上去只是一个普通的JavaWeb换壳…

作者头像 李华
网站建设 2026/10/11 19:49:23

SSH Key生成、配置与多账号管理全指南:从原理到实战排查

写SSH Key密钥生成这件事&#xff0c;其实是我接触过的开发者日常里藏着最多“隐性知识”的一环。很多人觉得自己会敲 ssh-keygen 就万事大吉&#xff0c;可一旦遇到多账号、权限报错、每次 push 都要输密码&#xff0c;就开始懵。这篇东西不打算写成一份“点击下一步”式的教…

作者头像 李华
网站建设 2026/10/11 19:47:44

opencode终端直接运行python命令:把endpoint改到TaoToken的配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华