简介围绕物理信息神经网络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_gradTrue) u torch.sin(x * torch.pi) # 一阶导 u_x torch.autograd.grad( u, x, grad_outputstorch.ones_like(u), create_graphTrue )[0] # 二阶导在一阶导的基础上再求一次 u_xx torch.autograd.grad( u_x, x, grad_outputstorch.ones_like(u_x), create_graphTrue )[0] print(u_xx)autograd.grad 计算的是标量对输入的梯度u 是向量时需要通过 grad_outputs 传入一个与 u 同形状的权重向量通常就是全 1 向量。create_graphTrue 表示把求导过程也保留在计算图里这样后续才能继续对 u_x 求导得到二阶导。第二个 autograd.grad 的 grad_outputs 用的是 ones_like(u_x)不是 ones_like(u)这个细节写错会报形状不匹配的错误。提示对 x 和 t 两个变量分别求导时要各调用一次 autograd.grad并且两次都要 create_graphTrue否则后续损失反向传播时梯度链会断掉。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) 0u(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_f10000, n_b200, n_ic200, seed42): torch.manual_seed(seed) # 内部配点均匀随机采样于 (0,1)x(0,1) x_f torch.rand(n_f, 1) t_f torch.rand(n_f, 1) # 边界 x0 和 x1 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) # 初始时刻 t0 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], dim1))Xavier 初始化在这里不是玄学而是刚需。tanh 在零附近近似线性如果权重初始化太大输入进激活函数后直接饱和梯度趋近于零网络早期根本学不动。PyTorch 默认的 init 方式对普通分类任务够用但对 PINN 这种每个点都要计算高阶导的任务初始化直接决定起步阶段的梯度质量。3.4 训练循环Adam 预热 L-BFGS 精修损失计算要把 PDE 残差、边界损失、初始损失分开算再求和这样每一部分可以单独观察。核心代码def compute_loss(model, data, alpha0.1, w_bc5.0, w_ic5.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_outputstorch.ones_like(u_f), create_graphTrue)[0] u_x torch.autograd.grad(u_f, x_f, grad_outputstorch.ones_like(u_f), create_graphTrue)[0] u_xx torch.autograd.grad(u_x, x_f, grad_outputstorch.ones_like(u_x), create_graphTrue)[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(), lr1e-3) for epoch in range(5000): optimizer.zero_grad() loss, _ compute_loss(model, data) loss.backward() optimizer.step() if epoch % 1000 0: print(fAdam epoch {epoch}: loss {loss.item():.6e}) # 第二阶段L-BFGS 精修 optimizer torch.optim.LBFGS(model.parameters(), lr0.8, max_iter1000, history_size50) 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-3L-BFGS 默认 0.8 到 1.0 通常表现更好。提示如果你的显存有限可以适当减少内部配点数到 5000L-BFGS 阶段对显存占用比 Adam 高一截因为它在每次迭代要保存历史梯度信息。3.5 快速评估相对 L2 误差和可视化训练完不能只看 loss必须把网络输出和解析解对比。用密集网格采样计算相对 L2 误差def relative_l2_error(model, alpha0.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], indexingij) 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_graphTrue导致 u_x 脱离计算图后续对它求导时梯度链断开返回 None。另一个常见原因是网络权重初始化不当tanh 输入过大导致输出和导数出现极大值残差平方爆炸。解决所有 autograd.grad 调用统一加 create_graphTrue并且按照“先求一阶再对一阶结果求二阶”的顺序逐步计算。初始化统一换 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_add1000, noise0.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_outputstorch.ones_like(u), create_graphTrue)[0] u_xx torch.autograd.grad(u_x, x_f, grad_outputstorch.ones_like(u_x), create_graphFalse)[0] u_t torch.autograd.grad(u, t_f, grad_outputstorch.ones_like(u), create_graphFalse)[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], dim0) t_f torch.cat([t_f[:n_add], t_new], dim0) 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 值得认真投入如果只是常规边界条件下求个高精度数值解老老实实去用有限元或者谱方法别跟自己过不去。希望这几章的经验能帮你少走几趟弯路。本文还有配套的精品资源点击获取