做CFD的人都有这种经历:想快速算一个二维平板通道的换热,网格要剖、离散格式要选、迭代参数要调,一折腾就是半天。如果只是想验证一个想法,或者想拿一个能随时调参的温度场代理模型,传统有限体积法就会显得重。这个背景让我把目光放到了软物理信息神经网络(软PINN)上,用PyTorch手写了一个针对平板间二维稳态对流传热方程的实现。整个过程核心是:把对流传热的控制方程残差和边界条件残差揉进一个损失函数,再不断训练一个MLP去逼近连续温度场。这方案适合两类读者——一类是刚接触PINN、想在传热问题上练手的同学,另一类是搞工程仿真、想做代理模型的工程师。下面从方程、架构、代码到调参,完整还原我的实操过程。
1. 项目背景与核心挑战
1.1 平板间稳态对流传热到底在算什么
想象两个平行平板之间的矩形通道,下板恒温、上板恒温,入口处流体温度比壁面低,流体从入口流向出口。稳态下温度场只随位置变化,不随时间变化。这种结构是很多工程问题的物理原型:换热器板束通道、电子芯片间的冷却流道、热沉上的平板式流道。求解目标就是获得整个二维域内的温度分布 T(x,y),以及由此导出的壁面热流、出口平均温度这类工程指标。
对于常物性、低速流动,能量方程可以简化为对流项加扩散项,也就是对流-扩散方程。通道内流动若为充分发展层流,速度场可以预先给定:x 方向速度沿 y 方向呈抛物线分布,y 方向速度为 0。这使问题从“求解速度场+温度场”退化为“在已知速度场中求解温度场”,非常适合用来做 PINN 的入门验证问题。
我这次用的是无量纲形式。定义无量纲温度 θ=(T−T_in)/(T_w−T_in),无量纲坐标 X=x/L、Y=y/H,无量纲速度 u*=u/u_avg。通过链式法则整理,控制方程变成:
Pe·u*·∂θ/∂X = ∂²θ/∂X² + (L/H)²·∂²θ/∂Y²
其中 Pe = u_avg·L/α,代表对流与扩散的强度比。Pe 越大,对流越强,温度边界层越薄。这个方程看着简单,但后面所有实现细节、采样策略、损失权重调整,本质上都是在和这个 Pe 数较劲。
1.2 为什么用软PINN而不是传统CFD或硬PINN
传统 CFD 需要生成网格,再进行离散和迭代求解。网格太粗会抹掉温度边界层,太细则计算量急剧上升。PINN 的好处是直接把物理方程写进损失函数,靠神经网络自动求导获得二阶导数,不需要网格,只需在定义域内散点采样。
那么“软”怎么理解?硬 PINN 会强行改造网络结构,让输出恒满足边界条件;软 PINN 不改造结构,只把边界条件作为罚项加进损失。这样处理的优点很直接:Dirichlet 和 Neumann 边界可以统一处理,复杂几何也能原样加入。缺点是边界条件只是“尽量满足”,必须靠调节权重来平衡。
我的平板问题属于混合边界场景:入口和壁面是固定温度,出口是零法向梯度。用硬约束去实现这种混合边界非常麻烦,出口的导数条件很难通过输出变换直接写进网络结构。所以软约束成了自然选择。代价就是后面要花大量时间调损失权重,但这比写一套有限体积离散格式还是要轻松得多。
2. 整体方案设计与数学建模
2.1 控制方程与边界条件的无量纲化处理
完整的原始能量方程是:
u(y)·∂T/∂x = α·(∂²T/∂x² + ∂²T/∂y²)
其中 α 是热扩散系数。为了数值稳定,我没有直接把原始量扔给网络,而是先做无量纲化。令 X 和 Y 分别用平板长度 L 和板间距 H 归一化,T 用入口与壁面温差归一化,速度用平均速度 u_avg 归一化。代入后整理成:
Pe·u*·∂θ/∂X = ∂²θ/∂X² + (L/H)²·∂²θ/∂Y²
这里速度分布取充分发展层流:u* = 6Y(1−Y)。几何参数设 L=1、H=0.5,所以 L/H=2。Pe 先取 20,属于对流和扩散相当、但又不至于太陡的范围。
边界条件设成工程里很常见的一组:
- 入口 X=0:θ=0,即流体入口温度
- 下壁 Y=0:θ=1,即壁面恒温
- 上壁 Y=1:θ=1,同样壁面恒温
- 出口 X=1:∂θ/∂X=0,充分发展热出口
这个边界组合有个隐患:入口角点处,入口温度 θ=0 和壁面温度 θ=1 直接冲突,θ 在角点发生跳跃。传统有限体积法一般会把这个奇点单独处理,软 PINN 里如果采样点正好落在角点附近,损失会一直压不下去。这个坑我后面会专门讲解决方式。
2.2 网络结构、激活函数与输入特征
网络结构我采用 4 层隐藏层的 MLP,每层 64 个神经元,输入是 (X,Y),输出是 θ。代码里用 Sequential 拼起来。网络本身没有任何物理结构,物理信息全部来自损失函数,这正是 PINN 最简洁的地方。
激活函数一定用 tanh。为什么?PDE 损失需要二阶导数,ReLU 的二阶导恒为 0,无法参与到 ∂²θ/∂X² 的计算。tanh 光滑、导数有界,在 PINN 里是最稳的选择。有些项目会用 Swish,但 tanh 最不容易出问题。激活函数应该是最后一步优化手段,而不是一开始就纠结的地方。
输入特征直接喂归一化后的坐标。很多教程会给输入加傅里叶特征或位置编码,但对这个温度场比较平滑的问题来说作用不大;对流占优时傅里叶特征有帮助,但会增加超参数。我建议先用归一化坐标把问题弄通,再考虑升级。权重初始化用 Xavier 均匀初始化加零偏置,实测能避免前期梯度消失。
2.3 软约束的含义与损失函数总览
软 PINN 的全部哲学都体现在损失函数里。总损失写成:
L = λ_pde·L_pde + λ_bc·L_bc
L_pde 是内部采样点上的方程残差均方误差,L_bc 是所有边界点上的边界条件残差均方误差。如果还有实验数据,就加数据项。我这个纯物理场景不需要数据。
软约束的本质是罚函数方法:不要求网络在数学上严格满足 PDE 和边界,只希望所有残差在统计意义上足够小。这样做的好处是即使边界条件有噪声或者不精确,网络不会因为强制满足而把解扭曲;缺点是损失景观复杂,不同损失的量级需要精心配平。
网上很多教程默认 λ_bc=1,但我的经验是边界损失必须以 10 倍甚至 100 倍权重起步。原因在后面调参部分展开。总之一句话:把 L_pde 和 L_bc 放进同一个 loss 里,不等于它们会被同等对待。
3. PyTorch代码实现全解析
3.1 采样:内部点与边界点的生成方法
采样决定训练数据的分布,这是 PINN 里最容易忽略的部分。内部点我用随机采样,但更推荐拉丁超立方,它能避免点簇拥在一个区域。边界点要分开生成:壁面点、入口点、出口点三组,因为它们的损失计算方式不同。
直接看代码:
import numpy as np import torch torch.manual_seed(0) np.random.seed(0) L, H = 1.0, 0.5 N_pde = 10000 N_bc = 2000 # 内部点,归一化后在 [0,1]x[0,1] 内采样 X_pde = torch.rand(N_pde, 1) Y_pde = torch.rand(N_pde, 1) # 壁面点 X_wall = torch.rand(N_bc, 1) Y_wall0 = torch.zeros_like(X_wall) # 下壁 Y=0 Y_wall1 = torch.ones_like(X_wall) # 上壁 Y=1 # 入口点 X=0 Y_inlet = torch.rand(N_bc // 2, 1) X_inlet = torch.zeros_like(Y_inlet) # 出口点 X=1 Y_outlet = torch.rand(N_bc // 2, 1) X_outlet = torch.ones_like(Y_outlet)这里有一个经验:边界点与内部点的比例控制在 1:5 到 1:10 之间。边界点太少,边界损失统计上不充分;太多,网络会拼命满足边界而忽略内部方程。我最终用的 N_pde=10000、N_bc=2000,效果比较平衡。
3.2 PDE残差与边界损失的代码细节
网络定义很常规:
import torch.nn as nn class SoftPINN(nn.Module): def __init__(self, dim_in=2, hidden=64, depth=4): super().__init__() layers = [] layers.append(nn.Linear(dim_in, hidden)) for _ in range(depth): layers.append(nn.Tanh()) layers.append(nn.Linear(hidden, hidden)) layers.append(nn.Tanh()) layers.append(nn.Linear(hidden, 1)) self.net = nn.Sequential(*layers) def forward(self, X, Y): inp = torch.cat([X, Y], dim=1) return self.net(inp)PDE 残差需要二阶梯度,所以必须用 torch.autograd.grad 连续求两次导。这里有两个坑:create_graph=True 必须在两次求导中都设置,否则二阶导数的计算图会在第一次 backward 后被截断;retain_graph=True 也要在第一次求导时设置,否则同一张计算图会在下次求导时被释放。
def compute_pde(model, X, Y, Pe, r): X = X.clone().requires_grad_(True) Y = Y.clone().requires_grad_(True) theta = model(X, Y) theta_X = torch.autograd.grad( theta, X, grad_outputs=torch.ones_like(theta), create_graph=True, retain_graph=True)[0] theta_Y = torch.autograd.grad( theta, Y, grad_outputs=torch.ones_like(theta), create_graph=True, retain_graph=True)[0] theta_XX = torch.autograd.grad( theta_X, X, grad_outputs=torch.ones_like(theta_X), create_graph=True, retain_graph=True)[0] theta_YY = torch.autograd.grad( theta_Y, Y, grad_outputs=torch.ones_like(theta_Y), create_graph=True, retain_graph=True)[0] u = 6.0 * Y * (1.0 - Y) # 无量纲充分发展速度剖面 pde = Pe * u * theta_X - (theta_XX + r * r * theta_YY) return pde边界损失里,Dirichlet 边界直接算输出和目标值的差,Neumann 出口边界需要对 X 求导:
def compute_bc(model, X_wall, Y_wall0, Y_wall1, X_inlet, Y_inlet, X_outlet, Y_outlet): theta_wall0 = model(X_wall, Y_wall0) theta_wall1 = model(X_wall, Y_wall1) loss_wall = torch.mean((theta_wall0 - 1.0) ** 2) + \ torch.mean((theta_wall1 - 1.0) ** 2) theta_inlet = model(X_inlet, Y_inlet) loss_inlet = torch.mean((theta_inlet - 0.0) ** 2) X_outlet = X_outlet.clone().requires_grad_(True) theta_outlet = model(X_outlet, Y_outlet) theta_outlet_X = torch.autograd.grad( theta_outlet, X_outlet, grad_outputs=torch.ones_like(theta_outlet), create_graph=True)[0] loss_outlet = torch.mean(theta_outlet_X ** 2) return loss_wall + loss_inlet + loss_outlet注意出口点的 X_outlet 必须在传入模型前就 requires_grad_(True),否则自动求导会对一个非叶子节点报错。我一开始在这里卡了很久。
3.3 训练循环:Adam预热与L-BFGS收尾
单纯用 Adam 训练 PINN 往往收敛慢,因为二阶导数残差对应的损失面非常崎岖。我习惯用 Adam 先跑一两千步,把参数拉到合理区域,再切换 L-BFGS 精调。L-BFGS 利用损失曲率的近似信息,在接近最优解时收敛更准,但容易陷入局部极小,所以必须先让 Adam 热身。
def compute_losses(model, X_pde, Y_pde, X_wall, Y_wall0, Y_wall1, X_inlet, Y_inlet, X_outlet, Y_outlet, Pe, r): pde = compute_pde(model, X_pde, Y_pde, Pe, r) loss_pde = torch.mean(pde ** 2) loss_bc = compute_bc(model, X_wall, Y_wall0, Y_wall1, X_inlet, Y_inlet, X_outlet, Y_outlet) return loss_pde, loss_bc # Adam 预热 model = SoftPINN() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) lambda_bc = 20.0 for step in range(2000): optimizer.zero_grad() loss_pde, loss_bc = compute_losses( model, X_pde, Y_pde, X_wall, Y_wall0, Y_wall1, X_inlet, Y_inlet, X_outlet, Y_outlet, Pe, r) loss = loss_pde + lambda_bc * loss_bc loss.backward() optimizer.step() if step % 200 == 0: print(f"step {step}, pde {loss_pde.item():.4e}, bc {loss_bc.item():.4e}") # L-BFGS 收尾 optimizer = torch.optim.LBFGS(model.parameters(), lr=0.2, max_iter=50) def closure(): optimizer.zero_grad() loss_pde, loss_bc = compute_losses( model, X_pde, Y_pde, X_wall, Y_wall0, Y_wall1, X_inlet, Y_inlet, X_outlet, Y_outlet, Pe, r) loss = loss_pde + lambda_bc * loss_bc loss.backward() return loss optimizer.step(closure)L-BFGS 的闭包函数必须返回 loss 并且内部有 loss.backward(),否则优化器不迭代。这个写法看起来有点绕,但它是 PyTorch L-BFGS 的固定用法。
4. 实操过程与调参经验
4.1 特征归一化与无量纲化的小心得
有些人直接在原始 x,y 上训练,发现自动求导出来的二阶导数量级不一致,损失权重调半天没效果。原因很简单:x 范围 [0,1],y 范围 [0,0.5],两个方向梯度尺度不同,方程里的二阶项还带着 L²/H² 的系数。把坐标全部归一化后再写方程,这些系数就显式出现在方程里,网络输入输出都在 O(1) 量级,训练稳定很多。
我用 Y=y/H 替代原始 y 后,速度函数写成 u*=6Y(1−Y),PDE 变成 Pe·u*·θ_X = θ_XX + (L/H)²·θ_YY。网络输入是归一化后的无量纲坐标,输出也是无量纲温度,数值范围天然适配 tanh 的输出范围,边界惩罚项也容易比较。
经验上,任何 PINN 题目的第一步都该是无量纲化。不做无量纲化就调权重等于盲人摸象。
4.2 损失权重配比与动态调整
λ_bc=20 是我在这个问题上的起步经验。初始损失中 PDE 残差可能上万,而边界残差只有个位数,如果不加权,优化器会拼了命把 PDE 压到几十,边界却完全被忽略。加大边界权重后,网络先满足边界,再慢慢学习内部方程。
我还会在训练中途调整权重:前 1000 步 λ_bc=20,后面 500 步降到 5,最后 500 步保持 1。这个策略在多个类似问题上都有效。也可以用自适应权重:每 500 步算一下各损失项当前量级,让权重与量级反比。但手动设置更容易复现,适合写文章或做实验对比。
如果你看到训练后期边界误差降不下去,但 PDE 误差正常,大概率是 λ_bc 太小。如果看到内部出现异常的波纹,大概率是 λ_bc 太大,边界点过多地扭曲了内部场。这两者之间的平衡点要靠实验找。
4.3 收敛判断与预测效果评估
PINN 没有传统 CFD 那样的残差收敛曲线,总损失是几个机制的相互斗争。更可靠的办法是单独打印 PDE 和边界子项,同时准备一个密集网格上的参考解。参考解可以来自高精度 CFD 或者纯导热的级数解。
我每隔 200 步算一次验证集上的 L2 相对误差:
err = torch.sqrt(torch.mean((theta_pred - theta_ref) ** 2)) rel_err = err / torch.sqrt(torch.mean(theta_ref ** 2))当 rel_err 降到 1e-3 以下,并且边界最大误差小于 1e-4,我认为模型已经收敛。实际训练里 PDE 损失可能仍然有起伏,但验证集误差稳定下降,说明学到的是物理规律而不是过拟合采样点。
对稳态问题,训练轮数和采样点数是两个关键参数。我测试过从 5000 点到 20000 点,误差下降的边际效应很明显;把采样数翻倍不如把 λ_bc 调好来得实在。
5. 常见问题与排查技巧实录
5.1 训练初期就NaN或者不收敛怎么办
NaN 大多来自学习率太大和梯度爆炸。Adam lr=1e-3 起步比较安全,如果前几百步就出现 NaN,把 lr 降到 3e-4 再试。另一个原因是 PDE 残差中的二阶导数在初始权重下可能被放得很大,出现异常峰值,可以在 backward 之前做梯度裁剪:
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)如果损失一直不下降,先检查边界损失。把 λ_bc 提高到 50,确认边界先被满足,再逐步加入 PDE 损失。这个“先边界后 PDE”的思路在前面提过,实际操作中比同时优化两个目标要可靠得多。
5.2 边界条件被淹没或者边界起毛边
边界被淹没的表现是温度场在壁面附近出现小的振荡,验证集边界误差大。这是软约束的经典问题。除了提高权重之外,还可以在靠近壁面处加密采样点。比如用双曲正切分布在 Y=0 和 Y=1 附近多取点,让网络在这些位置有足够的输入样本。
另一个隐蔽原因是角点奇异性:入口 X=0 处,入口温度 θ=0 和壁面温度 θ=1 直接冲突。无论怎么调权重,角点附近都无法同时满足两个目标。我的处理方式是让入口温度沿 Y 方向作一个极短的线性过渡:
θ_in = min(1, Y / ε)
这个修改会显著改善壁面附近的收敛性,虽然只是改变了边界条件的表达方式,并没有真正消除奇点,但对神经网络来说,连续过渡比硬跳变友好得多。
5.3 高Pe数下的边界层捕捉与自适应采样
当 Pe=100 以上,出口区域的壁面附近会出现很薄的温度边界层,均匀采样根本覆盖不到。这时候不能只靠增加采样点,因为纯随机增点效率太低。我试过每次训练 500 步后,把当前 PDE 残差最大的点挑出来,在它们周围做局部扰动,生成新样本替换掉一部分均匀样本。这个办法见效快,但要保留足够的全局点,避免整个分布被高残差区主导。
高 Pe 时也会遇到损失空间不平整的问题。我建议分阶段训练:第一阶段完全不管 PDE,只用边界损失把网络掰到一个大致符合边界的解;第二阶段再把 PDE 损失加进来。这个“课程学习”式的策略虽然没有数学上的严格保证,但实操里几乎每次都能把训练从震荡里救回来。
6. 个人体会与后续扩展
6.1 我从这次实现里得到的几点体会
软 PINN 之所以叫软,是因为它把“满足方程”从硬性要求变成了损失函数里的加权目标。这样对复杂边界条件友好,但也意味着你必须在训练流程里去平衡各个目标。我最大的体会是,PINN 项目调试的重点不是网络结构,而是采样、权重和训练协议这三件事。
如果你照着代码复现,建议先跑通小 Pe 的纯导热极限。当 Pe→0,方程退化为拉普拉斯方程,网络很容易学到平滑解。把它跑通之后,再加 Pe 项,才能真正理解对流主导时损失景观变得多崎岖。
6.2 后续扩展:反问题、多工况和参数化模型
如果你需要不同 Pe 数下的温度场,可以让网络多一个输入 Pe,一次训练得到一个连续参数化的代理模型。如果想要反演边界的局部热流,只需要把未知边界条件也作为网络参数的一部分,在损失函数里加入实测温度数据项。这是软 PINN 的天然优势:所有约束都是软项,加入数据项不需要改架构。
最后说点个人看法。很多人一提到 PINN 就想到替代 CFD,这其实是误区,至少现阶段不现实。软 PINN 真正适合的场景是:需要快速评估大量工况、需要融入不完整测量数据、或者传统网格方法难以处理的复杂几何。如果你想用 PyTorch 复现我这次的流程,建议从设定 Pe=20 开始,按照上面的代码先跑通,再加大 Pe。实际跑一遍你就会发现,把每一步的损失项都打印出来,比任何花哨的网络设计都有用。