截断牛顿法在波形反演中的工程实践:从TRN.ZIP到SEISCOPE
2026/9/14 2:12:39 网站建设 项目流程

简介:这份资源基于SEISCOPE优化工具箱提供截断牛顿法全波形反演的完整示例,面向从事地震勘探、速度建模与计算地球物理研究的科研人员和研究生;代码实现L. Metivier等2013年发表于SIAM J. Scientific Computing的优化算法,适用于求解大规模非线性最小二乘反演问题,有助于理解二阶优化方法如何利用Hessian信息加速收敛并控制计算成本。压缩包共22个文件,核心为15个Fortran 90源程序(f90),涵盖主程序、工具箱接口、算法实现与数据读取;另含2个dat数据文件用于反演输入和迭代记录,以及Visual Studio工程文件(sln、vfproj、u2d、suo)和头文件,方便在Windows环境中直接打开编译、修改与调试;整体仅31KB,体量精简、目录层次清晰。目前已有239人学习下载,借助该程序可快速搭建截断牛顿反演实验环境,逐段跟踪线搜索、Hessian向量积、梯度计算等关键步骤,对复现论文结果、开展算法对比或教学演示都有直接帮助。

1. 从 TRN.ZIP 说起:截断牛顿法在波形反演里到底值不值

拿到 TRN.ZIP 这个名字时,有全波形反演(FWI)经验的人第一反应应该是:这是 SEISCOPE 项目里那套截断牛顿优化器。全波形反演是一个典型的 大规模非线性最小二乘问题,目标函数从几百 MB 到几 GB 的观测数据里反演地下介质参数,而截断牛顿法(Truncated Newton / TRN)的出现,是为了解决一个非常现实的矛盾:经典牛顿法收敛快但 Hessian 矩阵根本存不下,而梯度类方法省内存却在强散射介质里收敛太慢。TRN.ZIP 里打包的,就是这个中间路线的完整实现——用共轭梯度法隐式求解牛顿方程,每次迭代只算 Hessian 和一个向量的乘积,完全不显式构造 Hessian 矩阵。这个压缩包的价值不在于“又一个优化器”,而在于它给出了 FWI 场景下工程可用的整个链路:目标函数接口、梯度计算入口、Hessian-vector product 的封装方式,以及截断准则怎么设才不会把算力浪费在无谓的内迭代上。这篇文章适合两类人:一类是想把 SEISCOPE 工具箱直接工程化部署的反演工程师,另一类是手里已有正演代码、正在评估要不要从 L-BFGS 迁移到二阶方法的算法研究者。

2. 波形反演里的优化选型:为什么我会用截断牛顿而不是 L-BFGS 或全牛顿

2.1 目标函数的形态决定优化器上限

波形反演的最小二乘目标函数写作:

J(m) = 1/2 * || F(m) - d_obs ||²

其中 F(m) 是正演算子(通常由有限差分求解波动方程得到),m 是速度模型,d_obs 是观测数据。这个问题的维度极高,一个三维工区的模型参数轻易超过千万级,Hessian 矩阵的存储因此几乎不可能。理论上的全牛顿步 δm = -H⁻¹g 需要完整 Hessian 矩阵 H,在 2D 工区尚可勉强考虑,到了 3D 就是一个纯粹的存储灾难——这正是截断牛顿法存在的意义:它不直接求 H,而是把求牛顿步长的问题转化为另一个线性系统,用共轭梯度(CG)算法从初始零向量出发迭代求解,迭代到一定精度就提前截断,因此叫“截断”。

SEISCOPE 优化工具箱里同时提供了 NLCG、L-BFGS 和 TRN 三种优化器,TRN.ZIP 压缩包在目录结构上和其他方法分离,说明设计者从一开始就把它当作一个独立模块来维护。选择 TRN 的工程理由可以从 Hessian 矩阵的对角占优性来分析:FWI 中 Hessian 对角元对应照明强度,非对角元携带散射和多次波信息,L-BFGS 用有限个梯度差向量去近似 Hessian 的逆,这个近似在照明充足、大偏移距数据覆盖全面的情况下表现不错,但当数据缺失严重、反演进入强非线性区域,L-BFGS 的二阶近似就不够用了。TRN 在这里的优势是:通过 Hessian-vector product 保留了更完整的曲率信息,又不显式存储矩阵。

