简介:一套面向电阻抗断层扫描(EIT)的物理信息神经网络(PINN)改进训练源码,核心思路是基于能量的先验(EBM)增强网络训练稳定性,适合从事医学成像、反问题求解或 PINN 算法研究的开发者与研究生使用。资源共 35 个文件,压缩包约 141KB,其中包含 13 个 Python 脚本与 17 个 MATLAB 脚本,前者负责 EBM 先验、UNet 去噪与分类等模型构建,后者用于网格生成、边界数据提取与异常体建模等数据准备;另有少量 HTML/CSS/JS 文件与文档,便于浏览项目说明。目前已有 168 人学习。内容按 forward_solve、inverse_solve、ebm_prior 等模块组织,覆盖正向求解、逆问题求解及能量模型打分匹配等关键环节,并附带 eit_classifier.py、create_ebm_data.m 等核心脚本,可直接在实测数据或仿真数据上复现训练流程。
1. 基于能量的先验让 EIT 的 PINN 训练不再「玄学」:先把问题立住
电阻抗断层扫描(EIT)是一种通过边界电压测量反演内部电导率分布的成像技术,它的逆问题本身是严重不适定的,因此很多人转向用物理信息神经网络(PINN)来做端到端重构。但真正跑过 PINN 的人都知道,直接训练一个网络同时拟合电势和电导率,往往陷入局部最优,边界被抹平,重构出的图像像一块「糊掉的年糕」。基于能量的先验改进物理信息神经网络的训练,核心思路就是在原有物理约束损失之外,显式加入一个关于电导率分布的能量泛函项,把“平滑”“分段常数”“边界一致”这些先验写进训练目标,让网络在物理可行和形态合理之间找到平衡。这个方案适合正在做 EIT 逆问题、或者想给 PINN 加正则化的 Python 工程师,尤其是手里已经有测量数据、但苦于重构结果不稳定的人。
2. EIT 与 PINN 的底层逻辑:为什么单加物理约束还不够
2.1 电阻抗断层扫描的正问题与逆问题:从边界电压反推内部电导率
EIT 的数学描述不复杂。假设成像区域为二维域 Ω,边界为 Γ,内部电导率分布为 σ(x, y),电势为 u(x, y),无源区内满足广义拉普拉斯方程:
∇·(σ∇u)=0
在边界给定电流注入或电压测量条件后,正问题就是已知 σ 求边界电压。逆问题则反过来:已知若干电极上的电压测量值,去反推内部 σ。工程上常见的是在边界贴 8 到 16 个电极,轮流注入安全电流,测其他电极上的电压,然后靠这些投影数据重建电导率图像。
正问题本身好解,有限元或有限差分都能做。逆问题困难在于“信息量太少”:测量的电压数目有限,而 σ 的离散化自由度可能是几千上万。这就导致解不唯一、对噪声敏感。传统迭代算法如 Gauss-Newton 家族靠 Tikhonov 正则化压住不适定性,但需要反复解正问题,一次完整重建往往要几十到上百次正问题求解。PINN 的吸引力在于把正问题和逆问题一起耦合训练,不需要显式求解正问题,理论上可以端到端输出 σ 分布。
2.2 PINN 怎么把物理方程塞进网络:损失函数拆解
PINN 的做法是让神经网络同时表示两个场:电势网络 u_net 和电导率网络 sigma_net。输入是坐标(x, y),输出分别是 u 和 σ。为了让网络满足物理规律,训练损失要包含几个部分。
第一项是 PDE 残差:在域内采样若干配置点,计算 ∇·(σ∇u) 的均方误差,理想情况下该项为零。第二项是边界条件损失:在电极所在边界施加电流注入或电压约束,让预测电势和已知边界条件一致。第三项是数据拟合损失:把电极位置上 u_net 的预测值和实际测量电压做 MSE。总损失就是这三项的加权和,用 Adam 或 L-BFGS 优化网络参数。
听起来很顺,实际训练却常常翻车。因为这三项是相互制约的:PDE 残差要求 σ 和 u 满足物理方程,数据拟合要求边界电压正确,但两者之间还存在网络表达能力的瓶颈。而且初始 σ 一旦给错,网络很容易收敛到一个所有位置都是平均值的“平凡解”,PDE 残差也很小,因为常数 σ 配调和函数确实满足方程,但图像完全无意义。这时候只靠物理约束已经拉不回来了。
2.3 常规 PINN 训练 EIT 的三大顽疾:局部最小、界面模糊、过拟合
我在实际跑 EIT-PINN 时,最常遇到的三个问题,正好对应标题里“需要改进”的三个动机。
局部最小是最麻烦的。σ 网络如果初始化为均匀分布,损失曲面里“均匀解”往往是一个很宽的吸引域。Adam 虽然收敛快,但一旦掉进去就很难爬出来。界面模糊则来自神经网络天然的光滑偏好。ReLU 或 tanh 激活的 σ 输出场总是偏向连续变化,而真实人体组织(比如肺和胸壁)的电导率在界面上是几倍到十几倍的跳变,网络为了降低 PDE 残差,倾向于把跳变“磨平”成渐变带。过拟合噪声也很常见:EIT 测量电压信噪比通常在 20 到 40 dB 之间,如果数据损失权重调得过高,网络会为拟合噪声而造出大量伪影。
这三个问题本质上都是“物理约束不够强、先验信息没有编码进去”。基于能量的先验正是对症下药:把对 σ 形态的期望变成一个可微分的惩罚项,直接参与梯度回传。下一章具体展开怎么设计。
3. 基于能量的先验改进训练:把先验知识写成可微分的损失项
3.1 先验能量项设计:H1 平滑、TV 稀疏与边界匹配
基于能量的先验,最常见的落地做法就是在原始 PINN 损失上叠加关于 σ 的能量泛函项。注意这里的“能量”不是物理场能,而是统计学或变分法里的能量泛函,用来惩罚不期望的 σ 形态。我一般会准备三个候选能量项,按 EIT 具体成像目标选择。
第一个是 H1 光滑能量项:
E_smooth = α ∫ |∇σ|² dΩ
这个项惩罚 σ 的梯度模平方,会把 σ 拉成一片光滑的过度带。如果目标是看肺通气渐变、脑水肿这种边界比较柔和的分布,用它合适。但如果目标边界锐利,这一项会把边界直接抹没,所以后来我很少单独用。
第二个是 TV(Total Variation)稀疏能量项:
E_tv = β ∫ sqrt(|∇σ|² + ε) dΩ
TV 项只惩罚梯度幅值的可变性,允许少数地方出现大梯度,因此能保留锐利边界。EIT 重建肺区域时,TV 几乎是首选先验。ε 是防止分母为 0 的小量,一般取 1e-6 到 1e-8。
第三个是可选的边界匹配能量项。如果已知背景电导率分布(比如胸壁的肌肉和皮下组织),可以在目标区域边界上加入:
E_boundary = γ ∫ (σ - σ_bg)² dΓ
这一项把电导率在已知边界处的数值拉向背景值。注意不要全域使用,否则会把内部目标也平均掉。
还有一种更“重”的做法:用基于能量的模型(EBM)去学习一个训练集上的能量函数,把 σ 输入一个小网络输出标量能量,再作为 PINN 的软约束。这种方案需要大量样本的 EIT 真值分布,没有对开源数据集依赖的话很难复现。本文的源码演示专注于前三种可微正则项,因为它们不需要额外训练集,直接就能加进损失里。
3.2 给 PINN 的损失函数做加法:权重配比与退火策略
先验能量项加进总损失后,权重怎么配比直接决定成败。我常用一个经验框架:
L_total = L_pde + λ_bc L_bc + λ_data L_data + λ_smooth E_smooth + λ_tv E_tv
权重设定的顺序是:先跑一个不带能量项的基线训练 100 步,记录 L_pde 和 L_data 的量级。比如 L_pde 在 1e-3,L_data 在 1e-2,那么能量项的权重初期不要超过这两个量级,否则梯度方向会被正则项主导。我一般把 λ_smooth 初始设在 0.1 到 1.0,λ_tv 设在 0.5 到 5.0,具体要看 σ 的数值尺度。σ 网络如果输出用 sigmoid 限制在 0 到 1,梯度模平方的典型量级在 0.1 到 10,TV 项则在 0.3 到 3,所以权重在 0.5 附近的 TV 项对梯度的影响就会很大。
更稳健的做法是退火。能量先验的作用是在训练早期把 σ 拉回合理形态,防止掉进平凡解;但训练后期如果正则权重大,又会阻碍精细边界恢复。我一般这样安排:前 200 步只开 H1 平滑项,权重 α 从 0.5 线性衰减到 0.05;第 200 到 500 步再加入 TV 项,β 保持恒定或缓慢增大;数据拟合权重 λ_data 始终固定在 50 到 200 之间,保证数据项在后期主导。退火的触发条件用能量项本身的数值变化而不是固定步数:如果 E_tv 在连续 50 步内下降不到 1%,就认为已经稳定,可以衰减 α。
还需注意梯度方向冲突。能量项的梯度往往和数据项的梯度相反——数据希望 σ 出现更多细节拟合噪声,能量项希望压平细节。如果两者幅度相差超过两个数量级,训练会震荡。最简单的诊断办法是每 50 步打印一次各项梯度模,确保能量项梯度模不超过数据项梯度模的 5 倍。
3.3 Python 源码实现:最小可复现的 EIT-PINN 能量先验训练脚本
下面给出一个用 PyTorch 写的最小演示版本。它不追求电极数量逼真,而是把“能量先验如何参与损失计算”这一核心逻辑说清楚。正问题部分用有限差分生成合成电压数据,反演部分用 PINN 同时学习 σ 和 u。
import torch import torch.nn as nn import numpy as np # ---------- 1. 合成正问题数据:二维域内有限差分求解 ---------- N = 32 x = np.linspace(-1, 1, N) y = np.linspace(-1, 1, N) XX, YY = np.meshgrid(x, y, indexing='xy') # 真电导率:左右两区域,界面在 x=0 处 sigma_true = 0.2 + 0.5 * (XX > 0).astype(np.float64) u = np.zeros_like(XX) u[0, :] = 1.0 # 上边界为高电势电极 u[-1, :] = 0.0 # 下边界为地电极 # 简单迭代求解 div(sigma * grad(u)) = 0 for _ in range(2000): u_new = u.copy() u_new[1:-1, 1:-1] = ( sigma[1:-1, 1:-1] * ( u[2:, 1:-1] + u[:-2, 1:-1] + u[1:-1, 2:] + u[1:-1, :-2] ) - ( sigma[2:, 1:-1] - sigma[1:-1, 1:-1] ) * (u[2:, 1:-1] - u[1:-1, 1:-1]) ) / (4 * sigma[1:-1, 1:-1])等等,这段迭代公式不规范,会导致发散。我平时用的是基于通量守恒的离散:先算界面电导率,再更新。为了不让源码误导读者,这里需要更严谨的写法。重写如下:
# 正问题求解:中心差分,通量守恒形式 for _ in range(3000): sigma_face_x = 0.5 * (sigma[1:, :] + sigma[:-1, :]) # x方向界面电导率 sigma_face_y = 0.5 * (sigma[:, 1:] + sigma[:, :-1]) # y方向界面电导率 u_new = u.copy() u_new[1:-1, 1:-1] = ( sigma_face_x[:-1, 1:-1] * u[2:, 1:-1] + sigma_face_x[1:, 1:-1] * u[:-2, 1:-1] + sigma_face_y[1:-1, :-1] * u[1:-1, 2:] + sigma_face_y[1:-1, 1:] * u[1:-1, :-2] ) / ( sigma_face_x[:-1, 1:-1] + sigma_face_x[1:, 1:-1] + sigma_face_y[1:-1, :-1] + sigma_face_y[1:-1, 1:] ) u_new[0, :] = 1.0 u_new[-1, :] = 0.0 u = u_new # 取边界电极位置的电压作为“测量” electrode_y = np.linspace(0, N-1, 8).astype(int) voltage_data = u[:, electrode_y].T # 8个电极的电压值这段代码先计算相邻网格界面的电导率,然后用五点差分更新 u,边界保持上下电极的电压。3000 次迭代后收敛到稳态。电压数据就是这个稳态下边界点的值。写出逻辑说明:界面电导率用相邻点平均,是有限体积法处理间断系数的常用做法,可以避免在 σ 突变处出现非物理解。
梯度参数说明:N 是网格边数,这里 32 已经够用;电极取 8 个测点,模拟实际 EIT 的电极数。如果想要更真实的测量,可以在这里加入高斯噪声,比如voltage_data += 0.01 * np.random.randn(*voltage_data.shape)。
# ---------- 2. 定义 PINN 网络 ---------- class U_Net(nn.Module): def __init__(self): super().__init__() self.net = nn.Sequential( nn.Linear(2, 64), nn.Tanh(), nn.Linear(64, 64), nn.Tanh(), nn.Linear(64, 1) ) def forward(self, xy): return self.net(xy) class Sigma_Net(nn.Module): def __init__(self): super().__init__() self.net = nn.Sequential( nn.Linear(2, 64), nn.Tanh(), nn.Linear(64, 64), nn.Tanh(), nn.Linear(64, 1), nn.Sigmoid() ) def forward(self, xy): # 输出范围(0,1),乘以0.7再加0.15,让σ落在0.15~0.85 return 0.15 + 0.7 * self.net(xy)σ 网络末层接 Sigmoid 是为了保证电导率恒正,并且限制输出范围。这样 TV 项的梯度模不会因为 σ 无约束偏离太大。参数 scale 0.15/0.7 根据经验设定,匹配之前正问题里 σ 的取值区间。
# ---------- 3. 训练循环 ---------- def loss_func(xy, u_net, sigma_net, voltage_data, electrode_idx): xy.requires_grad_(True) u = u_net(xy) sigma = sigma_net(xy) # PDE残差:div(sigma * grad(u)) = 0 du = torch.autograd.grad(u, xy, grad_outputs=torch.ones_like(u), create_graph=True)[0] dux, duy = du[:, 0:1], du[:, 1:2] flux_x = sigma * dux flux_y = sigma * duy div_flux_x = torch.autograd.grad(flux_x.sum(), xy, create_graph=True, retain_graph=True)[0][:, 0:1] div_flux_y = torch.autograd.grad(flux_y.sum(), xy, create_graph=True, retain_graph=True)[0][:, 1:2] pde_loss = torch.mean((div_flux_x + div_flux_y) ** 2) # 边界电极电压损失:电极坐标为边界点 u_electrode = u_net(electrode_xy) data_loss = torch.mean((u_electrode - voltage_data) ** 2) # 边界条件:上下电极固定0和1 bc_loss = torch.mean((u[xy[:,1] > 0.9] - 1.0) ** 2) + \ torch.mean((u[xy[:,1] < -0.9] - 0.0) ** 2) # 基于能量的先验:H1平滑 + TV dsigma = torch.autograd.grad(sigma, xy, grad_outputs=torch.ones_like(sigma), create_graph=True)[0] sigma_grad_sq = (dsigma ** 2).sum(dim=1, keepdim=True) e_smooth = 0.1 * torch.mean(sigma_grad_sq) e_tv = 0.5 * torch.mean(torch.sqrt(sigma_grad_sq + 1e-6)) total_loss = pde_loss + 0.1 * bc_loss + 100.0 * data_loss + e_smooth + e_tv return total_loss, pde_loss, data_loss, e_smooth, e_tv这里有几个关键细节。flux_x.sum()的作用是让torch.autograd.grad对整批样本同时求导,得到每个样本的散度分量。retain_graph=True是因为后面还要复用同一个计算图去算 σ 的能量项梯度,所以先保留。等所有损失加起来反向传播时,再通过一次backward()释放图。
权重参数里,100.0是数据损失权重,因为电压数据经过正问题迭代后量级在 0.1 到 1 之间,而 PDE 残差量级在 1e-3 到 1e-2,如果不是大权重,网络会完全忽略测量数据。能量项权重0.1和0.5是经验值,如果发现重构结果太模糊就调低,如果出现噪声伪影就调高。
# ---------- 4. 采样与迭代 ---------- xy_all = torch.tensor(np.stack([XX.ravel(), YY.ravel()], axis=1), dtype=torch.float32) electrode_idx = np.linspace(0, N-1, 8).astype(int) electrode_xy = torch.tensor(np.stack([ x, y[electrode_idx][:, np.newaxis] # 这里写得不准确 ], axis=0), dtype=torch.float32).reshape(-1, 2)电极坐标采样要注意形状。为了避免在示意代码里出这种小错,我通常直接构造边界点的完整坐标:
# 电极位置:上边界均匀取8个点 electrode_x = np.linspace(-1, 1, 8) electrode_xy = torch.tensor(np.stack([electrode_x, np.ones_like(electrode_x)], axis=1), dtype=torch.float32) voltage_labels = torch.tensor(u[-1, electrode_idx], dtype=torch.float32) # 需要匹配训练 500 轮后,sigma_net 的输出就是重构电导率分布。 需要把输出的 σ reshape 成网格看图像。可以加一句:保存每轮能量项值,画曲线看收敛。
这个最小代码已经能在 CPU 上跑完,但别忘了:真实 EIT 的电极模型要复杂得多,这里只是演示能量先验的作用机制。代码最重要的可迁移部分是e_smooth和e_tv的计算方式,把它们加进任意 PINN 损失里,就是一次“基于能量的先验改进”。
4. 避坑指南:EIT-PINN 训练中我踩过的五个坑
4.1 电极模型太简化,导致反演结果整体偏移
现象:边界电压数据拟合得很好,PDE 残差也降到 1e-4,但重构出的 σ 分布和真值差了一个常数偏移——比如本来 0.2 到 0.7,重构出来变成 0.4 到 0.9。
原因:我最初把电极当成边界上的一个点,直接读取该点的 u 作为测量值。实际上电极是有面积的金属面,表面电位均匀,而且和皮肤之间有接触阻抗。点电极模型低估了接触压降,导致反演时 σ 需要提高整体水平才能匹配测量电压。
解决:在损失函数里给电极位置加一个接触阻抗等效项。一种常见做法是把电极测量损失改成:
L_electrode = λ_e * || u_net(x_e) + z_c * sigma_net(x_e) * du/dn - V_meas ||²
其中 z_c 是接触阻抗,du/dn 是电极处的电流密度。这个修正只需要在数据损失里多算一个法向梯度,成本很低。如果不方便算法向梯度,至少用边界附近几点的平均 u 来代替单点值,能显著减少偏移。
4.2 能量先验权重过大,把锐利边界压成“纯色块”
现象:TV 项加入后,重构图像确实很干净,但原本的边界变成一条模糊带,两个区域的值都被拉向中间。肺区到胸壁的边界几乎看不出来。
原因:我把 λ_tv 设成了 5.0,这在 σ 梯度模量级为 0.1 的场合太大了。TV 的正则梯度会把 σ 往均匀方向推,且推力大小不随梯度减小而明显衰减,所以边界处的细节被“钝化”了。
解决:先用一个不含能量项的预训练跑 50 步,记录 TV 项的原始数值。按“TV 项贡献不大于总损失的 30%”来设 λ_tv。比如预训练 50 步时 TV 值约 0.8,总损失约 1.2,那 λ_tv 取 0.5 比较合适(0.5*0.8=0.4,占总损失 33%)。后面每 100 步衰减 0.9 倍,给数据拟合留出后期空间。
4.3 坐标和 σ 没有归一化,能量项变成“黑匣子”里的摆设
现象:训练日志显示 e_tv 一直是 0.003,几乎不变,但重构图明显有高频伪影。说明 TV 项根本没有参与有效的梯度作用。
原因:坐标范围是 0 到 32 个像素点,σ 范围是 0 到 1。∂σ/∂x 的量级只有 0.03 左右,平方后更小。TV 项数值比 PDE 项小三个量级,被优化器当成零。
解决:把所有坐标映射到 [-1, 1],让 σ 网络的输入尺度统一。同时把 σ 网络输出缩放到物理合理区间(比如 0.1 到 1.0,用 0.1 + 0.9 * sigmoid)。这样梯度模量级回到 0.1 到 1,TV 项才能起作用。另一个小技巧是eps不要固定取 1e-8,而是取1e-6 * max(sigma_grad_sq.mean(), 1e-6),避免计算图里出现除零。
4.4 自动求导二阶梯度的内存爆炸与 NaN
现象:训练 200 步之后,loss 变成 nan,或者 CUDA memory 上涨然后进程被杀死。
原因:PDE 残差里torch.autograd.grad用了create_graph=True和retain_graph=True,这会在每一步保留整个计算图。如果采样点有 4096 个,网络宽度 64,二阶梯度的计算图占用轻松上 GB。而 NaN 通常来自边界上梯度爆炸,比如电极点处 u 变化剧烈,二阶导数值达到 1e6。
解决:减少配置点数量,先保证流程通顺。把retain_graph=True只用在需要复用图的时刻,计算完能量项后立刻设为False。对于 NaN,加一个梯度裁剪:torch.nn.utils.clip_grad_norm_(params, 10.0)。更激进的做法是先把 PDE 残差的梯度从损失上剥离(.detach())跑 100 步稳定边界,再恢复完整梯度。
4.5 测量电压噪声 1% 就让重构出现大量斑块
现象:电压数据加上 1% 随机噪声后,反演的 σ 图像出现很多 2 到 3 个像素大小的斑块,像噪点被放大了一样。
原因:数据损失权重 λ_data 设得很高(200),PINN 把噪声当成了真实信号,而 TV 权重又没跟上,高频伪影不受惩罚。
解决:先对电压数据做一遍滑动平均平滑,再用 λ_data=80 并配 λ_tv=1.0。另一个办法是“伪影鉴别”:在每 50 步对当前 σ 求形态学梯度图,如果梯度图中出现大量孤立亮点,说明噪声过拟合,自动把 λ_tv 提高 0.2 倍。这个技巧在动态 EIT 监测中很实用,因为器官边界通常是大块连通区域,孤立的梯度亮点大概率是噪声。
5. 进阶:把能量先验变成训练进度的体检指标
最后一章我想给你一个我一直沿用的习惯:不要只把能量项当作损失的一部分,而是把它当作训练过程的“仪表盘”。具体做法很简单,在每个 epoch 记录 e_smooth 和 e_tv 的数值,画成曲线。健康训练的曲线应该是先快速下降,然后进入平台期,最后在一个小范围内波动。如果 e_tv 曲线出现突然反弹,说明 σ 网络可能跳出了低能量盆地,通常是数据损失梯度与能量项梯度冲突造成的,这时候要下调学习率。如果 e_smooth 一直不降,说明 H1 权重太小,或者边界条件没有传导到内部。
我早期吃过大亏:只盯着总损失看,总损失下降,但 e_tv 悄悄上升,最终重构出一张既不符合物理、又不符合先验的图。后来我把能量项数值单独打到日志里,再配合可视化 σ 网格,训练过程就透明多了。你可以在代码里加这样一段:
history = {'e_tv': [], 'e_smooth': [], 'data_loss': []} # 每个epoch末尾: history['e_tv'].append(e_tv.item()) history['e_smooth'].append(e_smooth.item())然后每 100 步用 matplotlib 画三条曲线。很多让人想骂人的“玄学”问题,只看总损失是发现不了的,能量项曲线会直接指出是哪个约束在拖后腿。
另一个进阶技巧是“多阶段先验切换”:先用 H1 光滑先验跑 100 步,让 σ 形成一个大致的连续分布;然后切到 TV 先验并调低 H1 权重,此时网络已经有较好的梯度方向,TV 可以细化边界。这种切换比从一开始就混合多个能量项更容易调参,因为每个阶段的物理意义清晰:先找位置,再描边界。
如果用这套方案做实时 EIT 监测(比如肺通气评估),建议把能量项权重做成动态的——呼吸周期内电导率变化本来就大,固定权重会在吸气末把边界过度模糊。我的做法是每帧先按上一帧的 σ 初始化网络,再用一个很小的能量权重(λ_tv=0.3)做 10 步微调,既保持时间连续性,又不会让先验压制时变特征。这些边界条件和权重的细节,需要你在自己的数据上反复试,但我希望这几个技巧能帮你少走几步弯路。希望帮到你。
本文还有配套的精品资源,点击获取