
物理信息神经网络PINN这个词最近在科学计算圈子里热度一直没降过。很多人初听觉得这是一个很神秘的新框架其实剥开来看它就是“用神经网络去拟合偏微分方程的解同时把物理方程本身当成监督信号塞进训练过程”。这篇文章我就拿一维热传导方程当例子从方程推导、损失函数组装、PyTorch代码实现到最后的可视化验证和调参复盘完完整整走一遍。看完之后你不仅能跑通一个具体case还能看懂论文里那些“网络损失训练”的通用套路。这篇文章适合两类人一类是刚接触PINN、想从零跑通一个完整例子的初学者另一类是已经能跑通代码但对损失权重、采样策略、梯度计算细节还一知半解的同学。读完之后你会发现PINN并没有想象中那么玄乎核心可能只有一句话把物理方程当作监督信号来训网络。1. PINN到底是什么用一个例子建立直觉1.1 从“解题”的角度理解PINN传统求解偏微分方程PDE的思路典型的是有限差分、有限元这类数值方法。它们的做法是把求解区域切成网格把连续的偏导数替换成离散的差分公式或者单元插值最后解一个大型代数方程组。这个思路很成熟但有几个绕不开的痛点网格生成费时费力、高维问题直接网格爆炸、反问题根据观测反推参数实现起来比较麻烦。PINN的思路则完全不同。它不画网格而是直接用一个神经网络 u_θ(x, t) 来当候选解。这个网络的输入是坐标 x 和时间 t输出是对应位置、对应时刻的物理量 u。训练这个网络的时候我不需要事先准备一堆“标准答案”而是把物理方程本身变成监督信号网络输出的解必须满足控制方程、初始条件和边界条件。哪个条件不满足就在损失函数里扣分训练过程就是在不断降低这些“扣分项”。用一个生活化的类比来说传统方法像是老师给一道题配好标准答案学生照着答案念PINN则更像老师只给定规则物理方程然后不断检查学生的回答是否合乎规则学生通过反复试错来学会答题。规则本身不需要标准答案但规则就是硬约束违反规则的输出会被惩罚。这就是PINN最核心的思想不需要大规模标签数据方程本身就是标签。传统深度学习要依赖人工标注数据而PINN只需要边界和初始信息外加一个明确的物理方程就能训练出一个满足物理规律的解。1.2 PINN解决的是哪一类问题为什么大家会关注PINN因为它解决的问题正好踩在传统方法的痛点上。第一高维问题。传统数值方法在三维、四维甚至更高维的空间上做网格剖分计算量随维度指数增长也就是所谓的“维数灾难”。PINN用连续函数表示解不需要显式网格在维度增加时网络结构几乎不用变只是输入维度增加几个数。第二正问题与反问题的统一。正向问题是从方程和定解条件求响应反问题是从观测数据反推方程参数。PINN的损失函数天然支持同时拟合数据和方程所以把某个参数比如热扩散系数 α改成待优化变量就能在同一个框架里做反演。这是传统方法很难做到的。第三自动微分带来的准确性。传统方法对梯度的离散近似有截断误差而PINN使用自动微分对网络本身求导数精度可以做到接近机器精度不会有差分格式的耗散和振荡问题。当然PINN也不是万能药。它目前对复杂方程、强间断问题的处理仍需不少技巧训练成本和调参难度也不低。但搞清楚一个简单例子是理解它优势与局限的最短路径。1.3 一个完整PINN需要哪些构件从代码角度看一个最小可用的PINN包含四个部分神经网络结构一般用多层感知机MLP输入坐标输出物理量自动微分能力求出网络输出对输入坐标的偏导数三部分损失函数PDE残差、初始条件、边界条件一个优化器常见Adam配合一定训练轮数。下面的内容我会按这个顺序一步步展开先从数学建模开始再落到代码最后看结果。2. 从方程开始一维热传导问题的建模细节2.1 为什么选一维热传导方程做例子一维热传导方程是PDE里最经典、也最好上手的一个∂u/∂t α ∂²u/∂x²其中 u(x, t) 表示一维杆上位置 x 处在时刻 t 的温度α 是热扩散系数。整个问题的求解区域可以设为 x ∈ [0, 1]t ∈ [0, 1]。我选这个方程有三个原因。第一它能写出解析解方便和网络的预测结果做数值对比验证模型对不对第二计算量足够小普通笔记本用CPU跑几分钟就能训完非常适合入门第三它包含了时间一阶导数、空间二阶导数足够让初学者理解PINN里“自动微分求梯度”的核心机制又不至于像Burgers方程那样因为激波和强非线性而增加训练难度。为了把问题完全确定需要补上初始条件和边界条件。这里我取初始条件u(x, 0) sin(πx) 边界条件u(0, t) 0u(1, t) 0这个组合有精确解u(x, t) exp(−α π² t) sin(πx)当 α 0.5 时温度会从正弦分布开始随时间指数衰减边界始终为零。这个解析解会是我后面验证准确性的“标尺”。2.2 神经网络如何“扮演”方程的解现在我要用一个神经网络去近似这个 u(x, t)。输入是二维向量 (x, t)输出是一维标量 u。如果网络训练得足够好那么对于任意 (x, t) ∈ [0,1]×[0,1]网络输出都应该接近上面的精确解。问题来了怎么让网络学会这个规律答案就是把方程本身变成损失函数。定义网络输出为 û(x, t)那么它要满足三个约束第一在求解区域内部方程残差要趋近零 r(x, t) ∂û/∂t − α ∂²û/∂x² ≈ 0第二初始点t0上网络输出要接近 sin(πx) 第三左右两个边界点x0 和 x1上输出要接近 0。这里就引出了PINN最关键的技术点∂û/∂t 和 ∂²û/∂x² 怎么算答案是自动微分Automatic Differentiation。PyTorch和TensorFlow这类框架都内置这个能力它沿着计算图链式求导可以精确求出网络输出对输入坐标的一阶导、二阶导。一个常见的坑是激活函数的选择。很多刚上手的人习惯用ReLU但ReLU的二阶导恒等于0拿来算 ∂²û/∂x² 会直接发现残差学不动。更稳妥的选择是tanh或sigmoid这类光滑激活函数因为它们存在连续的高阶导数。下面的代码统一用tanh。2.3 损失函数“三件套”怎么组装PINN的损失函数通常由三块组成L_pde (1/N_pde) Σ [∂û(xᵢ,tᵢ)/∂t − α ∂²û(xᵢ,tᵢ)/∂x²]² L_ic (1/N_ic) Σ [û(xᵢ,0) − sin(πxᵢ)]² L_bc (1/N_bc) Σ [û(0,tᵢ)² û(1,tᵢ)²]其中 N_pde 是内部配置点collocation points的个数N_ic 是初始点个数N_bc 是边界点个数。最终总损失是L λ_pde × L_pde λ_ic × L_ic λ_bc × L_bc默认情况下三个权重都可以取 1。但在实际训练中你会发现这三项的量级经常不一致PDE残差可能天然比初始条件项大不少或者反过来。权重是用来控制三块约束的优先级和训练平衡的。后面第四节我会详细说怎么调。采样点的选择也很关键。内部配置点一般用随机采样或拉丁超立方采样覆盖整个求解区域初始点固定 t0x 在 [0,1] 里随机取边界点固定 x0 或 x1t 在 [0,1] 里随机取。一个常见的数量配比是内部点 10000 个初始点 512 个边界点 256 个。3. 完整代码实现从网络结构到训练循环3.1 环境与网络结构定义我用PyTorch来写。选择PyTorch没有别的原因主要是它的自动微分在科学计算场景里用起来最顺手而且社区里大部分PINN开源代码也都是PyTorch实现的。先定义一个标准的全连接网络。输入是 (x, t)输出是预测温度 û。网络结构我取4个隐藏层每层50个神经元激活函数用tanh。这个规模对一维热传导问题来说是足够的没必要堆更多层。import torch import torch.nn as nn import numpy as np import matplotlib.pyplot as plt torch.manual_seed(42) alpha 0.5 # 热扩散系数 pi np.pi class PINN(nn.Module): def __init__(self, in_dim2, hidden_dim50, num_layers4): super().__init__() layers [nn.Linear(in_dim, hidden_dim), nn.Tanh()] for _ in range(num_layers - 1): layers.append(nn.Linear(hidden_dim, hidden_dim)) layers.append(nn.Tanh()) layers.append(nn.Linear(hidden_dim, 1)) self.net nn.Sequential(*layers) def forward(self, x, t): # x, t 都是 [N, 1] 的张量 xt torch.cat([x, t], dim1) return self.net(xt) def init_weights(m): if isinstance(m, nn.Linear): nn.init.xavier_normal_(m.weight) nn.init.zeros_(m.bias) model PINN() model.apply(init_weights)初始化这里值得多说一句。我习惯用Xavier正态初始化它对tanh激活函数很友好。如果直接用PyTorch默认的初始化也不是不能训但收敛速度明显差一些尤其在损失函数包含二阶导数项之后好的初始化能让训练稳定不少。3.2 采样点生成策略采样点负责“把物理约束铺满整个求解区域”。内部配置点需要覆盖 x ∈ [0,1]、t ∈ [0,1] 的二维区域初始点集中在 t0 这条线上边界点集中在 x0 和 x1 这两条竖线上。我习惯在每一轮迭代里重新随机采样而不是固定同一批点。这样相当于每步都稍微换一批“考试题目”能避免网络在固定点上过拟合也能更充分地覆盖整个区域。N_pde 10000 N_ic 512 N_bc 256 def sample_points(): # 内部配置点 x_pde torch.rand(N_pde, 1, requires_gradTrue) t_pde torch.rand(N_pde, 1, requires_gradTrue) # 初始条件点t 0 x_ic torch.rand(N_ic, 1, requires_gradTrue) t_ic torch.zeros(N_ic, 1, requires_gradTrue) # 边界条件点x 0 和 x 1 x_bc0 torch.zeros(N_bc, 1, requires_gradTrue) x_bc1 torch.ones(N_bc, 1, requires_gradTrue) t_bc torch.rand(N_bc, 1, requires_gradTrue) return (x_pde, t_pde), (x_ic, t_ic), (x_bc0, x_bc1, t_bc)这里设置 requires_gradTrue 是必须的否则自动微分无法对 x 和 t 求导。一个很容易踩的坑是你把 x 和 t 当成普通的输入扔给网络然后用 torch.autograd.grad 去求导结果却发现梯度全是 None。原因多半就是没有设置 requires_gradTrue。3.3 自动微分与损失计算在PINN里损失函数的计算不是简单的“网络输出和标签比较”而是要计算网络输出对输入坐标的导数。具体到这个例子需要算 u_t 和 u_xx。这里用 torch.autograd.grad 来实现。计算 u_xx 时先求出 u_x再对 u_x 求一次导注意第二次求导要设置 create_graphTrue。原因在于我们最终要用 loss.backward() 来更新网络参数而 loss 里包含了对网络输出导数这个中间量。只有 create_graphTrue 时自动微分过程本身才会保留计算图让高阶导数能够继续反传到网络参数上。def pde_loss(model, x, t): # 确保 x, t 需要梯度 x.requires_grad_(True) t.requires_grad_(True) u model(x, t) u_t torch.autograd.grad(u, t, grad_outputstorch.ones_like(u), create_graphTrue)[0] 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] residual u_t - alpha * u_xx return torch.mean(residual ** 2)可能有人会问为什么不用 torch.autograd.grad 直接对 u 求二阶导而是先求 u_x 再对 u_x 求导其实直接指定 grad_outputs 对输入求二阶导也是可以的但上面这种写法更直观、更不容易出错。尤其是当你以后要换更复杂的方程比如带 u·u_x 这种非线性项时逐阶求导再组合的思路会更好扩展。三部分损失合在一起就是这样def compute_loss(model): (x_pde, t_pde), (x_ic, t_ic), (x_bc0, x_bc1, t_bc) sample_points() # 初始条件损失 u_ic model(x_ic, t_ic) loss_ic torch.mean((u_ic - torch.sin(pi * x_ic)) ** 2) # 边界条件损失 u_bc0 model(x_bc0, t_bc) u_bc1 model(x_bc1, t_bc) loss_bc torch.mean(u_bc0 ** 2) torch.mean(u_bc1 ** 2) # PDE残差损失 loss_pde pde_loss(model, x_pde, t_pde) loss loss_pde loss_ic loss_bc return loss, loss_pde, loss_ic, loss_bc这里默认三个权重都是 1先跑起来看情况再调。如果你观察曲线发现某一项一直降不下去再回头调整权重。3.4 训练循环与监控指标训练用Adam优化器初始学习率 1e-3训练20000步。Adam对PINN这类多目标损失来说是很稳妥的选择它比SGD更容易收敛同时对学习率不那么敏感。训练过程中我习惯每2000步打印一次三个子损失这样能直观地看到每块约束是否都在下降。optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max20000) for it in range(20000 1): loss, loss_pde, loss_ic, loss_bc compute_loss(model) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() if it % 2000 0: print(fiter {it:6d} pde {loss_pde.item():.3e} fic {loss_ic.item():.3e} bc {loss_bc.item():.3e} ftotal {loss.item():.3e})关于学习率调度器有人用有人不用。我的经验是先不加也能训但加了一个余弦退火之后后期pde残差的震荡会小很多。原因很简单训练前期需要大学习率快速把初值边界条件拉下来后期需要小学习率去精细地拟合PDE残差。如果你后续做更复杂的方程这个技巧会更明显。3.5 完整可运行代码把以上代码组装起来就是一个可以直接跑起来的最小实现。为了减少篇幅我这里不再重复粘贴完整代码只补充一个测试和可视化的部分。你把这一段接着上面的训练循环后面跑即可。# 测试固定某个时刻看看预测与解析解是否重合 def evaluate(model, t_val0.2): x_test torch.linspace(0, 1, 200).view(-1, 1) t_test torch.full_like(x_test, t_val) u_pred model(x_test, t_test).detach().numpy() u_exact np.exp(-alpha * np.pi**2 * t_val) * np.sin(np.pi * x_test.numpy()) err np.abs(u_pred - u_exact) l2_rel np.linalg.norm(err) / (np.linalg.norm(u_exact) 1e-8) return x_test.numpy(), u_pred, u_exact, err, l2_rel x_test, u_pred, u_exact, err, l2_rel evaluate(model, t_val0.2) print(ft0.2 最大绝对误差: {err.max():.3e}, 相对L2误差: {l2_rel:.3e}) plt.figure(figsize(8, 4)) plt.plot(x_test, u_exact, k-, labelexact) plt.plot(x_test, u_pred, r--, labelPINN) plt.xlabel(x) plt.ylabel(u) plt.legend() plt.title(t 0.2) plt.show()如果你能看到两条曲线几乎重合说明这个PINN已经基本学会了这个方程的解。如果没有先别急着调网络结构去检查训练日志看看是哪个子损失没有降下来。4. 结果分析训练曲线、误差评估与调参排查4.1 训练过程曲线怎么读训练过程中三块子损失的下降节奏往往并不相同。在我实际跑这个例子的经验里初始条件损失和边界条件损失通常会先降下来因为它们直接约束网络输出的数值大小PDE残差损失通常会慢半拍因为它要求的是网络输出在“任意内部点”都满足偏微分关系这比单纯拟合一个初值函数要难得多。正常情况下的曲线大概是这样的前几千步ic和bc损失从 1e-1 量级快速降到 1e-3 量级pde损失从 1e-1 缓慢降到 1e-3 或更低。走到10000步以后三个损失会同步缓慢下降偶尔出现小幅震荡。震荡不一定是坏事但如果震荡幅度太大pde损失反复反弹就要考虑降低学习率或者调整损失权重。最需要警惕的是两种异常情况。 第一种是pde损失一直卡在某个值不动而ic和bc已经降得很低了。这说明网络更倾向于“记住”初值和边界但没有真正学会区域内部的物理规律。处理方法可以是增大N_pde或者把pde损失的权重调大。 第二种是三个损失都降得不错但测试误差反而很大。这通常意味着过拟合到了采样点而没有泛化到整个连续区域。遇到这种情况我会重新采样更多点或者增加L2正则化。4.2 测试阶段与解析解对比训练结束后光看损失数字是不够的必须拿解析解来验证。我用测试代码里计算出来的相对L2误差来量化精度。在我固定的随机种子和训练参数下20000步训练后t0.2时刻的相对L2误差通常在 1e-3 到 1e-2 量级这已经足够展示PINN的可行性了。更完整的评估方式是画一张误差云图。横轴x纵轴t颜色表示预测值与解析解之间的绝对误差。好的结果应该是除了边界附近极少数小斑点外整个色块颜色都很浅。如果出现一整片区域的明显色差说明那一带的PDE约束没有学好需要重点检查配置点覆盖是否均匀。还有一个小技巧不要只在训练结束时测一次。我习惯在训练中途比如5000步、10000步、15000步各测一次把误差记录下来。这样你能看到误差随训练轮数的变化趋势也能判断模型到底是在收敛还是已经“训过头”。4.3 常见问题速查表这里把PINN入门阶段最容易踩的坑整理成一张速查表按“现象—原因—解决”来排。每一项我都实际遇到过或者帮别人排查过。现象可能原因解决思路总loss下降但pde损失不降网络表达不足、配置点太少、学习率过低增加隐藏层宽度增大N_pde适当调大初始学习率初始条件一直拟合不准loss_ic权重太低或采样点太少调大λ_ic增加N_ic边界条件拟合得很差边界点采样不足增加N_bc或对边界点做密集采样训练震荡剧烈loss反复跳学习率过大、损失权重失衡降低学习率使用余弦退火调整权重完全训不动梯度爆炸输入未归一化、初始化不合适把x和t缩放到[0,1]改用Xavier初始化必要时梯度裁剪PDE损失能降但预测精度差在固定采样点上过拟合每轮重新采样或增大采样点数量这里面最容易被忽视的是“输入归一化”。很多人以为 x ∈ [0,1]、t ∈ [0,1] 已经很小了不需要再归一化但其实没问题。真正需要警惕的是当 x 和 t 量纲差异很大时比如 x 是0到1t 是0到1000网络会很难学。遇到这种问题把时间缩放到 [0,1] 往往立竿见影。4.4 我的调参心得与实战建议最后聊一些多次跑实验之后攒下的体会。第一损失权重比网络结构更值得先调。很多新手一遇到效果不好就想把网络加宽加深但PINN这种多目标优化往往问题出在三个损失的比例上。pde残差损失天然和方程系数有关α越大残差数值可能越大如果不做权重平衡网络会优先减小量大的那一项。我的习惯是先全部设成1跑一版观察各项下降速度再动态调整。第二采样点比“层数”更重要。PINN的拟合能力来自网络参数但物理约束的覆盖度来自采样点。同样一个网络配置点从1000增加到10000精度往往能提升一个量级而把网络从4层加到8层精度提升可能微乎其微训练时间却翻了几倍。所以先加采样点再加网络深度。第三多阶段训练是一个有效技巧。有时候我会先把初始条件和边界条件单独训几百步让网络先把边界形状“记住”再加入PDE残差损失做联合训练。这样能避免一开始多个目标互相干扰尤其适合更复杂的方程。不过对于一维热传导这种简单问题直接联合训练也没问题。第四测试误差比训练损失更值得信任。PINN的训练损失只能说明“在采样点上满足物理约束的程度”不代表真实泛化能力。所以我每次跑完都会算一遍相对L2误差如果这个数字没有随着训练下降就说明模型在试图作弊而不是在学习一个光滑的连续函数。关于后续扩展这个例子还能继续走很多方向。比如把 α 从固定常数改成待训练参数就可以把正问题改成逆问题通过观测数据反演热扩散系数把一维热传导方程换成Burgers方程就能看到PINN处理非线性和激波的潜力再把配置点替换成更高级的自适应采样策略就能进入这个方向的前沿话题。不过无论扩展得多复杂基础流程依然是这张图定义网络、组装物理损失、自动微分、优化权重、用解析解或真实数据验证。我在实际跑这个例子过程中最大的感受是PINN并不能轻轻松松替代所有传统数值方法但它提供了一种完全不同的视角——把“物理规律”嵌进深度学习模型而不是让模型盲目地从数据里挖规律。一旦你真的跑通了“从方程到代码再到结果分析”这条链路再看其他物理信息学习问题手里就已经多了一把顺手的尺子。希望这篇实战记录能让你少走几步弯路。