2.2 三种优化算法的边界对比

把 L-BFGS、高斯牛顿和截断牛顿放在同一张表里看,选型依据会清晰很多。

特性L-BFGSGauss-NewtonTruncated Newton
Hessian 存储仅存梯度历史向量不显式存储但需近似不显式存储
Hessian 精度有限阶近似忽略二阶项相对完整
每轮正演次数2 次(梯度+线搜索)3-4 次4-8 次(内迭代累加)
收敛速度线性到超线性接近二阶二阶
强散射介质表现易陷入局部极值中间状态最稳
工程复杂度

这张表反映的是一个核心取舍:TRN 每次迭代成本高于 L-BFGS,但它减少了总迭代轮数,尤其当问题本身非线性强时,TRN 用额外的内部正向模拟换取了更稳定的收敛路径。在 SEISCOPE 提供的默认配置里,TRN 的 CG 内迭代上限一般设为 10 到 30 轮,每轮内部 CG 迭代需要一次 Hessian-vector product,而 Hessian-vector product 的实现方式有两种:有限差分扰动法(对参数加扰动再算一次梯度)和二阶伴随法。SEISCOPE 的示例代码里默认用的是有限差分形式,因为它对已有正演代码的侵入最小,只需要多提供一次梯度计算入口即可。

2.3 CG 内迭代里的残留范数不等式

TRN 的核心机制是:在外迭代第 k 步,给定当前梯度 g_k 和 Hessian 作用算子 H_k,求解牛顿方向 p_k 满足 H_k p_k = -g_k。CG 从这个线性系统开始迭代,每一步都估算当前残差 r_i = H_k p_i + g_k。关键问题是:CG 到底迭代到什么时候停?答案由截断准则决定。常见做法是采用 Eisenstat-Walker 准则:当残差满足 ||r_i|| ≤ η_k ||g_k|| 时截断,η_k 在 0.1 到 0.5 之间随外迭代自适应调整。η_k 太小会导致内迭代次数爆炸,η_k 太大则内迭代没精度,外迭代退化成梯度下降。SEISCOPE 的 TRN 实现里,这个参数被封装成内部变量,用户能看到的是 memory 参数和 CG 最大迭代次数。

# 表示截断牛顿步求解核心流程的伪代码 def truncated_newton_step(m, g, hess_vec_prod, eta=0.3, max_cg=20): # g: 当前梯度, hess_vec_prod: Hessian-vector product 函数 r = -g # CG 初始残差 p = r # 初始搜索方向 rz_old = r.dot(r) for i in range(max_cg): Hp = hess_vec_prod(m, p) # 核心: 仅需一次正演与一次伴随 alpha = rz_old / p.dot(Hp) x += alpha * p # x 即为牛顿方向 r -= alpha * Hp if r.norm() <= eta * g.norm(): break # 截断条件 rz_new = r.dot(r) beta = rz_new / rz_old p = r + beta * p rz_old = rz_new return x

这段逻辑里最值得注意的参数是 hess_vec_prod:它不返回矩阵而是返回向量,这是 TRN 与经典牛顿法的本质区别。SEISCOPE 工具的普通用户不需要自己写 CG,只需要提供 hess_vec_prod 的接口实现——在波形反演中,这通常意味着把梯度计算函数对模型做一次小扰动,重新算一遍梯度再除以扰动值,这就是所谓的有限差分 Hessian-vector product。实际实现时扰动 δ 通常在 1e-6 到 1e-4 的量级,视浮点精度而定。

3. 导入 SEISCOPE 的 TRN 模块:从压缩包到可执行的最小工程

