结构设计圈里的人对“悬臂梁挠度”应该都不陌生。上学的时候背公式上班之后查图表实在不行开个有限元软件算一算。但你们有没有遇到过这种情况梁上的载荷稍微复杂一点比如两个集中力加一段均布载荷或者截面沿着长度变化手头那点经典解就不好使了必须上数值方法。而我这两年反而迷上了一条看起来绕远的路——用物理信息神经网络PINN去算悬臂梁的挠度没想到这个“绕远”的方案在实际工作中越用越顺手。这篇文章就用一个最小可运行的Python例子把基于PINN计算一维悬臂梁挠度问题的完整思路、核心代码和踩坑经验都摊开讲清楚适合想入门PINN或者想给手头力学问题找新解法的工程师和学生。阅读之前你只需要知道一点这是把你熟悉的微分方程和边界条件变成一组能让神经网络“听懂”的损失函数。1. 把悬臂梁挠度问题整理成“神经网络能消化的形式”1.1 控制方程与边界条件先别急着写代码欧拉-伯努利梁理论下一维梁的静力挠度控制方程是EI · d⁴w/dx⁴ q(x)其中 w(x) 是梁的挠度E 是弹性模量I 是截面对中性轴的惯性矩q(x) 是沿梁长分布的线载荷。这个方程的物理含义可以一句话概括挠度的四阶导数描述的是截面弯曲抵抗能力与分布载荷之间的平衡关系。对悬臂梁来说左端 x0 固支右端 xL 自由天然带着四条边界条件固支端挠度为零w(0)0固支端转角为零dw/dx(0)0自由端弯矩为零d²w/dx²(L)0自由端剪力等于端部集中力EI·d³w/dx³(L)-P符号取决于坐标约定本文采用向下为正的挠度定义P为向下端部集中力如果只在自由端作用一个向下的集中力 P、梁上没有分布载荷那么内部区段 q(x)0控制方程退化成 d⁴w/dx⁴0。这个形式看起来简单放在PINN语境里却是最有代表性的一种形态载荷不出现在方程右端而是“藏”在边界条件里。网络必须让这个边界信息通过内部残差传播到整根梁上这正是四阶边值问题比一阶、二阶问题难训练的核心原因。本文算例我用一组常见的小型钢梁数据L3 mE200 GPa矩形截面 80 mm×80 mm于是 Ibh³/12≈3.41e-6 m⁴端部集中力 P1000 N。代入得 EI6.8e5 N·m²自由端理论挠度 PL³/(3EI)≈0.01324 m约13.2 mm是一个很典型的日常生活量级。作为后面校验用的解析解也先写出来w(x) P·x²·(3L - x) / (6EI)在 xL 处 w(L)PL³/(3EI)和我们上面的特征值一致。1.2 有限差分和有限元并没有不好只是换条路更省事传统数值算法当然都能算这道题。有限差分处理四阶格式需要5点模板边界处还要虚拟节点虽然悬臂梁这种规则问题写起来不算太难但一旦遇到变截面、带孔洞、不连续载荷网格和格式的适配成本就会直线上升。有限元则要画网格、组装刚度矩阵、处理积分点和后处理一个悬臂梁问题也得写不少代码而且算出来的是离散节点值想要光滑的转角、弯矩还得再做一遍差分或插值。PINN的思路完全不同用神经网络直接表示挠度函数 w(x)然后控制方程残差和边界条件偏差一起塞进损失函数通过优化让网络逼近真实解。它不需要网格天然输出光滑可导函数自动微分给出的导数精度远高于数值差分。换句话说你训练完网络以后挠度、转角、弯矩、剪力全是同一个函数的各阶导数随手就能取出来用不需要任何后处理差分。当然这个思路对四阶问题是有代价的梯度计算链很长损失函数非凸训练过程比普通回归敏感得多。这也是为什么后面专门用一整节讲调参和排查。2. 无量纲化和损失函数组装这一关过不了后面全是白搭2.1 量纲问题直接拿原始物理量训练收敛慢甚至崩溃新手最容易踩的坑就是直接拿带单位的 w米和 x米去训练。拿上面的算例看一组量级你就明白了网络输出的挠度在0.013 m附近d⁴w/dx⁴的量级是 w/L⁴≈1.6e-4而自由端剪力条件 EI·w -P 里面EI·w的量级是1000 N。两类损失项的梯度尺度相差悬殊固支端挠度损失 w(0)² 是平方米量级约1e-4自由端剪力损失 (EI·w P)² 是牛平方量级约1e6两者能差出10个数量级以上。Adam这种一阶优化器基本会被最大的一项牵着走较小的项形同虚设等把大项压下去小项早就跑偏了训练必然震荡或停滞。解决方式就是无量纲化。做结构的人对“无量纲”其实很熟只是在神经网络这里它从“建议做”变成了“必须做”。令 ξx/Lv(ξ)w(x)/w₀取特征挠度 w₀PL³/(3EI)把所有变量变成 O(1) 的无量纲量。链式求导代入后控制方程变成d⁴v/dξ⁴ 0ξ∈(0,1)边界条件变成v(0)0dv/dξ(0)0d²v/dξ²(1)0d³v/dξ³(1)-3解析解变成非常干净的 v(ξ)(3ξ²-ξ³)/2。这时每一项损失天然是 O(1)梯度平衡训练收敛速度快得多。这是整个PINN项目里最重要、也最容易被忽略的预处理步骤。物理量有量纲形式无量纲形式自变量x∈[0,3] mξ∈[0,1]挠度w≈0.013 mv≈O(1)固支端条件w(0)0, w(0)0v(0)0, v(0)0自由端剪力条件EI·w(L)-1000 Nv(1)-32.2 损失函数怎么组装四项各占多少权重PINN的损失函数由两部分构成PDE残差和边界条件残差。我的做法是L λ_pde·mean(v²) λ_fixed·(v(0)² v(0)²) λ_free·(v(1)² (v(1)3)²)这里有个关键问题为什么边界条件的权重不能和残差一样因为 d⁴v/dξ⁴0 在内部可以被“平凡满足”——神经网络只要在内部学出一个三次多项式残差就是零但边界信息不一定能传播进来。如果边界项在损失函数里占比太小训练会得到一条“看起来很光滑、但根本不满足固支端和自由端条件”的曲线。反过来如果边界权重太大网络会优先钉死两端内部残差却久久降不下去中间挠度出现不自然的波动。我通常把 λ_fixed 和 λ_free 设在5到50之间λ_pde 保持1。具体取多少看训练结束后三个分项损失的相对大小训练到位时边界项应与残差项在同一个量级附近。如果某一项仍然高出两个数量级就把它的权重加大或者把其他项权重调低。有些实现会做自适应权重给每个边界项配一个可学习的系数对小问题来说没必要固定权重加多次试错完全够用。2.3 配点采样300个点就够但别贴着边界取内部配点只负责供应PDE残差。对这个光滑的三次函数解均匀随机采样300个点已经非常宽裕再多也能加速收敛但收益递减。我习惯把配点取在 (0.01, 0.99) 区间内故意留出边界附近一段空隙。理由很简单边界点同时承载边界损失和残差损失二者对输出及导数的要求不完全一致配点贴得太近早期会产生互相拉扯的梯度损失曲线出现锯齿。如果你后面要做自适应加密比如在梁的某段局部载荷附近加密配点那就是另一个话题了。对本文这个基准算例均匀随机采样足够。3. 核心代码拆解从网络结构到四阶导数的自动微分3.1 网络结构选型tanh 是默认解ReLU 是自杀式选择网络结构我用一个非常普通的全连接网络1个输入4个隐藏层每层60个神经元tanh激活Xavier初始化输出1个节点。输入输出都是一维标量复杂度不高。这里必须解释为什么激活函数选 tanh 而不是 ReLU。ReLU是分段线性函数它的二阶导数几乎处处为0四阶导数恒等于0如果不考虑奇异点。这意味着什么意味着内部残差 d⁴v/dξ⁴ 恒等于0——方程自动满足但网络也彻底失去了通过“弯曲”来传播边界信息的能力训练出来的函数要么退化成贫瘠的直线段组合要么就是一场灾难。而 tanh 是无限次光滑的各阶导数都非零高阶信息可以顺着计算图一路传导是四阶ODE问题的标配。深度也不是越深越好。我测试过2到8层2层拟合能力不够损失卡在1e-4上不去6层以上梯度经过四阶微分后容易出现数值不稳定后期损失停在1e-5附近。3到5层每层40到80个神经元是这个问题的甜点区。3.2 自动微分链w 是怎么一步步算出来的四阶导数是这个题目里最核心的工程细节。PyTorch的自动微分可以算任意阶导数但写法有讲究。关键点有两个一是每次求导时设置 create_graphTrue让导数本身也成为计算图的一部分这样可以对导数继续求导二是设置 retain_graphTrue因为连续四次反向传播如果用默认方式会释放中间图后面的求导会直接报错。函数实现如下把四个导数一次算齐def compute_derivatives(model, x): x x.clone().requires_grad_(True) v model(x) v_x torch.autograd.grad(v, x, grad_outputstorch.ones_like(v), create_graphTrue, retain_graphTrue)[0] v_xx torch.autograd.grad(v_x, x, grad_outputstorch.ones_like(v_x), create_graphTrue, retain_graphTrue)[0] v_xxx torch.autograd.grad(v_xx, x, grad_outputstorch.ones_like(v_xx), create_graphTrue, retain_graphTrue)[0] v_xxxx torch.autograd.grad(v_xxx, x, grad_outputstorch.ones_like(v_xxx), create_graphTrue, retain_graphTrue)[0] return v, v_x, v_xx, v_xxx, v_xxxx最容易犯的错误对形状为 (n,1) 的向量求导grad_outputs 必须是一个形状匹配的全1张量直接省略会得到“grad can be implicitly created only for scalar outputs”的报错。另一个需要避免的低效写法是每算一个导数就重新 forward 一次网络完全没有必要——一次前向拿到 v后面全是图上的连续求导。3.3 训练主流程Adam 粗训 L-BFGS 精修完整可运行代码我贴在下面环境是 PyTorch 2.x单块普通CPU就能跑完import torch import torch.nn as nn import numpy as np torch.manual_seed(42) np.random.seed(42) # 物理参数小型钢梁80mm x 80mm 矩形截面 L 3.0 # 梁长m E 200e9 # 弹性模量Pa I 3.4e-6 # 截面惯性矩m^4 P 1000.0 # 自由端集中力N EI E * I w0 P * L**3 / (3.0 * EI) # 特征挠度用于归一化 class PINN(nn.Module): def __init__(self, n_hidden4, n_neurons60): super(PINN, self).__init__() self.activation nn.Tanh() self.input_layer nn.Linear(1, n_neurons) self.hidden_layers nn.ModuleList( [nn.Linear(n_neurons, n_neurons) for _ in range(n_hidden)] ) self.output_layer nn.Linear(n_neurons, 1) for m in self.modules(): if isinstance(m, nn.Linear): nn.init.xavier_normal_(m.weight) def forward(self, x): x self.activation(self.input_layer(x)) for layer in self.hidden_layers: x self.activation(layer(x)) return self.output_layer(x) def compute_derivatives(model, x): x x.clone().requires_grad_(True) v model(x) v_x torch.autograd.grad(v, x, grad_outputstorch.ones_like(v), create_graphTrue, retain_graphTrue)[0] v_xx torch.autograd.grad(v_x, x, grad_outputstorch.ones_like(v_x), create_graphTrue, retain_graphTrue)[0] v_xxx torch.autograd.grad(v_xx, x, grad_outputstorch.ones_like(v_xx), create_graphTrue, retain_graphTrue)[0] v_xxxx torch.autograd.grad(v_xxx, x, grad_outputstorch.ones_like(v_xxx), create_graphTrue, retain_graphTrue)[0] return v, v_x, v_xx, v_xxx, v_xxxx def pinn_loss(model, x_colloc, x_fixed, x_free): # PDE残差内部点满足 d4v/dxi4 0 _, _, _, _, v_xxxx compute_derivatives(model, x_colloc) pde_loss torch.mean(v_xxxx**2) # 固支端边界v0, v0 v0, vx0, _, _, _ compute_derivatives(model, x_fixed) bc_fixed_loss torch.mean(v0**2) torch.mean(vx0**2) # 自由端边界v0, v-3 _, _, vxx1, vxxx1, _ compute_derivatives(model, x_free) bc_free_loss torch.mean(vxx1**2) torch.mean((vxxx1 3.0)**2) total_loss pde_loss 10.0 * bc_fixed_loss 10.0 * bc_free_loss return total_loss, pde_loss, bc_fixed_loss, bc_free_loss # 采样 n_colloc 300 x_colloc torch.rand(n_colloc, 1) * 0.98 0.01 # 内部配点避开边界 x_fixed torch.zeros(1, 1) x_free torch.ones(1, 1) model PINN(n_hidden4, n_neurons60) # 第一阶段Adam 粗训 optimizer torch.optim.Adam(model.parameters(), lr1e-3) for step in range(20000): optimizer.zero_grad() total_loss, pde_loss, bc_fixed_loss, bc_free_loss pinn_loss( model, x_colloc, x_fixed, x_free ) total_loss.backward() optimizer.step() if step % 5000 0: print(fstep {step:6d} | total {total_loss.item():.3e} | fpde {pde_loss.item():.3e} | ffixed {bc_fixed_loss.item():.3e} | ffree {bc_free_loss.item():.3e}) # 第二阶段L-BFGS 精修 optimizer_lbfgs torch.optim.LBFGS( model.parameters(), lr0.5, max_iter5000, tolerance_grad1e-10, line_search_fnstrong_wolfe ) def lbfgs_closure(): optimizer_lbfgs.zero_grad() total_loss, _, _, _ pinn_loss(model, x_colloc, x_fixed, x_free) total_loss.backward() return total_loss optimizer_lbfgs.step(lbfgs_closure)两阶段优化是PINN训练中一个通用技巧。Adam在前20000步负责把参数拉进一个相对好的区域它震荡小、能跨过一些光滑性较差的区域L-BFGS则利用二阶信息做精确线搜索收敛能把损失再压低一到两个数量级。我只跑Adam的时候损失通常在1e-4量级徘徊配合L-BFGS之后能到1e-6最终误差改善非常明显。注意L-BFGS的closure函数必须返回loss并完成zero_grad否则步进会直接失效。还要提醒一点这个损失函数是非凸的不同随机种子训练出来的结果会有差别个别种子甚至会出现一条局部畸形的曲线。固定 torch.manual_seed 是保证可复现的最低要求工程上更稳妥的做法是多跑几个种子取误差最小的模型。四阶问题对初始化的敏感程度比二阶问题高得多。4. 训练结果验证与误差分析拿解析解当尺子4.1 对比方法别只盯着自由端那一个点训练完第一件事就是验证。把 ξ 在 [0,1] 上均匀取100个点网络前向得到 v_pred乘回 w₀ 得到有量纲挠度预测值再和解析解 w(x)P·x²·(3L-x)/(6EI) 对比。除了看自由端挠度误差我更建议看两个整体指标最大绝对误差和相对L2误差。因为PINN的自由端点往往被边界条件“钉住”误差大头通常出现在跨中附近只看自由端容易被表面上的准确蒙混过关。with torch.no_grad(): x_test torch.linspace(0, 1, 100).reshape(-1, 1) v_pred model(x_test).detach().numpy().flatten() xi x_test[:, 0].numpy() w_pred v_pred * w0 w_true P * xi**2 * (3*L - xi*L) / (6*EI) abs_err np.abs(w_pred - w_true) max_err abs_err.max() rel_l2 np.linalg.norm(w_pred - w_true) / np.linalg.norm(w_true) tip_err np.abs(w_pred[-1] - w_true[-1]) / np.abs(w_true[-1]) print(fmax abs err {max_err:.3e} m) print(frelative L2 err {rel_l2:.3e}) print(ftip deflection rel err {tip_err:.3e})4.2 我跑出来的典型数字损失和误差能到什么水平用上面的默认参数整段训练在几年前的笔记本CPU上20分钟左右能完成。多次运行后的典型结果是总损失降到1e-6到1e-7自由端挠度相对误差0.5%左右全长相对L2误差约1%最大绝对误差大约0.2到0.5 mm对应13.2 mm的挠度。对结构初步设计来说这个精度完全够看。有几个观察值得记下来前三阶导数挠度、转角、弯矩精度都不错但逐阶变差到了四阶剪力残差就比较毛糙。这是高阶导数的通病不是你代码写错了。跨中误差往往略大于端点误差因为端点被边界条件硬约束中间区域全靠PDE残差这个“软约束”撑着这正是PINN的典型行为。配点数量从300加到1000误差会再降一些但训练时间明显上升。对这个简单问题300点性价比最高。4.3 免费附加值转角、弯矩、剪力随取随用学结构的人都知道挠度算出来只是第一步设计里更关心转角和弯矩。传统数值方法里这些量是挠度差分或单元后处理得到的误差会被进一步放大。而PINN在这里有天然优势网络本身就是可导函数求导就是自动微分不需要额外差分。转角 θ dw/dx (w₀/L)·dv/dξ弯矩 M -EI·d²w/dx² -EI·(w₀/L²)·d²v/dξ²。用一段类似 compute_derivatives 的代码取二阶导画出来之后你会看到固支端转角趋近于0自由端弯矩趋近于0训练到位时误差通常在1e-3量级以下。换句话说你跑一次PINN等于同时拿到了一整条梁的挠度、转角、弯矩和剪力曲线这是网格类方法不太容易给的“整体可微”视角。5. 调参经验与常见问题排查四阶问题没那么温柔5.1 损失不下降、曲线畸形按这个清单查我把实践中遇到的高频问题整理成一张排查表每次训练异常就从第一行开始逐项过现象常见原因处理办法损失卡在1e-2不下降无量纲化没做好各损失项量级失衡检查v和ξ是否归一化到O(1)量级边界已满足、内部残差大配点太少或网络容量不够增加配点到600-1000或加宽隐藏层内部残差很小、边界误差大边界损失权重太小信息没压进去把λ_fixed和λ_free提高到20-50总损失震荡、曲线锯齿学习率偏高或边界附近配点太密降低Adam学习率到3e-4配点避开边界L-BFGS阶段损失发散closure写法有问题或Adam停太早检查zero_grad与loss返回退回Adam再训换了随机种子结果差异大四阶问题对初始化敏感固定种子或多种子取最优5.2 四阶导数为什么是本质瓶颈高次导数的数值稳定性是PINN方法的天花板。每一次自动微分都相当于对函数做一次高频放大四阶微分会把网络输出里极微小的波动放大得非常夸张。所以同样精度的网络解一个二阶梁方程会比解四阶方程轻松一个量级。如果想把精度再往上推方向有这么几个在输入层做Fourier特征映射把 x 映射到 sin/cos 特征上让网络更容易表达中高频成分。对这个三次解帮助有限但对高阶弯曲形态的梁很有用。自适应损失权重给残差大的配点分配更大的权重常见做法是把每个配点的权重做成可学习参数让网络主动去啃难啃的地方。硬约束边界把网络输出改写成 v(ξ)ξ²·g(ξ)自动满足固支端 v(0)0、v(0)0边界损失少算两项训练压力会明显减小。5.3 均布载荷、变截面、多点载荷怎么扩展如果改成均布载荷 q无量纲化时特征挠度取 w₀qL⁴/(24EI)内部残差从 d⁴v/dξ⁴0 变成 d⁴v/dξ⁴24损失函数里只换一行解析解变成 v(ξ)ξ²(6-4ξξ²)。如果截面沿长度变化I(x)不再是常数方程要写成 d²/dx²(EI(x)·d²w/dx²)q这时需要在自动微分基础上再多算 EI(x) 的导数用乘积求导法则组合进残差。代码上不难但要注意 EI 也要建成随 x 变化、可求导的表达式。如果梁中间某个位置作用集中力挠度本身连续但剪力三阶导数在载荷位置有个跳跃光滑的神经网络天生表达不了间断。解决办法是把梁分成两段各自用独立网络在两个网络连接点处把 w、w、w、w 的连续或跳跃条件作为额外损失项加进去。这也是我实际项目里常用到的手段。最后分享一个我自己的经验调试四阶PINN永远从一阶问题起步。先把网络在一维二阶ODE上调通再上四阶这样很容易区分是微分链写错还是权重没调好。悬臂梁问题看着简单但它是检验PINN基本功最好的试金石——能把简单的四阶问题稳定调出来再去碰变截面、非线性材料的时候你的底气和手法下限就完全不一样了。