最近一直在复盘我用 PINN(物理信息神经网络)算一维悬臂梁挠度的完整过程。为什么选这个题目?因为悬臂梁是结构力学里最经典的问题之一,控制方程明确、边界条件清晰、解析解现成,拿来做 PINN 的“hello world”再合适不过。这篇文章不绕弯子,直接讲清三件事:怎么把“求挠度”变成“训练一个神经网络”、核心代码怎么落地、以及我踩过的坑。
项目的核心其实很简单:给定一根一端固定、一端自由的悬臂梁,受均布载荷作用,求解挠曲线 w(x)。传统做法是划分网格、组装刚度矩阵;我这里用 Python 写一个小型神经网络,输入坐标 x,输出挠度 w,训练的时候完全不喂标签数据,而是把欧拉-伯努利梁方程直接写进损失函数,约束网络输出满足物理规律。跑完之后,网络在任意位置给出的挠度与解析解最大误差能到 10⁻⁴ 量级。
这篇文章适合两类人:一类是已经会神经网络基本操作、想往“AI + 科学计算”方向迈一步的开发者;另一类是做结构分析、想了解 PINN 到底能不能用来算力学问题的工程师。下面按我实际操作的顺序,把原理、代码、坑位一次说清楚。
1. 把“悬臂梁挠度”变成一个神经网络能算的问题
1.1 控制方程与两类边界条件
一维悬臂梁的小挠度问题,采用欧拉-伯努利梁理论,控制方程为:
EI · d⁴w/dx⁴ = q(x)
其中 E 是弹性模量,I 是截面惯性矩,EI 是抗弯刚度,q(x) 是分布载荷,w(x) 是挠度。
这里先解释一下为什么是四阶导数。材料力学里三个基本关系是:弯矩 M = EI·d²w/dx²,剪力 V = EI·d³w/dx³,分布载荷 q = dV/dx = EI·d⁴w/dx⁴。也就是说,四阶导数直接对应载荷,这是整个 PINN 损失函数里最关键的一项。
边界条件分两类。固定端在 x=0:位移为零,即 w(0)=0;转角为零,即 dw/dx(0)=0。自由端在 x=L:弯矩为零,即 EI·d²w/dx²(L)=0;剪力为零,即 EI·d³w/dx³(L)=0。
有一个非常常见的错误:初学者只写位移边界 w(0)=0 和弯矩边界 EI·d²w/dx²(L)=0,把转角条件和剪力条件漏了。结果网络确实给出了一条“经过固定端、自由端弯矩为零”的光滑曲线,但固定端转角明显不为零,挠曲线的形状完全不对。这四个边界条件一个都不能少。
1.2 为什么用 PINN 而不用传统数值方法
有限元求解悬臂梁,精度很高,成熟度也很高。那为什么还要用 PINN?我的答案很直接:不是为了替代有限元,而是为了训练一个“物理约束下的可微函数”,让它能嵌入到更大的深度学习系统里。
有限元把整个梁离散成单元,需要划分网格、组装刚度矩阵。一旦结构几何发生变化、载荷分布变化,往往要重新建模。PINN 的思路完全不同:它把求解过程转成一个优化问题,函数本身用神经网络表示,物理方程作为正则项。网络训练好之后,任意给一个坐标,立刻输出挠度,而且输出是连续光滑的。
更重要的是,PINN 天然适合反问题。假设已经测得自由端挠度,想反推分布载荷 q 的大小,传统方法要做逆求解,往往困难;PINN 里只需要把 q 也设成网络的一个输入或者待优化变量,训练时用实测挠度作为标签,就能同时拟合出载荷。这类“物理约束 + 数据驱动”的范式,才是 PINN 真正的价值所在。
当然,纯粹计算一根等截面悬臂梁的挠度,PINN 在效率上完全比不上解析解或有限元。这一点先说明白,免得读者抱着“替代 ANSYS”的期待进来,结果失望。
1.3 一个自带标准答案的验证案例
为了验证 PINN 算得对不对,必须选一个有解析解的工况。设梁长 L=1 m,抗弯刚度 EI=1000 N·m²,均布载荷 q₀=1000 N/m。解析解为:
w(x) = q₀·x²·(6L² - 4Lx + x²) / (24·EI)
代入参数后,最大挠度在自由端 x=L 处:
w_max = q₀·L⁴ / (8·EI) = 1000 / 8000 = 0.125 m
这个值可以当作标准答案。训练完成后,我把 PINN 预测值和这个解析解逐点对比,就能量化误差。之所以选这组参数,是因为量级合适:挠度在 0.1 米左右,EI 和 q₀ 的比值让四阶导数量级不至于过大或过小,训练稳定性好。
2. PINN 求解的完整思路拆解
2.1 网络结构:给神经网络“配一张光滑的嘴”
我先说结论:输入层 1 个节点,4 个隐藏层,每层 50 个神经元,激活函数用 tanh,输出层 1 个节点且不带激活函数。
这个配置不是拍脑袋定的,参考了 PINN 早期论文里的经典设置,对一维问题非常够用。隐藏层再多、神经元再宽,精度并不会明显提升,反而会让优化更困难。我试过 8 层 128 神经元,训练时间翻了好几倍,精度基本没变化,最后又回到 4×50。
激活函数选 tanh 是有讲究的。ReLU 在负半轴导数为零,分段线性,二阶以上导数要么不存在要么处处为零,根本没法用来计算四阶导数。sigmoid 两端饱和,高阶导数非常容易消失,导致网络深层梯度传不回去。tanh 处处光滑,任意阶导数存在且计算稳定,是目前 PINN 最常用的激活函数。
另外一个小细节:输出层初始化要格外小心。如果输出层权重初始值太大,网络初始输出可能直接到几十的量级,边界损失瞬间爆炸。我用的是标准差 0.05 的正态分布初始化,相当于让初始预测值保持在很小的范围,然后再让物理损失逐步“拉”到真实解上。
2.2 损失函数:把物理方程变成训练目标
PINN 的核心就是损失函数。整个损失由两部分组成:PDE 残差和边界条件残差。
PDE 残差项,在梁内部采样 N 个点,对每个点计算网络输出对输入的导数,代入控制方程:
L_pde = mean( (EI·d⁴w/dx⁴ - q₀)² )
这一项的意义是:网络输出虽然在任意点都成立,但它必须满足梁的平衡方程。如果网络学出一条不符合物理规律的曲线,这一项就会很大,优化器会把它拉回来。
边界条件残差项,把四个边界条件全部写成零均值误差:
L_bc = w(0)² + (dw/dx(0))² + (EI·d²w/dx²(L))² + (EI·d³w/dx³(L))²
总损失为 L = L_pde + λ·L_bc。λ 是边界权重,我一般取 10 到 50。为什么边界权重要比 PDE 项高一些?因为边界条件只有孤立的几个点,PDE 项有两千个采样点,如果不提高边界权重,网络会优先满足大多数点的方程,边界的约束就被“淹没”了。这个思想类似罚函数法。
使用均方误差而不是绝对值误差,是因为 MSE 对误差的梯度是线性的,优化时更平滑;绝对值误差在零点不可导,容易导致损失函数在边界处振荡。
2.3 量纲处理:一个非常容易翻车的坐标归一化细节
这一节是整个实现里最容易出错的地方,必须单独拎出来说。
神经网络输入坐标时,我通常先做归一化,令 x_norm = x / L,把输入控制在 [0, 1] 区间。这一步本身没问题,但问题出在导数换算上。
如果网络学习的函数是 ŵ(x_norm),那么对真实坐标 x 求导时,每求一阶导数就要多乘一个 1/L。四阶导数的换算是:
d⁴w/dx⁴ = (1/L⁴) · d⁴ŵ / dx_norm⁴
所以损失函数里的 PDE 残差应该写成:
pde_res = EI / L⁴ · d⁴ŵ/dx_norm⁴ - q₀
而不是直接在归一化坐标下用 EI·d⁴ŵ/dx_norm⁴ - q₀。如果你漏掉 1/L⁴,相当于把抗弯刚度放大了 L⁴ 倍,网络为了平衡损失,会学出一个幅值差很远的解。我见过不止一个人卡在这个问题上:训练过程完全收敛,损失也很低,但算出来的挠度比解析解大了一万倍。
同理,边界条件里的二阶导和三阶导也要对应换算:d²w/dx² = (1/L²)·d²ŵ/dx_norm²,d³w/dx³ = (1/L³)·d³ŵ/dx_norm³。
2.4 训练策略:先 Adam 快速下降,再精细收敛
训练过程我分两个阶段。
第一阶段用 Adam 优化器,学习率 1e-3,训练 20000 到 50000 步。Adam 的好处是收敛快,对初始学习率不敏感,前几千步就能把损失从 10⁴ 量级压到 1 以下。第二阶段切换到 L-BFGS,这个优化器利用二阶曲率信息,在接近极小值点时收敛非常准确,能进一步把损失降到 1e-6 量级。
如果嫌麻烦,也可以只用 Adam,把学习率在 20000 步后手动降到 1e-4,再跑 20000 步,效果差不太多。我跑这个例子时主要用 Adam,最后误差到 1e-4 量级就够用了。
训练时采样点数量也很关键。梁内点我取 2000 个,边界点固定取 x=0 和 x=L。2000 个内点对一维问题来说足够,再多并不会带来明显收益,反而增加单步计算时间。有一点值得注意:一维问题计算量小,所以训练时直接全量使用所有采样点,不用小批量。引入 mini-batch 反而会让 PDE 残差的估计产生波动,边界约束的稳定性变差。
3. 代码实现:从采样点到误差曲线
3.1 环境准备与参数设定
我用的环境是 Python 3.9 + TensorFlow 2.10 + NumPy + Matplotlib。PyTorch 也能实现同样的功能,但 TensorFlow 的 GradientTape 写高阶导数更直观,代码量少,所以我这里用 TensorFlow 演示。
有一个非常重要的前置设定:全程使用 float64 精度。四阶导数需要嵌套四层梯度计算,数值范围被极度放大,float32 非常容易出现 NaN。所有张量在创建时我都显式指定 dtype=tf.float64。
物理参数直接定义成全局常量:
L = 1.0 # 梁长,单位 m EI = 1000.0 # 抗弯刚度,单位 N·m² q0 = 1000.0 # 均布载荷,单位 N/m3.2 生成训练数据和搭建网络
采样分两部分:梁内点用均匀随机采样,让网络在 [0, L] 范围内都受到 PDE 约束;边界点直接固定为两个端点。
import numpy as np import tensorflow as tf N_INNER = 2000 # 内部采样点,归一化坐标下采样,范围 [0, 1] x_inner = tf.random.uniform( (N_INNER, 1), minval=0.0, maxval=1.0, dtype=tf.float64 ) # 边界点:x=0(固定端)和 x=1(自由端) x_bc = tf.constant([[0.0], [1.0]], dtype=tf.float64)这里统一使用归一化坐标,也就是 x_norm = x/L。因为 L=1,所以内部采样点直接在 [0,1] 里取即可。如果 L 不是 1,一定要记得在输入网络之前先除以 L。
网络用 Keras Sequential 搭建。四个隐藏层各 50 个神经元,激活函数 tanh,输出层线性:
def build_model(): model = tf.keras.Sequential([ tf.keras.layers.Dense(50, activation='tanh', dtype=tf.float64), tf.keras.layers.Dense(50, activation='tanh', dtype=tf.float64), tf.keras.layers.Dense(50, activation='tanh', dtype=tf.float64), tf.keras.layers.Dense(50, activation='tanh', dtype=tf.float64), tf.keras.layers.Dense(1, dtype=tf.float64) ]) return model model = build_model()输出层不指定激活函数,也就是线性输出。这里每个 Dense 层都声明了 float64,确保整个计算图保持高精度。如果不加这个声明,输入虽然是 float64,但某些内置层可能会把张量转回 float32,计算四阶导数时容易出现精度问题。
3.3 损失函数与训练循环的核心代码
计算损失时,需要把内部点和边界点拼在一起,一次性算出所有点的导数,再分别提取对应位置的导数组成损失项。这样只用一次前向传播,效率更高。
def pinn_loss(model, x_inner, x_bc): # 拼接全部采样点 x_all = tf.concat([x_inner, x_bc], axis=0) # 嵌套四层 GradientTape 求 d4w/dx4 with tf.GradientTape() as t1: with tf.GradientTape() as t2: with tf.GradientTape() as t3: with tf.GradientTape() as t4: t4.watch(x_all) w = model(x_all) dw = t4.gradient(w, x_all) d2w = t3.gradient(dw, x_all) d3w = t2.gradient(d2w, x_all) d4w = t1.gradient(d3w, x_all) # PDE 残差:注意归一化坐标下的导数换算 d4w_inner = d4w[:N_INNER] pde_res = EI / L**4 * d4w_inner - q0 loss_pde = tf.reduce_mean(tf.square(pde_res)) # 边界条件残差 w_0 = w[N_INNER] dw_0 = dw[N_INNER] / L w_L = w[N_INNER + 1] d2w_L = d2w[N_INNER + 1] / L**2 d3w_L = d3w[N_INNER + 1] / L**3 loss_bc = ( tf.square(w_0) + tf.square(dw_0) + tf.square(EI * d2w_L) + tf.square(EI * d3w_L) ) return loss_pde + 20.0 * loss_bc代码里有两个关键点。
第一个是嵌套梯度。d4w 需要连续四层 GradientTape,每一层记录上一层的导数结果,然后对归一化坐标再求一次梯度。顺序不能乱,最内层求 w 对 x 的一阶导,最外层求三阶导对 x 的导数,得到四阶导。这种写法是 TensorFlow 官方推荐的高阶导数计算方式,比在同一个 tape 上反复调用 gradient 更稳定。
第二个是导数换算。dw_0 除以 L,d2w_L 除以 L²,d3w_L 除以 L³,pde_res 里的四阶导数除以 L⁴。这些系数是物理单位换算,不是人为调参。
训练循环代码如下:
optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3) @tf.function def train_step(): with tf.GradientTape() as tape: loss = pinn_loss(model, x_inner, x_bc) grads = tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables)) return loss for epoch in range(30000): loss = train_step() if epoch % 2000 == 0: print("epoch:", epoch, "loss:", float(loss.numpy()))加 @tf.function 可以让 TensorFlow 把整个训练步骤编译成计算图,显著加速。一维问题哪怕不加也能跑,只是慢一些。
训练过程中我观察到损失收敛速度大致是这样:前 500 步损失从 1e4 量级快速降到 1 左右;5000 步后降到 1e-3;20000 步后稳定在 1e-5 到 1e-6 之间。如果看到 loss 在 1e-3 附近震荡不下降,可以等训练结束后把学习率调到 1e-4 再跑一轮。
3.4 结果对比:和解析解对表
训练完成后,在 [0, 1] 上取 100 个均匀点,输入网络得到预测值,同时用解析解公式算出理论值。对比结果如下:
| 位置 x (m) | PINN 预测挠度 (m) | 解析解挠度 (m) | 绝对误差 (m) |
|---|---|---|---|
| 0 | 0.0000 | 0.0000 | ~0 |
| 0.25 | 0.0134 | 0.0132 | 2e-4 |
| 0.50 | 0.0441 | 0.0443 | -2e-4 |
| 0.75 | 0.0837 | 0.0835 | 2e-4 |
| 1.00 | 0.1252 | 0.1250 | 2e-4 |
这是我的实测记录,不同随机种子会有一定波动,但整体误差稳定在 1e-4 量级。
注意到误差在自由端附近略大。原因很简单:自由端同时要满足弯矩为零和剪力为零两个边界条件,约束比固定端更强,网络拟合难度更高。这个现象是正常的。
画图的时候,预测值和解析解两条曲线会几乎完全重合,肉眼看不出差别。更有用的是画误差曲线,也就是预测值减解析解的残差沿 x 的分布。如果残差呈现高频振荡状,说明网络容量不够或者隐藏层神经元数量太少;如果残差在两端明显偏大,说明边界权重还不够高。
4. 踩坑实录:这五个问题我被问过无数次
4.1 损失变成 NaN
这是 PINN 新手遇到最多的一个问题,大概率出现在前三步训练。原因通常是三个:精度不足、学习率过大、网络初始化太激进。
排查方法也按这三个方向走。首先把整个网络改成 float64,包括输入张量和所有 Dense 层的 dtype;其次把学习率从 1e-3 降到 1e-4;最后把边界权重从 20 降到 1 试试。如果这些都做了仍然 NaN,尝试把隐藏层从 50 个神经元降到 30 个。四阶导数的计算图比一阶导数深得多,数值范围极其敏感,网络越宽越容易出问题。
还有一个我自己的经验:如果把输入坐标归一化到 [-1, 1] 而不是 [0, 1],损失下降会更平缓,NaN 概率也会小一些。这算是隐藏技巧。
4.2 边界条件怎么都压不下去
训练完发现固定端位移不是严格的零,而是 0.002 甚至更大。这个问题的根源几乎永远是边界约束权重不够,或者边界点数量不够。
解决方案有两种。第一种最简单:把 x=0 和 x=L 各复制 20 份拼到采样点里,相当于人为增加边界点的“密度”,让优化器更重视这些点。第二种更彻底:使用硬约束编码,把网络输出修改成 w_pred = x_norm² · NN(x_norm),这样 x=0 时 w=0 自动满足,而且 dw/dx=0 也自动满足,固定端两个边界条件直接从软约束变成了硬约束。
硬约束编码的缺点是:x² 因子在 0 附近抑制了网络的输出变化,网络需要更多迭代才能学习到自由端的变形,收敛会慢一些。两种方案我都试过,实际项目中我通常先加权重,效果不够再上硬约束。
4.3 算出来的挠度比理论值整体偏大或偏小
如果边界条件都满足,PDE 损失也降到了 1e-5 以下,但整条挠曲线系统性偏离解析解,那么十有八九是归一化坐标的导数换算出了漏项。
我复盘过同一个 bug:忘了在 pde_res 里除以 L⁴,损失照样收敛,因为网络完全可以通过学习一个“假刚度”来补偿这个缺失的系数,最后损失很低,解却完全错误。这不是一个会报错的 bug,而是一个静默的逻辑错误,所以特别隐蔽。
排查方法很简单:分别打印 loss_pde 和 loss_bc,观察两者量级。理想状态下,两个损失都能降到 1e-4 以下。如果 loss_pde 已经很小但解的幅值不对,就去检查导数换算公式,特别是 L 的幂次。
4.4 多物理参数训练怎么设计采样
如果你想让一个网络在多个载荷或多种刚度下都能预测挠度,而不是为每套参数重新训练,可以把物理参数也作为网络输入。输入从 (x) 变成 (x, q),输出还是 w,损失函数里的 q 直接从输入中读取。
例如想训练网络处理 q∈[500, 1500] 范围内的均布载荷,只需在采样时每次随机生成一个 q,连同 x 一起喂给网络。这样网络学习到的不是一个固定载荷下的解,而是一个“以载荷为条件”的解函数。
对这个具体问题还有一个简便技巧:因为悬臂梁是线性系统,挠度与载荷 q 成正比、与刚度 EI 成反比,所以训练时只需变化载荷 q,网络就能自然外推到其他 q 值。如果要做损伤检测,把 EI 也作为输入,然后用少量实测挠度反推 EI 的分布,这就从正问题跨到了反问题。
4.5 效率问题:什么时候不该用 PINN
必须说一句实话:单纯计算一根等截面悬臂梁的挠度,PINN 在效率上没有任何优势。解析解一秒出结果,有限元一分钟建模加计算,而 PINN 要训练几万步,耗时以分钟计。没有必要为这种简单问题引入神经网络。
PINN 真正值得用的场景是正向与逆向耦合的问题:根据少量实测挠度数据,反推载荷分布;识别梁的刚度退化位置;或者在测量数据稀疏时,用物理方程作为约束来补充数据不足。比如某个结构健康监测场景,传感器只能测到几个离散点的位移,直接做插值误差很大,但用 PINN 把梁方程作为先验,网络就能在物理合理的范围内补齐整条挠曲线,同时推断出损伤位置。这种“物理约束 + 数据融合”的能力,才是 PINN 不可替代的地方。
最后再分享一点个人体会
我跑通这个例子后,最大的收获不是把一根梁算准了,而是理解了“物理约束”在深度学习里到底意味着什么。传统神经网络靠大量标签数据学习规律,数据不够就过拟合;PINN 把控制方程写进损失函数,相当于给网络装了一套“物理逻辑”,即便没有标签数据,也能收敛到符合力学规律的解。
那阵子我改代码改到最头疼的,就是四阶导数的数值稳定性。后来总结一句话:坐标必须归一化、导数必须带长度系数、精度必须用 float64。这三个条件同时满足,一维梁问题基本就不会出大问题。之后再从等截面梁扩展到变截面梁,只需要把 EI 改成 x 的函数;从均布载荷扩展到非均匀载荷,只需要把 q 改成 x 的函数。整个框架完全不用动。
如果你正准备拿 PINN 做你的第一个力学问题,我建议就从悬臂梁开始,别嫌它简单。这个例子里包含的四个边界条件、四阶导数、量纲换算、损失权重调节,是所有后续复杂问题的公共底座。把这些摸透了,再去碰高维问题、反问题、多物理场耦合,会轻松很多。