3.1 TRN.ZIP 解包后的核心文件定位

TRN.ZIP 的目录结构在 SEISCOPE 项目中有固定的组织方式。工具箱的主控程序通常是 optimiz.f90,根据用户选择的 method 参数分发到不同子模块。TRN 对应的文件是 pkg/optimization/trn_fwi.f90,核心模块包括外部迭代控制(outer loop)、CG 内迭代求解器(inner loop)和 有限差分 Hessian-vector product 的实现(compute_hessian_vect_prod)。最小工程不需要全部读懂,关键是找到三个接口函数的签名:cost_function(计算目标函数值和梯度)、hessian_vect_prod(计算 Hessian 与向量的乘积)、precond(预处理算子)。

# 解压并定位核心接口 (bash) unzip TRN.ZIP -d seiscope_trn cd seiscope_trn ls -la find . -name "*trn*" -o -name "*optimiz*" | head -20

SEISCOPE 的 Fortran 源码在 Linux 环境下的编译依赖 gfortran 和 make,进入源码根目录后,make 命令会生成 libseiscope.a 静态库和若干可执行示例程序。编译前需要确认编译配置文件 Makefile.inc 里的编译器路径和并行选项。如果机器配备 MPI 环境,建议开启并行编译,因为 FWI 的正演部分在频率域多炮并行时开销比较大。但如果只是验证 TRN 的反演流程,串行版就足够,一个 2D Marmousi 模型的最小示例在单核上运行大约需要几十分钟。

另外要留意 SEISCOPE 的数据格式约定:观测数据 d_obs 和模拟数据 d_cal 都存储为 SEISCOPE binary format,读入 Fortran 程序前,需要把模型参数按列优先顺序展平成一维数组。第一步测试建议不要用真实地震数据,而是用工具箱自带的示例数据(通常在 data/ 目录下),验证编译链路完整。

3.2 通过 Python 封装调用 TRN 核心

在真实工程项目中,大部分团队会用 Python 做上层反演编排、数据清洗和可视化,Fortran 优化器作为底层计算核心。把 TRN 模块封装成 Python 可调用的形式,需要用到 ctypes 或者 f2py。SEISCOPE 源码里不直接提供 Python 绑定,所以这个接口层需要自己写,我一般会暴露 3 个底层函数给 Python:

# 用 ctypes 调 Fortran 编译出的共享库 (python) import ctypes import numpy as np lib = ctypes.CDLL("./libseiscope_trn.so") # Fortran 子程序中, 所有参数按引用传递, 多维数组需按列优先展平 lib.trn_driver_.argtypes = [ ctypes.POINTER(ctypes.c_int), # n: 模型参数个数 ctypes.POINTER(ctypes.c_double), # m: 模型向量 (in/out) ctypes.POINTER(ctypes.c_double), # g: 梯度向量 ctypes.c_void_p, # 用户自定义数据指针 ctypes.POINTER(ctypes.c_double), # 目标函数值 ] # 调用示例: 输入初始模型 m0, 返回反演结果 m_final m0 = np.array([1500.0] * n_model, dtype=np.float64) fval = np.array([0.0], dtype=np.float64) lib.trn_driver_(n_model, m0, grad, None, fval)

这个封装有几个关键点:Fortran 数组默认按列优先存储,和 Python 默认的行优先不同,因此任何多维数组传给 Fortran 前必须先调用 np.asfortranarray 强制转换;函数名后面的下划线是 gfortran 对全局符号的修饰规则,如果是 intel ifort 则可能没有下划线——这也是用动态库前需要先用 nm 命令检查符号名的原因。此外,正演模拟部分如果本身是 Python 实现的(比如用 Devito 或 WaveFD 生成正演数据),则无法直接传给 Fortran 优化器做 Hessian-vector product,只能把正演和伴随全部迁移到 Fortran 端,这往往是把 SEISCOPE 集成到既有项目时最大的工作量所在。

3.3 forward/adjoint 对的正确性验证

