简介:针对基于物理信息神经网络求解微分方程的Python实践需求,这份压缩包提供了系统的方法示例与可运行代码。面向数值计算、深度学习交叉领域的初学者及研究人员,覆盖常微分方程、偏微分方程、随机微分方程等典型问题,并展示了DeepXDE、自动微分与损失函数构建等关键实现。压缩包共包含二十六个文件,以十七个ipynb笔记为主,辅以三个Python脚本、四个备份文件、图片与说明文档等,整体大小为八百九十一KB,便于快速查看与复用。已有二百一十七人参与学习。内容包含欧拉梁、扩散方程、泊松方程、拉普拉斯方程、洛伦兹系统等多个算例,既有常微分/偏微分方程基础案例,也有狄利克雷、诺伊曼、罗宾等边界条件的处理,以及雅可比-海森方法对比,能够帮助读者理解物理信息神经网络的建模、训练与验证流程,并迁移到自己的科研或工程问题中。
1. 为什么 PINN 能解微分方程:从网格到函数的思路转变
在传统数值解法里,我们习惯把求解区域铺满网格,然后在这个离散网格上用有限差分或有限元去逼近微分算子。但当几何变得复杂、维数升高,或者方程带有强非线性和多尺度特征时,网格生成的成本往往比求解本身还高。PINN(物理信息神经网络)换了个思路:不网格化,而是用神经网络去直接逼近微分方程的解函数,然后把控制方程、初始条件、边界条件全都编码到损失函数里,交给优化器去最小化。下面会用 Python 从零开始实现一个 PINN,覆盖最小代码、损失函数设计、训练排错到具体的 Burgers 方程求解。适合两类人:想把 AI 方法引入科学计算的研究者,以及想验证 PINN 是否值得替换现有求解器的工程人员。
2. PINN 求解微分方程的数学构成:网络、残差与损失
PINN 的核心,是把“求解微分方程”这个最终目标,转换成“优化一个由物理规律约束的损失函数”。所以先别急着写代码,先弄清楚损失函数是怎么来的,每一项代表什么。
2.1 神经网络作为解空间的函数逼近器
在 PINN 里,神经网络的作用是“函数逼近”。输入是自变量(例如时间 t 和空间 x),输出是方程未知函数 u(t,x) 的预测值。理论上,只要网络足够宽、足够深,就能以任意精度逼近任意连续函数,这保证了 PINN 的解空间不会偏离真实解太远。
常见的做法是使用全连接前馈网络(MLP),层数和宽度取决于问题复杂度。线性问题用 2 到 3 层即可;带激波、边界层这类剧烈变化的解,通常要 4 到 6 层,每层 32 到 64 个神经元。PINN 不需要预训练,也不需要任何已有的解作为监督信号;网络自己会在优化过程中学着符合物理规律。以下是一个可动态指定层宽的 MLP 定义:
import torch.nn as nn class PINN_Net(nn.Module): def __init__(self, layers, activation=nn.Tanh()): super().__init__() self.activation = activation self.linears = nn.ModuleList() for i in range(len(layers) - 1): self.linears.append(nn.Linear(layers[i], layers[i+1])) for layer in self.linears: nn.init.xavier_normal_(layer.weight) nn.init.zeros_(layer.bias) def forward(self, x, t): inputs = torch.cat([x, t], dim=1) for layer in self.linears[:-1]: inputs = self.activation(layer(inputs)) return self.linears[-1](inputs)代码里,layers是一个列表,例如[2, 50, 50, 50, 1]表示输入 2 维、中间三层各 50 个神经元、输出 1 维。激活函数默认用 Tanh,因为它的输出范围在(-1,1)之间,且高阶导数连续,适合在自动微分中构造二阶导数。权重用 Xavier 初始化,配合 Tanh 能避免深层网络信号逐层衰减。
2.2 自动微分:PINN 的基石
PINN 之所以能算残差,靠的是自动微分。给定一个输入点,网络前向计算出 u,随后框架自动构建 u 对输入 x 和 t 的求导图。这样哪怕方程里出现对 x 的二阶导、对 t 的一阶导,也只需要调用torch.autograd.grad,不需要手推差分格式,也不会引入截断误差。
以 Burgers 方程 u_t + u * u_x - nu * u_xx = 0 为例,用自动微分定义残差函数:
import torch 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_x), create_graph=True)[0] f = u_t + u * u_x - nu * u_xx return f这里有几个细节需要留意。输入 x、t 在求梯度前必须显式设置requires_grad_(True),否则梯度函数会报错。create_graph=True保证二阶导数也能反向传播,这意味着 PDE 残差对网络参数的梯度是完整的,不会因为截断而出现梯度缺失。最后返回的 f 就是该点上的物理残差,理想情况下应该为零。
2.3 完整的损失函数形态
PINN 的损失函数一般由三部分构成:控制方程残差、初值残差、边值残差,有时也加入观测数据残差。对于 Burgers 方程:
L = L_pde + λ_ic · L_ic + λ_bc · L_bc
其中 L_pde 是内部采样点上物理残差的均方误差,L_ic 和 L_bc 是初值、边值条件上的均方误差。λ 是两个权重系数,用于平衡不同区域的任务难度。初值条件给的是 t=0 时刻整个 x 区间上的函数值,边界条件给的是左右端点在 t 上的取值规律,两者物理意义完全不同,所以需要分开加权。
训练开始前可以参考表 2-1 设置初始值。
| 参数 | 建议值 | 说明 |
|---|---|---|
| λ_ic / λ_bc | 1.0 / 1.0 | 训练初期保持平衡 |
| 内部采样点数 | 5000~10000 | 控制方程残差采样 |
| 初边值采样点数 | 500~1000 | 各边界均匀采样 |
| 优化器学习率 | 1e-3 | Adam,后续按轮衰减 |
训练过程中如果内部残差下降很慢而初边值损失已经接近零,说明网络在“过度拟合边界”,此时把 λ_ic、λ_bc 调低到 0.1 左右,让网络把更多容量留给物理区域。反过来,如果边界损失一直压不下去,就适当调高对应权重。
2.4 采样策略:怎么收集训练点
PINN 没有固定训练集,每轮迭代都可以重新生成样本点。内部点在整个求解区域内用均匀分布随机采样,初边值点沿初始时刻和边界线上采样。每个训练步重新采样,比一次性固定训练集更稳,网络不会记忆某组特定点,而是在整个区域上泛化。
采样点数量的选择会影响收敛。数量越多,物理约束越强,但单步训练越慢。调参时先固定网络结构和权重,只调采样点规模,观察损失曲线是否平滑,再下结论。对于一维时间相关方程,8000 个内部点通常够用;二维或高维问题,需要按维度增长量级增加采样数。
3. 用 Python 最小实现 PINN:从数据生成到训练循环
理论清楚之后,现在搭一个可运行的 Python 实现。这里的代码基于 PyTorch,版本 2.0 以上都行。机器有 CUDA 就把 device 切到 cuda,没有就 CPU,样例规模训练时间在几分钟到几十分钟不等。
3.1 环境准备与依赖安装
在开始之前,确认当前环境已经装好 Python、torch、numpy。如果还没配置过 PyTorch,直接创建一个独立环境最省心:
conda create -n pinn python=3.10 conda activate pinn pip install torch numpy matplotlib上面的命令创建了一个干净的虚拟环境,避免污染系统的 Python。PyTorch 安装时注意对照官网的 CUDA 版本,CPU 版也能跑通本文的全部代码,只是训练速度慢一些。安装完成后可以用python -c "import torch; print(torch.__version__)"验证是否成功。
3.2 生成训练数据与损失计算
PINN 的训练数据不是一次生成完的,而是按需在训练循环里重新采样。这里写了一个显式的采样函数,返回内部残差点、初值点、边界点三组坐标:
def sample_data(n_pde=8000, n_ic=1000, n_bc=1000, domain=[0, 2*3.14159], t_span=[0, 1.0]): x_pde = torch.rand(n_pde, 1) * (domain[1] - domain[0]) + domain[0] t_pde = torch.rand(n_pde, 1) * (t_span[1] - t_span[0]) + t_span[0] # 初始条件点: t=0, x 在区间内均匀 x_ic = torch.rand(n_ic, 1) * (domain[1] - domain[0]) + domain[0] t_ic = torch.zeros_like(x_ic) # 边界点: 左右边界各一半,t 在时间上均匀 x_bc = torch.cat([torch.zeros(n_bc // 2, 1), torch.full((n_bc // 2, 1), domain[1])], dim=0) t_bc = torch.rand(n_bc, 1) * (t_span[1] - t_span[0]) + t_span[0] return (x_pde, t_pde), (x_ic, t_ic), (x_bc, t_bc)这个函数返回三组点:(x_pde, t_pde)用于计算物理残差,(x_ic, t_ic)用于计算初始条件损失,(x_bc, t_bc)用于计算边界条件损失。domain和t_span是需要根据具体问题修改的两个区间参数。注意torch.full生成边界点时要指定 dtype,否则可能隐式转换成 float64,和网络参数的 float32 不一致导致类型报错。
配合上面的采样函数,以 Burgers 方程为例写损失计算。初始条件取 u(0,x) = -sin(x),边界条件取周期边界:
def compute_loss(model, data, nu=0.01): (x_pde, t_pde), (x_ic, t_ic), (x_bc, t_bc) = data # PDE 残差 f = pde_residual(model, x_pde, t_pde, nu) loss_pde = torch.mean(f**2) # 初始条件 u_ic_pred = model(x_ic, t_ic) u_ic_exact = -torch.sin(x_ic) loss_ic = torch.mean((u_ic_pred - u_ic_exact)**2) # 边界条件,x_bc 两端是 0 和 2π u_bc_pred = model(x_bc, t_bc) left_mask = x_bc[:, 0] == 0 right_mask = x_bc[:, 0] == 2*3.14159 loss_bc = torch.mean((u_bc_pred[left_mask] - u_bc_pred[right_mask])**2) return loss_pde, loss_ic, loss_bc周期边界的编码是 PINN 里一个常见的易错点。很多入门代码会把边界条件错误地写成“左右两端各自逼近某个固定值”,那并不符合真实物理约束。这里用的是“左端预测值 = 右端预测值”的形式,才对应周期边界。mask 筛选时用了布尔掩码,分别取出左边界和右边界的预测值再算均方误差。
表 3-1 是 PINN 初始参数的建议值范围,之后所有实验都可以从这组参数起步。
| 参数 | 起始值 | 调整方向 |
|---|---|---|
| 网络宽度 | 50 | 不收敛时加宽 |
| 网络深度 | 3~4 | 边界层陡峭时加深 |
| pde 采样点数 | 8000 | 高维或强非线性时翻倍 |
| 训练轮数 | 2000 | 观察损失曲线确定 |
3.3 训练循环与优化器配置
数据生成和损失计算都到位之后,训练循环本身不复杂,但有一个容易被忽略的关键点:每一轮迭代都重新采样,而不是用固定数据集。这样做的好处是,网络在无数个“随机视角”下不断优化,最终在整个区域上连续地符合物理规律。
import torch.optim as optim model = PINN_Net([2, 50, 50, 50, 1]) optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=500, gamma=0.9) for epoch in range(2000): data = sample_data() loss_pde, loss_ic, loss_bc = compute_loss(model, data) loss = loss_pde + loss_ic + loss_bc optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() if epoch % 100 == 0: print(f'epoch {epoch}: pde={loss_pde.item():.3e}, ' f'ic={loss_ic.item():.3e}, bc={loss_bc.item():.3e}')训练中需要观察三个损失的相对量级。PINN 的损失面比纯监督学习复杂得多,学习率建议从 1e-3 以下开始。如果 loss 震荡,优先降低学习率或者加大采样点数量,而不是改变网络结构。StepLR 每 500 轮把学习率乘以 0.9,是一种温和的衰减方式,既能保证前期收敛速度,又能在后期帮助损失稳定下降。
4. PINN 训练不收敛时的检查顺序与参数调整
PINN 的调试和传统机器学习不太一样。没有固定数据集,没有验证标签,你的正确性完全来自物理规律。训练卡住时,按顺序排查比乱调参数有效得多。
4.1 先看三项损失的相对走势
训练日志里,如果 pde 损失始终在 1e-2 以上下不来,而 ic、bc 已经到 1e-4,问题几乎都出在内部点不够或者权重失衡。这种情况我一般先调权重,再看采样密度。
举个例子,运行上面的代码,如果 200 轮后看到:
epoch 200: pde=1.3e-2, ic=1.2e-4, bc=8.0e-5说明网络已经满足了初边值约束,但物理残差没有降下去。这是 PINN 里最常见的“欠约束内部”状态。可以把采样点从 8000 提升到 20000,或者在构成总损失时给 loss_pde 乘上一个大于 1 的系数,例如loss = 10 * loss_pde + loss_ic + loss_bc。这种改动不需要动网络结构,只需要调整 loss 组合方式。
4.2 激活函数与网络结构的影响
激活函数的选择直接影响高阶导数的表达能力。Tanh 是最常用的 PINN 激活函数,因为它的输出范围在 (-1,1) 之间,二阶导数非零且连续,适合自动微分构造的二阶损失。ReLU 虽然训练快,但二阶导恒为零,求解二阶微分方程时残差会恒等于零,完全无法训练——这是新手最容易踩的坑。
网络宽度和深度也需要控制。宽度从 50 增加到 100,训练时间大约翻倍,但损失下降速度不一定同比例加快。通常用“宽度 50、深度 4”起步,模型能拟合再增宽,而不是一开始就堆参数量。边界层特别陡峭的问题,加宽比加深更有效;需要捕捉全局振荡特征时,加深比加宽更明显。
表 4-1 整理了收敛异常时的快速排查方向。
| 症状 | 最可能原因 | 先试的调整 |
|---|---|---|
| 三项损失都不降 | 学习率过高 | 学习率降到 1e-4 |
| pde 损失高,ic/bc 很低 | 权重失衡 | λ_ic/bc 降到 0.1 |
| 训练后期 loss 震荡 | 采样点太少 | pde 点数加到 20000 |
| 边界附近振铃 | 边界权重过大 | 调低 bc 项权重 |
4.3 采样点、边界权重、学习率是三角组合
调参不是单变量任务。实际训练中,采样点数量、损失权重、学习率是协同作用的三角组合。某一项改动往往会引发连锁反应:把 pde 点数从 5000 加到 20000,单步计算变慢,需要同步降低学习率并延长训练轮数;把边界权重调低,pde 损失会更快下降,但边界误差可能反弹。
PINN 对权重初始化也比较敏感。同一个方程,换一个随机种子,最终误差可能差 30% 以上。建议一开始固定随机种子,把初始化变量排除掉:
import torch torch.manual_seed(1234)如果发现每次运行结果差异大,先固定种子,再用上面的排查表定位真正的结构问题,而不是把随机性误判成算法缺陷。这样可以节省大量调试时间。
5. 用 PINN 求解带粘性的 Burgers 方程并验证误差
最后用一个具体的偏微分方程跑完整个流程,验证效果到底如何。这里选择 Burgers 方程:u_t + u * u_x = ν u_xx,区域 x ∈ [0, 2π],t ∈ [0, 1],初值 u(0,x) = -sin(x),周期边界。当粘性系数 ν 较小时,解会出现陡峭的激波结构,是检验 PINN 对高阶导计算能力的经典基准。
5.1 完整的训练配置
torch.manual_seed(1234) model = PINN_Net([2, 64, 64, 64, 64, 1])这里把网络宽度从 50 提升到 64,并加深一层,是为了让网络有能力表达可能出现的高梯度区域。训练轮数增加到 3000,学习率仍然从 1e-3 开始,配合 StepLR 逐步衰减。
5.2 结果验证:误差怎么算
训练完成后,需要在一个稠密的网格上把预测结果和参考解做对比。参考解析解可以用 scipy 的数值方法或者已知的级数解生成。简单起见,在所有测试格点上计算相对 L2 误差:
x_test = torch.linspace(0, 2*3.14159, 256).repeat(100, 1) t_test = torch.linspace(0, 1, 100).unsqueeze(1) u_pred = model(x_test, t_test) u_ref = reference_solution(x_test, t_test, nu=0.01) relative_l2 = torch.norm(u_pred - u_ref) / torch.norm(u_ref) print(f'relative L2: {relative_l2:.2e}')如果相对 L2 误差在 1e-2 到 1e-3 量级,这个 PINN 模型基本合格。若误差偏大,回到第 4 章的排查表,优先增加 pde 采样点和训练轮数,而不是加网络宽度。激波附近的单个点误差可以单独统计,很多情况下整体误差不高,但局部误差超出了工程容忍度。
5.3 边界条件硬约束与自适应采样
PINN 的优点是免网格、能融合观测数据、天然适合逆问题,但训练时间明显高于传统数值方法,并且很难给出先验误差界。如果追求更高精度,可以把边界条件从软损失改造成硬约束:比如构造一个自动满足周期边界的网络输出层,把周期性的三角函数乘到网络输出上。这样边界损失就不再参与优化,网络可以把全部容量留给内部物理残差。
另一个有效的改进是自适应采样。在激波附近加密集采样点,在平缓区域减少采样密度,能显著加快收敛。做法是每训练若干轮,用当前模型预测的内部损失反过来指导下一轮采样:把损失高的区域作为新采样点的候选区。手头有具体方程需要求解时,建议从本文的最小实现开始跑通,再逐步替换成硬约束和自适应采样,每一步都观察误差变化。
本文还有配套的精品资源,点击获取