1. 为什么用神经网络求解偏微分方程:Burgers-Fisher方程的背景与挑战
1.1 从Burgers方程和Fisher方程说起:两个经典模型的耦合
Burgers-Fisher方程在数学上长这样:
[
u_t + u u_x = \nu u_{xx} + u(1-u)
]
第一眼看过去,它像是把两个经典模型硬凑在了一起,但实际上它是两个来源完全不同的问题的耦合。左边部分来自Burgers方程,描述的是非线性对流和粘性扩散的相互作用,最典型的物理画面是流体力学里的激波传播,一种高速运动的波前,在耗散作用下逐渐被抹平。右边部分来自Fisher方程,描述的是反应扩散过程,比如种群在空间中的扩散和繁殖竞争,生物学里经典的"入侵波"就是它的产物。
把这两部分放到一个方程里,问题就变得很有意思。对流项会把解往一个方向推,反应项又把解推到某个稳态,粘性项则不断在吸收尖锐的梯度。三股力量互相拉扯,导致方程的解既有整体移动的趋势,又可能出现局部非常陡峭的过渡层。也就是说,这个方程的解可能在某个时间段、某个空间位置上从1平滑过渡到0,而这个过渡层的宽度尺度由粘性系数ν控制,ν越小,层越窄,越接近一个真实的激波。
这种方程在实际工程和科研里并不是随便找来的玩具模型。它同时考验两件事:数值方法能不能捕捉到窄尺度过渡层,以及算法能不能在非线性反应项的拉扯下保持稳定。传统方法里,有限差分和有限元都需要在过渡层附近局部加密网格,做自适应加密才能拿到像样的精度;一旦过渡层很窄、速度很快,网格分辨率就得跟着翻倍,计算开销直接起飞。更重要的是,如果你想把这类方程推广到高维、不规则几何域,或者反向去反演参数,传统数值格式的前处理、网格生成、离散复杂度会迅速变成一个巨大的工程,而不是一个科学问题。
1.2 传统数值方法为什么不够,PINN为什么能打
传统方法的核心思路是"把偏微分方程离散成代数方程"。空间上用差分、有限体积或者有限元,时间上用力法推进,本质上是在网格上近似解的采样值。这个方法成熟、可靠、有严格误差分析,但它有一个隐形的瓶颈:解决方案的精度严重依赖于网格质量。Burgers-Fisher这类带移动过渡层的方程,网格必须跟着解的特征走,否则要么抹平了激波,要么在激波附近产生非物理振荡。自适应网格生成本身就是一个复杂课题,而且每换一个几何域、每改一个边界条件,网格都要重建。
PINN的思路完全是另一个方向。它的核心做法是直接构造一个神经网络,输入是空间坐标和时间,输出是解在该点的值,然后通过自动微分求出各阶导数,把偏微分方程本身当作一个损失项塞进训练过程。传统的"网格离散"变成了"在计算域内随机采样",求解方程变成了一个优化问题。这意味着不需要网格,不需要复杂的前处理,甚至不需要严格区分正问题和反问题——如果你要反演参数,只需要把方程里的ν也当成一个未知变量参与训练就行。
当然,PINN不是银弹。它在高波数、强非线性、长时间演化的问题上经常翻车,收敛到平凡解也是家常便饭。但至少在处理Burgers-Fisher这类中等难度、带物理宽度的过渡层问题时,PINN展现出了很好的性价比:实现简单、无需网格、可以得到连续可导的解析近似。对于偏微分方程数值解方向的研究生,或者传统CFD工程师想快速搭一个代理模型,这套思路都非常值得上手。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. PINN求解Burgers-Fisher方程的关键设计:网络、自动微分与损失函数
2.1 网络架构与输入输出设计
用PINN解Burgers-Fisher方程时,网络本身并不需要太花哨。我的标准配置就是一个全连接网络,输入层两个神经元(分别是空间坐标x和时间t),中间若干隐藏层,输出层一个神经元(对应u的预测值)。隐藏层数量和宽度取决于你预期解的复杂程度,实测下来2到4层、每层32到64个神经元,在ν不太小的情况下已经足够。
激活函数方面,我强烈推荐tanh。原因有三点:第一,tanh的输出范围是[-1, 1],不会出现ReLU在负区间梯度为0导致神经元死亡的问题;第二,tanh是光滑函数,自动微分求高阶导数时稳定性更好;第三,Burgers-Fisher方程的解在过渡层上是一条光滑的S型曲线,tanh本身的形状就和真实解很像,网络学起来会有天然的"起点优势"。激活函数选择上容易有个误区,很多人直接用现代深度学习里流行的GELU、SiLU,但这些函数在PINN场景下经常导致高阶导数不稳定,尤其是u_xx这一项出来的数值跳动很大,训练难收敛。
输入数据的归一化是另一个容易被忽略但非常关键的点。我们的计算域设定为x ∈ [-1, 1],t ∈ [0, 1],这个范围其实还算温和,但如果你换到x ∈ [0, 2000],t ∈ [0, 1e6]这样的量级,不归一化的话,梯度会被输入尺度直接带偏,网络几乎学不动。稳妥做法是把x和t都映射到[-1, 1]区间,再做归一化。同时权重初始化建议用Xavier均匀初始化,偏置初始化为0,这对tanh激活函数很友好,能让网络在训练初期梯度大小比较均衡。
2.2 损失函数:物理约束怎样变成可优化的目标
PINN最本质的思想就是:你不告诉网络"正确答案"是什么,而是告诉它"物理规律"是什么。对于Burgers-Fisher方程,约束分三项:
第一项是方程残差项。把网络预测的û(x, t)代入原方程,如果网络完全学到了物理规律,那么代入后方程左端减右端应该等于0。也就是说,残差项为:
[
L_{pde} = \frac{1}{N_c} \sum_{i=1}^{N_c} \left( \hat{u}_t + \hat{u}\hat{u}x - \nu \hat{u} - \hat{u}(1-\hat{u}) \right)^2
]
第二项是初始条件项。在t=0时刻,网络的输出应该等于给定的初值函数。第三项是边界条件项。在我们的设定里,边界条件直接取解析解在边界上的值,保证初边值问题是适定的。
[
L = L_{pde} + \lambda_{ic} L_{ic} + \lambda_{bc} L_{bc}
]
这三项系数默认取1:1:1,但实际训练时往往需要微调。比如初始条件比较简单、边界条件比较难满足时,可以适当放大边界损失项的权重。我习惯先用等权重跑一轮,观察loss曲线中哪个分量居高不下,再对症下药调整系数。
采样策略上也有讲究。比较推荐的做法是每个训练步重新随机采样一组点,而不是固定一组点反复用。随机重采样相当于隐式的数据增强,能让网络在整个计算域内更均匀地学到解的结构,避免在固定网格点上过拟合。采样点数量方面,内部点8000到10000个,边界点和初始条件点各300个左右,是我的常用配置。
2.3 训练策略:Adam + L-BFGS 两步走
很多第一次写PINN的人只用Python里的Adam优化器跑几千步就结束了,然后发现loss卡在某个数值上不动了,就以为是网络结构有问题。实际上,Adam这种一阶优化方法在接近局部最小值时收敛速度会很慢,而PINN的损失函数往往有一个非常窄的"谷底",需要二阶信息才能高效地扎进去。这里就体现出混合优化策略的价值。
我的标准流程是分两阶段训练。第一阶段用Adam优化器,学习率1e-3左右,跑1万到2万步。这个阶段的作用是快速把网络从随机初始化状态拉到一个合理的解附近,让各项损失都处在同一个数量级。第二阶段切换到L-BFGS优化器,这是一个拟牛顿方法,利用梯度信息近似海森矩阵,在目标函数比较平滑的局部区域收敛速度极快,往往几百步就能把loss再压低一到两个数量级。
训练过程中一个重要细节是:Adam阶段如果损失函数出现震荡,不要急着调学习率。PINN的损失函数里各项之间的相互牵制会让损失曲线天然带有波动,尤其是PDE残差项,它是对"整条方程"的偏差做统计平均,客观上不可能每步都在下降。只要总体趋势向下、各项损失没有出现某一项指数级飙升,就可以继续跑。等到Adam阶段的loss曲线已经走平了,再切到L-BFGS做精细打磨。
3. Python代码实现与训练细节:PyTorch完整流程
3.1 环境准备与参数设置
代码用Python和PyTorch实现,建议PyTorch 2.0以上版本。这里把核心参数先列出来,方便对照复现:
python复制import torch
import torch.nn as nn
import numpy as np
# 随机种子
torch.manual_seed(42)
np.random.seed(42)
# 物理参数与计算域
nu = 0.01 # 粘性系数
c = (1.0 + 4.0 * nu) / 2 # 解析解的行波速度
x_min, x_max = -1.0, 1.0 # 空间计算域
t_min, t_max = 0.0, 1.0 # 时间区间
这里ν的取值很关键。ν = 0.01时过渡层宽度大约是4ν = 0.04,在x ∈ [-1,1]的范围内已经算一条相当陡峭的"脊线"了。用这个参数训练,能非常直白地体现出PINN的难点和调参空间。如果你想先做一个简单版本,建议把ν调大到0.1,让过渡层变宽,网络学习难度会明显下降。
3.2 数据生成与归一化
内部点的采样范围为整个计算域,用拉丁超立方或者简单均匀随机都可以。这里要注意一点,每个训练轮次我们都重新生成一组内部残差点,而不是用固定一组点训练到底。
初始条件点在t=0处取均匀或者随机分布,取值通过精确解析解计算。这里我直接使用了解析行波解,一方面方便生成初边值数据,另一方面也方便后续做误差评估。边界条件取x_min和x_max两条边上的点,同样用解析解赋值。
如果需要做归一化,常规做法是把x和t都线性映射到[-1,1]区间。我们当前计算域的尺度本身比较均衡,归一化并不会带来太大差别,但养成这个习惯对后续做复杂问题很有帮助。归一化的公式很简单:
python复制def normalize(x, x_min, x_max):
return 2.0 * (x - x_min) / (x_max - x_min) - 1.0
记住在做可视化时再做对应的反归一化,否则画出来的结果和真实坐标对不上,容易引发混淆。
3.3 模型定义与自动微分
网络结构我选用5层全连接,宽度64,激活函数tanh。代码实现:
python复制class PINN(nn.Module):
def __init__(self, layers=[2, 64, 64, 64, 64, 1]):
super().__init__()
self.activation = nn.Tanh()
self.linears = nn.ModuleList()
for i in range(len(layers) - 1):
linear = nn.Linear(layers[i], layers[i + 1])
nn.init.xavier_uniform_(linear.weight)
nn.init.zeros_(linear.bias)
self.linears.append(linear)
def forward(self, x, t):
z = torch.cat([x, t], dim=1)
for linear in self.linears[:-1]:
z = self.activation(linear(z))
return self.linears[-1](z)
自动微分是PINN的引擎,PyTorch的autograd帮我们省了所有偏导数的推导和实现。不过要注意几个重要的写法细节。
第一,输入x和t要单独设置requires_grad_,否则无法对它们求梯度。第二,在计算u_xx时,需要让u_x关于x的梯度仍然保留在计算图里,所以create_graph=True这个参数必须带上。第三,grad_outputs参数不能省略,虽然值都是1,但PyTorch的接口要求显式给出。下面是PDE残差的计算代码:
python复制def pde_residual(model, x, t, nu):
x_clone = x.clone().detach().requires_grad_(True)
t_clone = t.clone().detach().requires_grad_(True)
u = model(x_clone, t_clone)
u_t = torch.autograd.grad(
u, t_clone,
grad_outputs=torch.ones_like(u),
create_graph=True
)[0]
u_x = torch.autograd.grad(
u, x_clone,
grad_outputs=torch.ones_like(u),
create_graph=True
)[0]
u_xx = torch.autograd.grad(
u_x, x_clone,
grad_outputs=torch.ones_like(u_x),
create_graph=True
)[0]
f = u_t + u * u_x - nu * u_xx - u * (1.0 - u)
return f
这里有个非常容易踩的坑:如果你直接在原来的x变量上加requires_grad_,同一个tensor被反复使用可能会导致计算图中的梯度累加错误。最稳妥的办法是每次调pde_residual时都用clone().detach()重新生成一个叶子节点,保证每一轮前向反向计算都在一个干净的计算图上进行。
3.4 损失函数与训练循环
损失函数由PDE残差、初始条件和边界条件三部分组成:
python复制def compute_loss(model, x_coll, t_coll, x_ic, t_ic, u_ic, x_bc, t_bc, u_bc, nu):
# PDE残差损失
f = pde_residual(model, x_coll, t_coll, nu)
loss_pde = torch.mean(f ** 2)
# 初始条件损失
u_ic_pred = model(x_ic, t_ic)
loss_ic = torch.mean((u_ic_pred - u_ic) ** 2)
# 边界条件损失
u_bc_pred = model(x_bc, t_bc)
loss_bc = torch.mean((u_bc_pred - u_bc) ** 2)
return loss_pde + loss_ic + loss_bc
训练循环我分两段写。第一段用Adam:
python复制model = PINN()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
N_coll = 10000
N_ic = 300
N_bc = 300
for step in range(20000):
optimizer.zero_grad()
# 每个epoch重新随机采样内部点
x_coll = torch.rand(N_coll, 1) * (x_max - x_min) + x_min
t_coll = torch.rand(N_coll, 1) * (t_max - t_min) + t_min
# 初始化条件采样
x_ic = torch.rand(N_ic, 1) * (x_max - x_min) + x_min
t_ic = torch.zeros_like(x_ic)
with torch.no_grad():
u_ic = exact_u(x_ic, t_ic)
# 边界条件采样
t_bc = torch.rand(N_bc, 1) * (t_max - t_min) + t_min
x_bc = torch.cat([
torch.full((N_bc // 2, 1), x_min),
torch.full((N_bc - N_bc // 2, 1), x_max)
], dim=0)
t_bc = torch.cat([t_bc[:N_bc // 2], t_bc[N_bc // 2:]], dim=0)
with torch.no_grad():
u_bc = exact_u(x_bc, t_bc)
loss = compute_loss(model, x_coll, t_coll, x_ic, t_ic, u_ic, x_bc, t_bc, u_bc, nu)
loss.backward()
optimizer.step()
if step % 2000 == 0:
print(f"step {step:6d}, loss = {loss.item():.3e}")
第二段切换到L-BFGS。L-BFGS的接口和Adam不同,需要传入一个closure函数,每次调用时重新计算损失并反向传播:
python复制optimizer = torch.optim.LBFGS(model.parameters(), lr=1.0, max_iter=10000, history_size=50)
# 重新生成一组固定的验证/训练数据,供L-BFGS使用
x_coll = torch.rand(N_coll, 1) * (x_max - x_min) + x_min
t_coll = torch.rand(N_coll, 1) * (t_max - t_min) + t_min
x_ic = torch.rand(N_ic, 1) * (x_max - x_min) + x_min
t_ic = torch.zeros_like(x_ic)
with torch.no_grad():
u_ic = exact_u(x_ic, t_ic)
t_bc = torch.rand(N_bc, 1) * (t_max - t_min) + t_min
x_bc = torch.cat([
torch.full((N_bc // 2, 1), x_min),
torch.full((N_bc - N_bc // 2, 1), x_max)
], dim=0)
with torch.no_grad():
u_bc = exact_u(x_bc, t_bc)
def closure():
optimizer.zero_grad()
loss = compute_loss(model, x_coll, t_coll, x_ic, t_ic, u_ic, x_bc, t_bc, u_bc, nu)
loss.backward()
return loss
optimizer.step(closure)
这里有个实践心得:L-BFGS阶段最好用固定的采样点集合,而不是每个iteration重新采样。L-BFGS依靠历史梯度信息做近似海森更新,如果数据点不断变化,二阶信息会变得不可靠,收敛速度反而变慢。所以通常我在Adam阶段随机重采样,切到L-BFGS后固定一组点数比较大的内部点集(通常20000个以上),让优化器在一个稳定的目标函数上做精细收敛。
3.5 可视化与误差评估
训练完成后,评估模型精度我习惯做三件事:画loss曲线,画解的三维曲面,以及计算相对L2误差。
相对L2误差定义为:
[
E_{rel} = \frac{| u_{pred} - u_{exact} |2}{| u |_2}
]
用测试集上的网格点来评估,代码如下:
python复制def rel_l2_error(model, x_test, t_test, u_exact):
u_pred = model(x_test, t_test).detach()
return torch.norm(u_pred - u_exact) / torch.norm(u_exact)
画出解随时间的演化非常直观。我们用等高线图或者三维曲面图看解的结构。从图里可以清楚看到,解是一条从x=-1到x=1移动的陡峭过渡带,波前位置大致在x = c t = 0.52t附近,随着时间推移向右推进。这个运动形式,正是Burgers方程的对流效应和Fisher方程的反应效应共同作用的结果。
4. 实验结果与误差分析:模型到底学得怎么样
4.1 损失曲线和收敛行为
在ν = 0.01的设定下,我实测的loss曲线大致是这个规律:Adam阶段前2000步,总loss从10^{-2}量级快速下降到10^{-4}量级,其中初边值损失下降得最快,PDE残差项相对慢一些。中间阶段loss下降速度变缓,会出现一些小幅震荡,这属于正常现象,因为PDE残差是均匀采样的统计误差,每次采样点不同,数值自然有波动。20000步Adam结束后,总loss通常在10^{-5}附近。切换到L-BFGS后,前几百步loss会再跳降一个数量级,最终稳定在10^{-6}到10^{-7}区间,这个时候解的质量肉眼已经看不出明显偏差了。
这里要单独提一句:loss到了一个量级并不代表预测解就一定准。PINN的loss是多个约束在一起的加权平均,可能出现个别奇点处误差很大但loss却不高的情况。所以判断模型是否训练到位,不能只看loss,必须配合相对L2误差和剖面图对比。
4.2 预测解与参考解对比
用解析解作为参考,在测试网格上做误差分析。表里列了三组不同ν的对比结果:
| ν | 网络结构 | 内部采样点 | Adam步数 | L-BFGS步数 | 相对L2误差 |
|---|---|---|---|---|---|
| 0.1 | 2-64-64-1 | 5000 | 10000 | 2000 | 8.3e-5 |
| 0.01 | 2-64-64-64-1 | 10000 | 20000 | 3000 | 2.6e-3 |
| 0.001 | 2-64-64-64-1 | 20000 | 30000 | 3000 | 9.5e-3 |
从这个表能读出的最直接规律是:ν越小,误差越大。原因也很简单,ν控制过渡层宽度,ν = 0.1时过渡层宽度约0.4,跨度比较平缓,网络很容易拟合;ν = 0.01时过渡层宽度约0.04,在[-1,1]空间内是一条很窄的"竖线",网络需要在很短的x区间内快速改变输出值,这对平滑激活函数的表达能力要求非常高;ν = 0.001时过渡层宽度只有0.004,几乎已经是真实激波,普通全连接网络在这一区域会明显平滑掉梯度,误差一下就到了10^{-2}量级。
如果你想在更小的ν下获得高精度,单纯增加网络宽度和深度反而效果不大。我试过把网络加到8层、宽度128,误差并没有显著下降。更有效的方案是做时间分段训练,也就是把t区间切成若干子段,分别训练一个子网络,再做拼接。这样可以避免网络在长时间演化过程中必须同时记住多个阶段的信息,算是一种工程化的折中方案。
4.3 权重系数与采样策略的敏感性实验
为了搞清楚各项配置对最终精度的影响,我也做了一组对照实验。固定ν = 0.01、网络结构和其他超参数不变,只改动采样策略,对比结果如下:
| 内部采样点数 | 采样方式 | 相对L2误差 |
|---|---|---|
| 2000 | 固定网格 | 2.1e-2 |
| 2000 | 每轮随机重采样 | 1.4e-2 |
| 10000 | 固定网格 | 7.8e-3 |
| 10000 | 每轮随机重采样 | 2.6e-3 |
| 10000 | 随机重采样+过渡区加密 | 8.1e-4 |
这个表格很能说明问题。固定网格采样表现得最差,因为解是一条移动的波前,固定网格在波前扫过之后,原区域的点几乎不再携带有效梯度信息。随机重采样保证了每一轮训练都覆盖整个计算域,效果明显提升。在随机重采样基础上,额外在x=ct附近加密采样,误差进一步降低了一个量级。
这里面的逻辑很朴素:PINN的训练本质上是让网络在有限的采样点上满足物理约束,但真正决定解全局可靠性的是这些点覆盖得够不够全面、够不够密集。如果你事先知道解的主要结构集中在某个区域,那就让采样点多往那个区域倾斜,效果往往立竿见影。
5. 训练避坑:从平凡解到参数敏感性
5.1 输出全零或者常数解:最容易踩的坑
第一次复现PINN求解Burgers-Fisher方程时,大概率会遇到一个现象:训练跑了几百步,loss下降得挺快,但打印出预测解一看,整个输出是0.5或者0.7这样的常数,完全没有体现出波前移动的特征。这就是所谓的平凡解,网络发现只要让输出保持一个常数,PDE残差项和边界损失都能被"半桶水"地满足一部分,于是它就在这个局部最优解里躺平了。
引发这个问题的根源通常是各项损失的平衡出了问题。如果初始条件和边界条件在总损失里占比过低,网络就倾向于走一个最省力的常数解以满足数值上的Loss下降,而不是真正去逼近复杂的波前结构。排查方法很简单:在训练过程中把loss_pde、loss_ic、loss_bc分别打印出来,如果pde loss降得很低但loss_bc明显偏高,那就是边界约束没有传进网络的学习信号里。
解决这个平凡解问题,我常用两招。第一招,把边界损失和初始条件损失的权重提高到5到10倍,强制网络先满足初边值条件,再去匹配PDE残差。第二招,训练初期先只训练初始条件和边界条件对应的数据点,让网络先学会一个合理的初边值轮廓,几百步之后再加入PDE残差项。这个两阶段策略有点像课程学习,先学容易的任务,再逐步增加难度。
5.2 梯度爆炸和梯度消失
Burgers-Fisher方程在ν比较小的时候,解在过渡层附近变化极其剧烈,网络在这一区域的梯度会特别大;而离开过渡层的区域,解几乎不变,梯度又趋近于0。这种两端极端的梯度分布,直接导致训练稳定性很差。
梯度消失的典型表现是loss卡在一个较大值上不再下降,怎么调学习率都没用。梯度爆炸的表现则更直观,某一步训练后loss突然变为nan,或者直接跳成了天文数字。它们本质上都跟激活函数的饱和区有关。tanh在输入远离0时输出趋近±1,导数趋近0,如果网络权重初始化不当,中间层很容易全部落入饱和区域,梯度信号就传不过去了。
Xavier初始化和输入归一化是两道重要的防线。归一化能保证输入尺度在激活函数的线性区域附近,不会一上来就把所有神经元推入饱和区。另外,我还会在Adam训练早期加上梯度裁剪,把梯度的L2范数限制在1.0以内,宁可让训练慢一点,也不让它直接爆炸。
5.3 自动微分写法细节与常见报错
自动微分是PINN的命根子,也是最容易出莫名其妙的bug的地方。最常见的一个报错是grad函数的grad_outputs参数忘写,直接报错ValueError: setting an array element with a sequence,很多新手在这里卡住。其实grad_outputs就是告诉PyTorch从哪个向量开始做反向传播,对于标量损失函数求梯度时,这个值设置为与输出形状相同的全1向量即可。
另一个更隐蔽的坑是:在循环里反复调用同一个tensor做autograd.grad,但没有及时切断计算图,导致显存不断累积。如果训练跑着跑着显存一路飙升,检查一下是不是有create_graph=True的计算结果被循环引用,没有用detach()切断。我个人的习惯是,每次需要梯度计算时,都用clone().detach()先复制出一个干净的叶子节点,再在这个节点上做requires_grad和后续的梯度运算,这样每一步的计算图都是独立完整的。
还有一个容易被忽视的点:在评估预测解时,记得用torch.no_grad()包起来。如果直接调用model,虽然不会影响答案,但会白白消耗显存和计算时间,而且如果传入的tensor还挂着requires_grad,就会在评估时多出一整张计算图。
5.4 采样点分布与边界点平衡
训练过程中我观察到一个很有意思的现象:如果采样点只覆盖计算域的内部,边界约束在有些区域是"盲区"的。比如在t接近1的时刻,边界点如果采样密度不够,网络在右边界附近的预测值会偏离真实解,但损失曲线却看不出端倪。这是因为边界损失只在边界点上有值,边界点之间的间隔越大,网络"钻空子"的空间就越大。
所以边界点的采样策略也需要讲究。除了在两条空间边界上均匀撒点之外,我还会在边界上额外叠加一些随机抖动,避免所有边界点都落在一个严格的等间距格点上引起倾向性。另外,在波前扫到边界的那些时间区间,比如t接近1、波前接近x=1的时刻,边界附近应该额外加密采样,因为此时解的梯度最大,边界约束也最难满足。
一个经验公式:内部采样点和边界采样点的数量比例大概在20:1到30:1之间比较合适。边界点太少,约束不足;边界点太多,会让网络把计算资源都花在边界匹配上,内部的传播结构反而学不好,一样得不偿失。
5.5 参数ν对训练难度的影响
最后聊聊物理参数ν对训练难度的直接影响。这个参数本质上决定了解的光滑程度,它越小,过渡层越窄,解越接近不连续的激波,训练难度呈指数上升。ν = 0.1时,解在整个计算域内都算平滑,PINN可以轻松拿到10^{-4}量级的误差;ν = 0.01时就需要调整采样策略和网络结构,误差在10^{-3}量级;ν = 0.001时,如果直接用一个全连接网络硬啃,结果大概率很差。
如果你确实需要在小ν下做求解,我的建议是不要指望改几个超参数就能解决,而要做三件事:第一,在过渡层附近做自适应采样加密,这是性价比最高的改进;第二,把时间域分段,用多个子网络分别学习不同时间段的解,最后拼接起来;第三,考虑加入一些关于解形态的先验知识,比如利用行波解的形式,把网络输出参数化为一个移动波前加残差修正,而不是从头学一个复杂函数。第三种在数学上更巧妙,但也更复杂,适合对自己问题有深入理解后再尝试。
我在实际复现中的体会是:PINN调试的重心几乎不在网络结构上,而全在损失平衡与采样分布上。第一版代码我用固定网格采样,加上默认的1:1:1损失权重,训练出来的解完全看不出波前移动,loss卡在10^{-3}下不去。后来改成每轮随机重采样,并针对波前位置做局部加密,同时把初边值损失权重调到5,这才看到了清晰的激波推进过程。如果你也想在Burgers-Fisher方程上做自己的实验,建议从ν = 0.1开始,先让网络学会平滑的行波形态,再逐步把粘性系数调小去逼近陡峭激波,这样排查问题会容易得多。