在开始反演之前务必要验证梯度计算的正确性,因为 TRN 内部对梯度精度的依赖比 L-BFGS 更强——梯度有误差,CG 内迭代的残差计算立刻失真,截断准则就会失效。常见做法是有限差分验证:对目标函数沿某个方向加一个微小扰动 ε,比较解析梯度与数值梯度。

# Taylor 检验: (python) 验证梯度与 hessian_vect_prod 的精度 def taylor_test(model, direction, eps_list=[1e-2, 1e-4, 1e-6]): base_cost = cost_function(model)[0] base_grad = cost_function(model)[1] gd = base_grad.dot(direction) for eps in eps_list: perturbed = model + eps * direction J_new = cost_function(perturbed)[0] # 目标函数差值应随 eps 线性减少 print(f"eps={eps:.1e}, ratio={(J_new - base_cost)/eps:.6f}") # 期望输出: ratio 在 eps -> 0 时趋近 gd

如果 Taylor 检验的输出 ratio 和 gd 的差大于 1%,说明梯度接口有 bug,不应该继续反演。另一个是 HVP 的验证:利用 H·v ≈ [g(m+εv) - g(m)]/ε,检查 hessian_vect_prod 输出与有限差分结果是否一致。这个验证 TRN 内迭代的每一步都会用到,如果错了,CG 得到的牛顿方向就不可靠。

4. 让 TRN 跑起来的关键参数与典型坑

4.1 SEISCOPE 优化工具箱的参数文件语义

SEISCOPE 的参数输入文件遵循一段固定格式,控制优化循环的参数包括 method、niter_max、memory、CG 相关参数和线搜索参数。TRN 模式与 L-BFGS 的差异集中在 CG 相关参数上:L-BFGS 没有内迭代,而 TRN 用内存换精度。

参数名TRN 推荐范围作用调整代价
method3 (TRN)选择优化器
niter_max50 - 20外部迭代轮数越大越费算力
memory20 - 40预处理器 L-BFGS 的历史步数影响收敛速度
cg_niter_max10 - 30CG 内迭代上限大则精度高但慢
cg_eta0.1 - 0.5截断残差阈值小则内迭代次数剧增
ls_max10 - 20线搜索最大步数太大会甩出稳定区

设参数的首要原则是:当你不确定某个值该设多少时,让 cg_eta 偏大(接近 0.5),同时把 cg_niter_max 调到 10,这样 TRN 实际运行成本接近 L-BFGS;确认反演趋势正确后再逐步调高内迭代上限。这样先跑通再优化的策略在实际项目中能节省大量调试时间。SEISCOPE 还支持指定 preconditioner,TRN 内部常用 L-BFGS 或对角 Hessian 近似作为预处理,memory 参数在 TRN 中真实角色是预处理器保留多少梯度历史用于隐式近似 H 的逆,这个值设太大会拖累每次预处理的线性代数求解。

4.2 观察收敛日志的哪些字段

SEISCOPE TRN 在日志中输出的字段与 L-BFGS 不同:除了常规的 cost、g_norm、model_update_norm 外,CG 残差历史(CG_RESIDUAL)是判断内迭代是否过多或过少的关键信号。如果 cg_residual 一路从 1.0 下降到 0.05,然后平稳,说明截断准则合理;如果这个值在第一次或第二次 CG 迭代时已经低于截断阈值,说明 cg_eta 太松,TRN 退化成梯度下降;如果 CG 迭代到上限仍然没有达到截断阈值,说明 Hessian 病态严重,这时候应该考虑增强预处理而非增加 cg_niter_max。

日志里另一个值得关注的是 steplength。TRN 外迭代偶尔会出现 steplength 被线搜索压到极小值的情况。在 FWI 里这通常意味着 Hessian 里面有负曲率方向,模型更新不满足下降条件。SEISCOPE 在这种状况下会重置 CG 初始搜索方向,用户不需要干预,但如果这种问题在前几轮就出现,多半是初始模型太差或者观测数据里存在异常振幅。

