简介:本资源是一套基于物理信息神经网络(PINN)求解偏微分方程(PDE)的Python实现代码包,面向计算机、电子信息工程及数学等专业的本科生与研究生,适用于课程设计、期末大作业及毕业设计等实践场景,帮助学习者掌握AI驱动的科学计算新范式。压缩包共48个文件,含19个核心Python源码(覆盖1D/2D Burgers方程、热传导、浅水方程等典型PDE案例)、15个编译后pyc文件、5个动态演示GIF、2个训练参数文件及README.md等说明文档,整体3.6MB,结构清晰、模块化强,便于按物理模型分类学习与调试。已有1756人学习下载,代码采用参数化设计,关键超参与方程形式均可灵活配置,配合详尽中文注释与可直接运行的示例数据,显著降低PINN入门门槛;同时提供训练过程可视化(GIF)与多维PDE求解对比,助力理解损失函数构建、边界条件嵌入及神经网络物理约束机制。
1. PINN不是“套公式”的黑匣子:用Python代码把PDE求解从数学推导拉回工程可复现现场
你手头有一组偏微分方程(PDE)——比如热传导、流体连续性或波动方程——但边界条件复杂、解析解不存在、网格划分又容易失稳。传统数值方法(FDM/FEM)卡在网格生成、刚性处理和高维扩展上;而纯数据驱动的神经网络,又因缺乏物理约束,预测结果常违背守恒律、发散甚至违反基本量纲。这时候,PINN(Physics-Informed Neural Network)不是锦上添花的“AI噱头”,而是把物理定律直接编译进损失函数的硬编码式建模:它不依赖海量标签数据,只靠方程本身+少量边界/初始点就能训练;它输出的是连续可微的解析近似解,而非离散网格上的插值结果;更重要的是,这份.rar里打包的 Python 代码,是真正跑通了 Burgers 方程、Poisson 方程、Navier-Stokes 简化形式的最小可行实现——没有 TensorFlow/Keras 的冗余封装,纯 PyTorch + Autograd 实现,所有前向传播、残差计算、梯度更新逻辑都摊开在model.py和train.py里。适合正在写毕业论文需要可复现 PDE 求解模块的研究生,也适合工业仿真中想快速验证新边界条件影响的 CAE 工程师——只要你熟悉 Python 基础、能看懂torch.autograd.grad的调用链,就能在 2 小时内跑通第一个案例并修改自己的方程。
2. 从方程到损失:PINN 的核心不在“神经网络”,而在“物理信息怎么注入”
2.1 物理信息不是加个正则项:PDE 残差必须显式构造为可微算子
PINN 的本质,是把偏微分方程 $ \mathcal{N} u = 0 $(其中 $ \mathcal{N} $ 是微分算子,$ u $ 是待求解函数)转化为神经网络的监督信号。关键不是让网络拟合已知解,而是让它满足方程本身。以一维 Burgers 方程为例:
$$ \frac{\partial u}{\partial t} + u \frac{\partial u}{\partial x} - \nu \frac{\partial^2 u}{\partial x^2} = 0 $$
在代码中,这被严格拆解为三部分可自动求导的张量运算:
# model.py 中的核心残差计算(PyTorch) def pde_residual(model, x, t, nu=0.01): u = model(torch.cat([x, t], dim=1)) # [N, 1] # 一阶导:u_t u_t = torch.autograd.grad(u.sum(), t, create_graph=True)[0] # 一阶导:u_x u_x = torch.autograd.grad(u.sum(), x, create_graph=True)[0] # 二阶导:u_xx u_xx = torch.autograd.grad(u_x.sum(), x, create_graph=True)[0] # 构造PDE残差:u_t + u*u_x - nu*u_xx residual = u_t + u * u_x - nu * u_xx return residual提示:
create_graph=True是必须项——否则高阶导数无法反向传播;u.sum()是为了生成标量 loss,避免grad报错;torch.cat([x,t],dim=1)强制输入为二维张量,适配全连接网络输入层。这里没有用torch.nn.functional里的现成算子,因为微分必须作用于网络输出u本身,而非中间特征。
这个残差函数不是装饰性模块,而是整个训练循环的基石。后续所有损失项(PDE 残差、初始条件、边界条件)都基于它构建。它的输出维度必须与采样点数一致(如[1024, 1]),才能参与MSE计算。若你替换为其他方程(如 Laplace 方程 $ \nabla^2 u = 0 $),只需重写pde_residual函数,其余训练流程完全复用——这是 PINN 可迁移性的底层保障。
2.2 边界与初始条件不是“额外标签”,而是带权重的硬约束项
PINN 不需要全域标注数据,但必须提供足够信息锚定解空间。常见做法是采样三类点:
- PDE 内部点:在定义域内随机采样(如
x ∈ [-1,1], t ∈ [0,1]),用于约束方程成立; - 初始条件点:固定
t=0,x遍历区间,强制u(x,0) = u_0(x); - 边界条件点:固定
x = ±1,t遍历时间,强制u(±1,t) = g(t)或∂u/∂x = h(t)。
在train.py中,这些点被组织为独立张量,并赋予不同权重:
# train.py 片段:多任务损失构造 def total_loss(model, x_pde, t_pde, x_ic, t_ic, u_ic, x_bc, t_bc, u_bc): # PDE 残差损失(权重 λ_pde = 1.0) res_pde = pde_residual(model, x_pde, t_pde) loss_pde = torch.mean(res_pde**2) # 初始条件损失(权重 λ_ic = 10.0) u_pred_ic = model(torch.cat([x_ic, t_ic], dim=1)) loss_ic = torch.mean((u_pred_ic - u_ic)**2) # 边界条件损失(权重 λ_bc = 1.0) u_pred_bc = model(torch.cat([x_bc, t_bc], dim=1)) loss_bc = torch.mean((u_pred_bc - u_bc)**2) return loss_pde + 10.0 * loss_ic + 1.0 * loss_bc权重设计有明确物理依据:初始条件通常比内部残差更“确定”,若不加权,网络会优先拟合 PDE 残差而忽略初值,导致时间演化起点错误;边界条件权重设为 1.0 是因多数问题边界信息精度与 PDE 内部相当。实际项目中,我一般会先固定λ_ic=10,再用验证集上L2 error on IC反向调节——而不是凭经验拍定。注意:u_ic和u_bc是真实函数值(如u_ic = -sin(π*x_ic)),不是网络预测值,因此这部分是标准监督学习。
2.3 网络结构不是越大越好:PINN 对深度与宽度有隐式敏感性
该代码包采用 4 层全连接网络([2, 50, 50, 50, 1]),输入为(x,t),输出为u(x,t)。这不是随意选择:
- 输入维度必须为 2:PDE 解是时空联合函数,
x和t不能拼接成高维向量再降维,必须保留其物理意义——否则导数计算会丢失坐标关联; - 隐藏层宽度 50 是平衡点:小于 30 时 Burgers 方程残差难收敛(<1e-3);大于 100 后训练震荡加剧,且 GPU 显存占用翻倍(单卡 2080Ti 下 50 层需 2.1GB,100 层达 4.7GB);
- 层数 4 是经验下限:少于 4 层(如 3 层)时,对强非线性项(如
u*u_x)拟合能力不足,残差长期卡在 1e-1 量级;多于 5 层虽理论表达力更强,但梯度消失风险上升,需配合残差连接(代码未实现,需手动添加)。
网络初始化采用torch.nn.init.xavier_normal_而非默认uniform,因 Xavier 能更好适配 tanh 激活函数(代码中nn.Tanh()是默认选择)。曾试过swish和gelu,但残差收敛速度反而下降——tanh 的饱和区恰好抑制了 PDE 解在边界处的剧烈振荡,这是 PINN 特有的“激活函数物理适配性”。
3. 训练不收敛?别急着调 learning rate:先检查这五个物理一致性断点
3.1 现象:PDE 残差 loss 一直 >1e-2,但 IC/BC loss 快速降到 1e-5
原因:pde_residual中微分顺序错误或变量未requires_grad=True。常见错误是x和t张量创建时未声明requires_grad=True,导致autograd.grad返回None或零梯度。
解决:在采样后立即添加
x_pde = x_pde.requires_grad_(True) t_pde = t_pde.requires_grad_(True)且必须在pde_residual函数内对x和t做此操作——若在train.py中设置,进入函数后可能因 detach 失效。
3.2 现象:训练初期 loss 突然爆炸(如从 1e-3 跳到 1e+6)
原因:Burgers 方程中u*u_x项在u值较大时产生数值不稳定,尤其当网络初始输出偏大(如u≈5)且u_x也大时,乘积项梯度爆炸。
解决:在pde_residual中加入梯度裁剪前置保护:
# 在计算 u*u_x 前 u_clipped = torch.clamp(u, min=-10.0, max=10.0) # 防止 u 过大 u_x_clipped = torch.clamp(u_x, min=-10.0, max=10.0) nonlinear_term = u_clipped * u_x_clipped注意:clamp仅用于稳定训练,不影响最终解精度——收敛后u自然回落至物理合理范围(如 Burgers 方程解通常在[-1,1])。
3.3 现象:IC loss 降得快,但预测解在t>0.5后严重偏离真解
原因:时间步长采样不均。代码默认t_ic仅在t=0采样,但若t_bc仅覆盖[0,0.3],网络未见过t>0.3的边界行为,外推失效。
解决:确保t_bc覆盖全时间域,例如:
t_bc = torch.linspace(0, 1, 256).reshape(-1, 1) # 而非 torch.rand(256,1)且x_bc应固定为边界点(如x=-1和x=1),而非随机采样——边界是集合,不是区间。
3.4 现象:GPU 显存 OOM,即使 batch_size=1
原因:create_graph=True导致计算图持续累积。PyTorch 默认在每次backward()后释放图,但autograd.grad若嵌套过深(如四阶导),图节点爆炸。
解决:在pde_residual结尾添加显式图清理:
# 计算完 residual 后 residual_detached = residual.detach() # 断开计算图 return residual_detached # 但注意:这会使 residual 不参与反向传播!正确做法:不 detach,改用torch.utils.checkpoint包装高阶导数计算,或降低采样点数(x_pde.shape[0]从 1024 降至 512)。
3.5 现象:训练 10000 epoch 后 loss 平稳,但u(x,t)预测值全为常数(如全 0.32)
原因:网络陷入平凡解(trivial solution)。PDE 如 Laplace 方程∇²u=0有无数解,若初始条件/边界条件未提供足够约束,网络会选择最平滑解(常数)。
解决:增加初始条件采样密度(如x_ic从 64 点增至 256 点),或引入物理引导项——在 loss 中添加torch.mean((u - u_mean)**2)作为方差正则(u_mean为 IC 的均值),强制解具备变化性。该技巧在 Poisson 方程求解中实测有效。
4. 从 Burgers 到 Navier-Stokes:如何安全替换你的 PDE 方程
4.1 替换步骤:四步定位法,拒绝全局搜索
该代码包结构清晰,修改 PDE 仅需改动四个文件位置,无需理解全部逻辑:
| 文件 | 修改位置 | 修改内容说明 |
|---|---|---|
equations.py | 全局函数pde_residual() | 核心:重写微分项,保持输入(model,x,t)和输出residual维度一致 |
data_gen.py | 函数generate_ic_bc() | 关键:按新方程定义初始/边界函数,如u_ic = ...,u_bc_left = ...,u_bc_right = ... |
train.py | total_loss()中loss_ic/loss_bc计算 | 校准:确认u_ic和u_bc张量 shape 匹配(如[N,1]),避免广播错误 |
main.py | if __name__ == '__main__':下模型调用参数 | 启动:调整nu(运动粘度)、domain(定义域)、epochs(训练轮数)等超参 |
注意:
equations.py是唯一必须修改的文件;其余三处若新方程与 Burgers 形式一致(如同样是一维、同样 Dirichlet 边界),可跳过。
4.2 Poisson 方程实战:∇²u = f(x,y)的完整替换示例
假设求解单位正方形上的 Poisson 方程,源项f(x,y) = 2π² sin(πx) sin(πy),真解为u_true = sin(πx) sin(πy),Dirichlet 边界u=0。
第一步:修改equations.py
def pde_residual(model, x, y): # 注意:输入变为 x,y,无 t u = model(torch.cat([x, y], dim=1)) u_x = torch.autograd.grad(u.sum(), x, create_graph=True)[0] u_y = torch.autograd.grad(u.sum(), y, create_graph=True)[0] u_xx = torch.autograd.grad(u_x.sum(), x, create_graph=True)[0] u_yy = torch.autograd.grad(u_y.sum(), y, create_graph=True)[0] # ∇²u - f = 0 → u_xx + u_yy - f(x,y) = 0 f = 2 * (3.1415926**2) * torch.sin(3.1415926 * x) * torch.sin(3.1415926 * y) residual = u_xx + u_yy - f return residual第二步:修改data_gen.py中generate_ic_bc()
def generate_ic_bc(): # Poisson 无初始条件,只需边界 # 左右边界:x=0, x=1, y∈[0,1] x_left = torch.zeros(256, 1) y_left = torch.linspace(0, 1, 256).reshape(-1, 1) u_left = torch.zeros(256, 1) # u=0 x_right = torch.ones(256, 1) y_right = torch.linspace(0, 1, 256).reshape(-1, 1) u_right = torch.zeros(256, 1) # 上下边界:y=0, y=1, x∈[0,1] x_bottom = torch.linspace(0, 1, 256).reshape(-1, 1) y_bottom = torch.zeros(256, 1) u_bottom = torch.zeros(256, 1) x_top = torch.linspace(0, 1, 256).reshape(-1, 1) y_top = torch.ones(256, 1) u_top = torch.zeros(256, 1) # 拼接所有边界点 x_bc = torch.cat([x_left, x_right, x_bottom, x_top], dim=0) y_bc = torch.cat([y_left, y_right, y_bottom, y_top], dim=0) u_bc = torch.cat([u_left, u_right, u_bottom, u_top], dim=0) return x_bc, y_bc, u_bc第三步:train.py中total_loss删除loss_ic项,仅保留loss_pde和loss_bc;第四步:main.py中将t相关采样替换为y,并设置domain = [[0,1],[0,1]]。运行后,L2 error可稳定在5e-4量级——这比传统 FDM 在 64×64 网格上的精度更高。
4.3 参数敏感性表:不同 PDE 类型的推荐配置
| PDE 类型 | 推荐采样点数(PDE) | 推荐采样点数(BC) | 推荐学习率 | 关键注意事项 |
|---|---|---|---|---|
| Burgers (1D) | 1024 | 512 | 1e-3 | nu必须与真解匹配,否则残差无法收敛 |
| Poisson (2D) | 2048 | 1024 | 5e-4 | 边界点必须覆盖全部四边,单边缺失会导致解漂移 |
| Heat Eq (1D) | 512 | 256 | 1e-3 | 初始条件采样密度 > 边界,因时间演化对初值更敏感 |
| Wave Eq (1D) | 1024 | 512 | 1e-3 | 需同时提供u(x,0)和∂u/∂t(x,0),代码需扩写u_t_ic项 |
血泪经验:Wave 方程必须提供初始速度,否则解不唯一;若只给位移
u(x,0),网络会默认∂u/∂t=0,导致反射波形错误。该代码包未内置双初始条件支持,需手动在data_gen.py中添加u_t_ic并在total_loss中新增loss_u_t_ic项。
5. 验证不是画个图就完事:用三个量化指标揪出“看起来对但物理错”的解
5.1 守恒律检验:对 Burgers 方程,积分∫u dx应随时间衰减
Burgers 方程具有耗散性,质量(∫u dx)应单调递减。这是纯数值解无法保证的物理特性。在训练完成后,对每个t切片计算数值积分:
# validation.py 中添加 def conservation_check(model, x_grid, t_eval): u_pred = model(torch.cat([x_grid, t_eval], dim=1)).detach().cpu().numpy() mass = np.trapz(u_pred, x=x_grid.numpy().flatten()) # 梯形积分 return mass # 执行 x_fine = torch.linspace(-1, 1, 1000).reshape(-1, 1) t_seq = torch.linspace(0, 1, 20).reshape(-1, 1) mass_curve = [] for t in t_seq: t_tile = t.repeat(1000, 1) mass = conservation_check(model, x_fine, t_tile) mass_curve.append(mass) # 绘图 plt.plot(t_seq.numpy(), mass_curve, 'b-o', label='Predicted mass') plt.xlabel('t'); plt.ylabel('∫u dx'); plt.legend() plt.grid(True)若曲线非单调递减(如出现局部回升),说明网络未真正满足 PDE 的耗散机制——可能是nu设置过小,或残差权重λ_pde不足。此时L2 error可能仍 <1e-3,但物理意义已失效。
5.2 量纲一致性检查:用自动微分验证∂u/∂t与u*∂u/∂x量级匹配
PINN 输出u(x,t)是无量纲的,但其导数必须满足原始方程的量纲关系。对 Burgers 方程,∂u/∂t与ν*∂²u/∂x²应同量级。取一个典型点(如x=0,t=0.5),计算各项绝对值:
x_test = torch.tensor([[0.0]], requires_grad=True) t_test = torch.tensor([[0.5]], requires_grad=True) u_test = model(torch.cat([x_test, t_test], dim=1)) u_t = torch.autograd.grad(u_test.sum(), t_test, retain_graph=True)[0].item() u_x = torch.autograd.grad(u_test.sum(), x_test, retain_graph=True)[0].item() u_xx = torch.autograd.grad(u_x, x_test, retain_graph=True)[0].item() print(f"|u_t| = {abs(u_t):.2e}") print(f"|u*u_x| = {abs(u_test.item() * u_x):.2e}") print(f"|ν*u_xx| = {abs(0.01 * u_xx):.2e}")理想情况下,三项应在同一数量级(如1e-2 ~ 1e-1)。若|u_t| << |u*u_x|,说明对流项主导,网络可能欠拟合时间演化;若|ν*u_xx|过大,则扩散项被过度强调,需调小nu或增加λ_pde。
5.3 网格无关性验证:用不同分辨率采样点测试解稳定性
PINN 解应不依赖采样密度。固定训练超参,分别用N_pde=512/1024/2048训练三次,计算各次预测解在相同测试点集上的L2 error:
| PDE 采样点数 | L2 error (vs true) | 训练时间(min) | 备注 |
|---|---|---|---|
| 512 | 3.2e-3 | 8.2 | 误差偏大,但趋势正确 |
| 1024 | 1.8e-3 | 15.6 | 推荐默认配置 |
| 2048 | 1.7e-3 | 29.3 | 收益递减,显存占用翻倍,不建议盲目增加 |
若N=2048的 error 反而高于N=1024,说明过采样引发优化困难——此时应检查pde_residual是否有数值不稳定(如除零、log负数),或降低学习率。
6. 我为什么坚持手写autograd.grad而不用torch.func.grad?一个关于调试自由度的硬核习惯
去年帮一个航天院所做激波管模拟,他们用torch.func.grad(PyTorch 2.0+ 新 API)实现了自动微分,代码更简洁,但遇到一个致命问题:当u在某点接近零时,u*u_x项的梯度计算出现 NaN,而torch.func.grad的错误堆栈只显示grad_fn顶层,无法定位是u还是u_x先崩坏。我临时切回手写autograd.grad,在每一步后插入assert not torch.isnan(u).any()和print(f"u: {u.min().item():.3e}, {u.max().item():.3e}"),三分钟内发现是u_x在边界处因tanh饱和导数趋近零,导致u*u_x梯度计算溢出——于是加了u_clipped保护。这件事让我彻底放弃“高级 API 一定更好”的幻觉。
现在我的 PINN 项目里,pde_residual函数永远遵循这个模板:
def pde_residual(model, x, t, **kwargs): # Step 1: 输入检查 assert x.requires_grad and t.requires_grad, "x,t must have requires_grad=True" assert x.shape == t.shape, f"shape mismatch: x{tuple(x.shape)} vs t{tuple(t.shape)}" # Step 2: 前向计算,逐层打印形状(调试期开启,发布前注释) u = model(torch.cat([x, t], dim=1)) print(f"[DEBUG] u shape: {tuple(u.shape)}, range: [{u.min().item():.2e}, {u.max().item():.2e}]") # Step 3: 一阶导,带 NaN 检查 u_t = torch.autograd.grad(u.sum(), t, create_graph=True, retain_graph=True)[0] assert not torch.isnan(u_t).any(), f"NaN in u_t at t={t.mean().item():.3f}" u_x = torch.autograd.grad(u.sum(), x, create_graph=True, retain_graph=True)[0] assert not torch.isnan(u_x).any(), f"NaN in u_x at x={x.mean().item():.3f}" # Step 4: 高阶导,同样检查 u_xx = torch.autograd.grad(u_x.sum(), x, create_graph=True)[0] assert not torch.isnan(u_xx).any(), "NaN in u_xx" # Step 5: 构造残差,最后检查 residual = u_t + u * u_x - kwargs.get('nu', 0.01) * u_xx assert not torch.isnan(residual).any(), "NaN in final residual" return residual这看起来啰嗦,但每次assert失败,都能精准告诉我崩溃发生在哪一阶导、哪个变量、哪个采样点附近。而torch.func.grad把所有微分包装成黑盒,debug 成本翻倍。更重要的是,这种写法强迫我理解每个导数的物理含义——u_t是局部变化率,u_x是空间梯度,u_xx是曲率,它们在 PDE 中的角色完全不同。当某次u_xx的assert频繁触发,我就知道该去检查边界条件是否施加了过强的曲率约束,而不是盲目调 learning rate。
从那以后,我每次新建 PINN 项目,第一件事就是手写pde_residual并加上这五层assert。它不提升最终精度,但把调试时间从“猜半天”压缩到“看一眼报错”。希望帮到你。
本文还有配套的精品资源,点击获取