4.3 常见失败模式与排查路径

TRN 在 FWI 中最常见的失败是波形反演发散,表现为 cost 函数在前几轮下降后突然跃升,随后梯度范数爆炸。排查路径按照以下步骤走:先检查正演是否稳定,用零延迟自相关对比模拟数据和观测数据主频是否一致;接着检查梯度符号,SEISCOPE 内部对权重和归一化的约定可能与你自己的代码不同,梯度多个负号,TRN 会一直往错误方向更新。最后才怀疑优化器本身。

常见陷阱是 FWI 目标函数中数据残差的单位规范化方式。SEISCOPE 默认是用整体数据 L2 范数做归一化,如果你自己写数据接口时又做了一次归一化,Hessian-vector product 里的扰动缩放就会错位。

# 检查一维模型梯度符号 (python) # 若 d_obs 是地震数据, model 是初始速度模型 # 正确做法: 分别计算 g = grad(J), 其中 J(data) = 2.0 * (F(m) - d_obs)

另外,TRN 内迭代的病态问题可以用数据预处理缓解,常用的手段是对地震道做时间增益补偿和带通滤波,让能量不集中在浅层。SEISCOPE 官方示例里对 Marmousi 模型做了子波估计、震源校正、静校正等步骤,这些在反演前的处理可以极大改善 CG 收敛速度。

5. 实测对比 L-BFGS 与 TRN 的收敛行为

用 SEISCOPE 的 TRN 模块跑穿 2D Marmousi 模型之后,最值得做的验证是把 L-BFGS 和 TRN 的结果放在同一坐标系下对比。这里的核心技巧是让两种方法停在同一梯度范数阈值下,然后比较对应模型的数据拟合度与成像清晰度。我一般会写一个循环脚本,分别调用 SEISCOPE 的 method=1 (L-BFGS) 和 method=3 (TRN),每 5 轮外迭代 dump 一次模型快照和目标函数。

一个典型观测结果:L-BFGS 在初始阶段下降速度比 TRN 快,因为它的初始步长较大、每轮成本低。但到了中后段,尤其在 3km/s 到 4.5km/s 深度区间存在高速层时,L-BFGS 的收敛曲线进入明显平台期,梯度范数在多个外迭代轮次陷入震荡;TRN 则平稳穿越这个区间,原因是 Hessian 中非对角项提供了界面处的曲率信息,帮助模型跳出梯度不敏感区。这个差异在数据缺大偏移距时更加明显——大偏移距数据的缺失会使 Hessian 的弱照明方向特征值接近零,L-BFGS 的近似在这些方向上过度放大噪声,而 TRN 的 CG 内迭代会因残差截断机制自动抑制这些无效方向。

需要特别指出的是:TRN 并非在所有反演场景中都胜出。如果观测系统覆盖均匀、初始模型接近真实速度,L-BFGS 因为每轮计算量更小,达到同等数据拟合度所需的总时间往往更短。在对计算成本极其敏感的生产环境中,一种常见的策略是把 L-BFGS 作为第一阶段优化,跑 10 轮后切换到 TRN 做精细修正;SEISCOPE 工具箱准许在运行中途切换优化器的标准做法是把当前模型与梯度状态导出,再以新输入的初值继续跑另一种方法。

如果你已经跑通上述流程、手头又有正演并行化能力,我强烈建议做一次内迭代历史的离线分析:把 CG 的残差序列 dump 到文件,用 matplotlib 画成半对数坐标图。你能清楚看到截断时机和收敛拐点。当残差曲线几乎垂直向下(一个数量级只花了 2-3 次 CG 迭代),说明预处理效果好;当残差曲线直线下降但到某个值后突然水平,说明 Hessian 特征值分散严重,CG 卡在了慢收敛模式。这种情况下,把 cg_eta 从默认值调大到 0.4 会让 TRN 更早截断,在损失少量精度的前提下避免无效内迭代。这个动作让实测总耗时可降低 30%-50%——这就是截断的意义。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询