1. 为什么非线性最小二乘值得单独整理
如果你做过视觉SLAM、点云配准、曲线拟合、相机标定,或者任何需要"让模型输出逼近真实观测"的工程任务,那你大概率已经和非线性最小二乘打过照面了。它要解决的问题非常朴素:给定一组观测数据和一个带参数的模型,找一组参数,让模型预测和真实观测之间的残差平方和最小。线性情形有闭式解,一个正规方程就搞定了;但一旦模型对参数是非线性的,事情就变得麻烦起来——没有闭式解,只能迭代逼近。
我整理这份东西的初衷,是因为在实际项目里反复踩坑:同一个问题,用高斯-牛顿能收敛,换一组初值就发散;LM调了半天阻尼因子,结果发现是雅可比矩阵的数值精度在捣鬼;好不容易收敛了,结果掉进局部极小,参数物理意义完全不对。这些坑单看某一篇论文或某一个库的文档是串不起来的,必须把方法体系从头捋一遍。
这份整理适合三类人:一是刚接触优化、需要把理论落到代码上的工程师;二是做视觉/机器人/测绘,天天和BA、配准打交道但没系统梳理过方法的人;三是想自己手写一个轻量优化器、不想被庞大框架绑架的开发者。我会从问题定义讲到主流方法(高斯-牛顿、LM、Dog-Leg),再到雅可比计算、收敛判据、工程实现细节,最后给一份常见问题速查表。全程用大白话加公式加代码,尽量让你看完就能动手。
2. 问题定义与整体思路拆解
2.1 非线性最小二乘到底在优化什么
先把数学形式摆清楚。我们有一组观测,通常写成残差向量的形式:
r(x) = [r1(x), r2(x), ..., rm(x)]^T其中 x 是 n 维待优化参数,每个 ri(x) 是第 i 个观测对应的残差(预测值减观测值,或者反过来,符号约定统一即可)。目标函数是:
F(x) = 0.5 * ||r(x)||^2 = 0.5 * sum(ri(x)^2)前面那个 0.5 是习惯,求导时能约掉平方的 2,让梯度形式更干净。非线性最小二乘的核心任务,就是找 x使 F(x) 最小*。
为什么强调"非线性"?因为如果 r(x) = Ax - b 是线性的,那 F(x) 是二次函数,直接令梯度为零得到正规方程 A^T A x = A^T b,一步求解。非线性情况下,F(x) 可能有很多局部极小、鞍点,梯度为零只是必要条件,而且方程本身也解不出来,只能迭代。
这里有个关键认知:非线性最小二乘本质上是无约束优化问题的一个特例,只不过目标函数有"平方和"这个特殊结构。这个结构非常重要,它让我们可以利用雅可比矩阵而不是完整的海森矩阵,省下大量计算。后面讲高斯-牛顿时会重点说这一点。
2.2 迭代法的通用框架
所有非线性最小二乘方法都遵循同一个套路,我把它抽象成五步:
- 给定初值 x0:这一步的选取极其关键,后面会专门讲。
- 在当前点计算残差 r(xk) 和雅可比 J(xk):J 是 m×n 矩阵,第 i 行第 j 列是 ∂ri/∂xj。
- 求解一个子问题,得到增量 Δx:不同方法的区别几乎全在这一步。
- 更新 x_{k+1} = xk + Δx(或 xk + α·Δx,带线搜索)。
- 判断收敛:满足条件就停,否则回到第 2 步。
这个框架看起来简单,但每一步都有讲究。初值选不好直接发散;雅可比算错收敛慢甚至不收敛;子问题求解方式决定了收敛速度和鲁棒性;收敛判据设不好要么提前停要么死循环。我见过太多项目在这几个环节出问题,所以后面会逐个拆。
2.3 方法选型的核心权衡:速度 vs 鲁棒性
非线性最小二乘的方法谱系,本质上是在两个极端之间找平衡:
- 高斯-牛顿(GN):在残差接近零或者问题接近线性时,收敛速度是二次的,非常快。但它对初值敏感,雅可比病态时增量会爆炸,残差大时可能根本不下降。
- 梯度下降:最鲁棒,保证下降方向,但收敛是线性的,慢得让人抓狂,尤其在最优点附近几乎不动。
- 列文伯格-马夸尔特(LM):在高斯-牛顿和梯度下降之间插一个阻尼因子,残差大时偏向梯度下降(稳),残差小时偏向高斯-牛顿(快)。这是工程上最常用的默认选择。
- Dog-Leg:另一种信赖域方法,结合了高斯-牛顿步和梯度下降步,用一条折线找信赖域内的最优步。理论上比 LM 更优雅,实际中也有不少库在用。
选型的经验法则:如果你不确定用哪个,先用 LM。它对初值的要求比 GN 宽松得多,收敛也够快。如果你的问题残差能降到很小、雅可比条件数也好,那 GN 会更快。Dog-Leg 适合那些 LM 调参调到崩溃的场景,但实现复杂度更高。
3. 核心方法深度解析与实操要点
3.1 高斯-牛顿法:快,但别乱用
高斯-牛顿的推导很漂亮。对残差做一阶泰勒展开:
r(x + Δx) ≈ r(x) + J(x) Δx代进目标函数:
F(x + Δx) ≈ 0.5 * ||r(x) + J Δx||^2对 Δx 求导令其为零,得到高斯-牛顿方程:
(J^T J) Δx = -J^T r这个方程的形式和线性最小二乘的正规方程一模一样,左边 J^T J 是 n×n 的近似海森矩阵,右边 -J^T r 是梯度。解出来就是 GN 步。
为什么 GN 快:因为它用 J^T J 近似了真实海森矩阵,丢掉了残差的二阶导数项。当残差接近零时,这个近似非常准,所以收敛接近二次。但反过来,当残差很大时,丢掉的项不能忽略,GN 步可能不是下降方向,甚至让目标函数变大。
实操中 GN 有几个致命陷阱:
- J^T J 奇异或接近奇异:比如参数冗余、观测不足、或者某些参数在当前点不可辨识。这时候解方程会得到巨大的 Δx,直接把参数甩飞。解决办法是加一个小的对角正则项(本质上是往 LM 靠),或者做参数化精简。
- 初值离最优解太远:GN 没有全局收敛保证,初值不好直接发散。我踩过的坑是相机标定里焦距初值给错,GN 迭代三次就 NaN 了。
- 残差量纲差异大:比如一个残差是像素级(几十),另一个是角度(零点几),J^T J 的条件数会很难看。这时候要么归一化残差,要么上 LM。
提示:GN 适合作为"最后精修"的手段。先用 LM 或带线搜索的方法把参数拉到最优点附近,再切 GN 快速收敛,这个组合在实际项目里非常好用。
3.2 列文伯格-马夸尔特:工程默认选择
LM 的核心思想是给 GN 方程加一个阻尼项:
(J^T J + λ I) Δx = -J^T r或者更一般地,用对角矩阵 D 代替 I:
(J^T J + λ D) Δx = -J^T rλ 是阻尼因子,D 通常取 J^T J 的对角元素(这叫 Marquardt 缩放,比单位阵效果好,因为它考虑了各参数的量纲差异)。
λ 的调节逻辑是 LM 的灵魂:
- λ → 0:退化成 GN,快但激进。
- λ → ∞:Δx ≈ -(1/λ) J^T r,方向变成梯度下降方向,步长很小,稳但慢。
实际算法里,λ 是自适应调整的。一个典型的策略是:
- 用当前 λ 算 Δx,计算实际的目标函数下降量。
- 如果下降量大于某个阈值(说明这一步有效),接受更新,并减小 λ(比如除以 3 或 10),让下一步更接近 GN。
- 如果下降量不够甚至上升,拒绝更新,增大 λ(比如乘以 3 或 10),重新算 Δx。
这个"试探-接受/拒绝-调 λ"的循环,就是 LM 鲁棒性的来源。它保证了每次接受的步都让目标函数下降(在数值精度允许范围内),所以不会像 GN 那样乱飞。
LM 的实操要点:
- 初始 λ 的选取:常见做法是取 J^T J 对角元素的某个比例,比如 λ0 = τ · max(diag(J^T J)),τ 取 1e-3 到 1e-6。取太小前期可能不稳,取太大前期收敛慢。
- λ 的更新因子:增大用 10,减小用 0.1 是经典配置。也有用 2 和 0.5 的,收敛更平滑但迭代次数多。我一般用 10/0.1,配合最大迭代次数兜底。
- 收敛判据:不能只看 Δx 小,还要看梯度小、目标函数变化小。三个判据同时用比较稳。
- 数值稳定性:J^T J + λD 的求解用 Cholesky 分解,比直接求逆快且稳。如果 Cholesky 失败(矩阵非正定),说明 λ 太小,直接增大 λ 重试。
3.3 Dog-Leg 与信赖域思路
Dog-Leg 属于信赖域方法,思路和高斯-牛顿、LM 都不太一样。它不直接调阻尼因子,而是先定义一个信赖域半径 Δ,认为在这个半径内,二阶泰勒近似是可信的,然后在这个球内找最优步。
Dog-Leg 的名字来自它的求解路径:它构造一条从当前点出发的折线,先沿梯度下降方向走,再折向高斯-牛顿步,像狗腿一样。具体来说:
- 计算高斯-牛顿步 Δx_gn。
- 如果 ||Δx_gn|| ≤ Δ,直接用 GN 步(说明 GN 步在信赖域内,可信)。
- 否则,计算梯度下降方向的最速下降步 Δx_sd(通常取到信赖域边界),然后在这两个方向张成的二维子空间里找最优步。
Dog-Leg 的理论保证比 LM 更清晰:它明确控制每步的"可信程度",收敛性分析更完整。但实现上比 LM 复杂,需要处理信赖域半径的更新、折线求交等细节。实际工程里,LM 因为实现简单、效果够好,用得更多;Dog-Leg 在一些对收敛性要求极高的库(比如某些标定工具)里能见到。
信赖域半径 Δ 的更新逻辑和 LM 的 λ 类似,但方向相反:如果实际下降和预测下降的比值 ρ 接近 1,说明近似很准,扩大 Δ;如果 ρ 很小甚至为负,说明近似不准,缩小 Δ。这个 ρ 叫增益比,是信赖域方法的核心指标。
3.4 三种方法的对比与选型表
| 方法 | 收敛速度 | 鲁棒性 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 高斯-牛顿 | 接近二次(残差小时) | 差 | 低 | 初值好、残差小、精修阶段 |
| 列文伯格-马夸尔特 | 介于线性与二次之间 | 好 | 中 | 通用默认,初值一般 |
| Dog-Leg | 接近二次(信赖域内) | 好 | 高 | 对收敛性要求高、LM 调参困难 |
选型建议:新项目直接上 LM,把 λ 的自适应逻辑写对,90% 的场景够用。如果发现 LM 收敛慢,检查是不是 λ 初始值太大或者更新因子太保守。如果 LM 都不收敛,那大概率是雅可比算错了或者模型本身有问题,换 Dog-Leg 也救不了。
4. 雅可比矩阵:算不对全白搭
4.1 解析雅可比 vs 数值雅可比
雅可比矩阵 J 是整套方法的命脉。它的计算方式直接决定收敛速度和稳定性。两条路:
- 解析雅可比:手推导数公式,代码里直接写。快、准,但推导容易出错,模型一改就得重推。
- 数值雅可比:用有限差分近似,∂ri/∂xj ≈ (ri(x + h·ej) - ri(x)) / h。实现简单,但精度受 h 影响,计算量是解析的 n 倍(每个参数都要扰动一次)。
我的经验是:原型阶段用数值雅可比快速验证,性能敏感或精度要求高的场景换解析雅可比。数值雅可比的 h 选取很讲究:太大截断误差大,太小舍入误差大。理论最优 h 大约是 sqrt(ε) · |xj|,ε 是机器精度(double 约 2.2e-16),所以 h 大概在 1e-8 量级。但实际中参数尺度差异大,我一般用 h = 1e-6 · max(|xj|, 1),实测比较稳。
中心差分比前向差分精度高一个量级:
∂ri/∂xj ≈ (ri(x + h·ej) - ri(x - h·ej)) / (2h)代价是计算量翻倍。如果残差函数计算不贵,中心差分很划算。
4.2 雅可比的计算成本与稀疏性利用
在 BA(光束法平差)这类问题里,参数可能上万,观测几十万,雅可比是巨大的稀疏矩阵。如果当成稠密矩阵算和存,内存直接爆。这时候必须利用稀疏性:
- J^T J 的稀疏结构:BA 里 J^T J 通常是块对角加边(箭头形),可以用 Schur 补先消元,把大问题拆成小问题。
- 雅可比的块结构:每个观测只依赖少数几个参数,J 的每一行只有几个非零块。存储和计算都按块来。
这些优化能把 BA 的复杂度从 O(n^3) 降到接近 O(n),是工程上必须掌握的。不过这是个大话题,这里先点一下,核心思想就是别把稀疏问题当稠密算。
4.3 数值雅可比的步长选择实操
我写过一个数值雅可比的调试工具,专门用来对比解析和数值的差异。做法是:对每个参数,分别用解析和数值算导数,打印相对误差。如果某个参数的误差超过 1e-4,基本可以断定解析推导有错。
步长选择的实操建议:
- 先用 h = 1e-6 试,看收敛情况。
- 如果收敛慢或震荡,试试 1e-8 和 1e-4,对比迭代次数。
- 对量纲差异大的参数,用相对步长 h · |xj| 而不是绝对步长。
- 中心差分优先,除非计算成本实在扛不住。
注意:数值雅可比在参数接近零时容易出问题,因为相对步长退化成绝对步长。这时候要么给参数加一个偏移,要么用绝对步长兜底。
5. 完整实操流程与关键环节实现
5.1 从零实现一个 LM 优化器
我用 Python 写一个最小可用的 LM,把前面讲的逻辑串起来。假设残差函数和雅可比函数由用户提供。
import numpy as np def lm_optimize(residual, jacobian, x0, max_iter=100, lambda0=1e-3, nu=10.0, tol_grad=1e-8, tol_step=1e-8, tol_cost=1e-10): x = np.array(x0, dtype=float) n = len(x) lam = lambda0 r = residual(x) cost = 0.5 * np.dot(r, r) for it in range(max_iter): J = jacobian(x) g = J.T @ r # 梯度 H = J.T @ J # 近似海森 D = np.diag(np.diag(H)) # Marquardt 缩放 D = np.where(D == 0, 1.0, D) # 防止除零 # 收敛判据:梯度足够小 if np.linalg.norm(g, np.inf) < tol_grad: break improved = False for _ in range(20): # 内层调 lambda A = H + lam * D try: dx = np.linalg.solve(A, -g) except np.linalg.LinAlgError: lam *= nu continue x_new = x + dx r_new = residual(x_new) cost_new = 0.5 * np.dot(r_new, r_new) # 实际下降 vs 预测下降 predicted = -g @ dx - 0.5 * dx @ H @ dx actual = cost - cost_new rho = actual / predicted if predicted != 0 else -1 if rho > 0 and cost_new < cost: x = x_new r = r_new cost = cost_new lam = max(lam / nu, 1e-12) improved = True break else: lam *= nu if not improved: break # 无法下降,退出 # 收敛判据:步长和目标变化 if np.linalg.norm(dx) < tol_step * (np.linalg.norm(x) + tol_step): break if abs(cost - cost_new) < tol_cost: break return x, cost, it这段代码有几个关键点值得说:
- 增益比 rho 的计算:预测下降用二阶泰勒近似 -g·dx - 0.5·dx^T H dx,实际下降是 cost - cost_new。rho 接近 1 说明近似准,接近 0 或负说明不准。这个指标比单纯看 cost 是否下降更有信息量。
- 内层循环调 λ:最多试 20 次,每次失败就乘 nu。如果 20 次都不行,说明当前点附近找不到下降方向,直接退出。
- D 矩阵的处理:用 J^T J 的对角元素做缩放,但要对零元素兜底,否则除零。
- 收敛判据三重:梯度、步长、目标变化,任一满足就停。实际中梯度判据最可靠,步长判据容易在病态问题里误判。
5.2 一个曲线拟合的完整案例
拿一个经典的指数拟合练手。模型是 y = a · exp(b · t) + c,观测数据带噪声。参数 x = [a, b, c]。
残差函数:
def residual(x): a, b, c = x return a * np.exp(b * t) + c - y_obs def jacobian(x): a, b, c = x e = np.exp(b * t) J = np.zeros((len(t), 3)) J[:, 0] = e # d/da J[:, 1] = a * t * e # d/db J[:, 2] = 1.0 # d/dc return J这个问题的坑在于:b 的初值极其敏感。如果 b 初值符号错了,指数项要么爆炸要么衰减到零,LM 很难拉回来。我的做法是先用对数变换或者网格搜索给一个粗略初值,再上 LM 精修。
实测下来,初值 [a=1, b=0.1, c=0] 在大多数数据上能收敛,迭代 15 到 30 次,最终 cost 在 1e-6 量级。如果初值给 [1, -0.1, 0],大概率发散或者收敛到错误的局部极小。
5.3 收敛判据的设置与调试
收敛判据设不好,是很多"优化不收敛"问题的真凶。我总结了几条:
- 梯度判据:||g||_inf < 1e-8 是常用值。但注意,如果残差量纲很大(比如像素级),梯度天然就大,这个阈值要相应放大。更好的做法是用相对判据,比如 ||g|| / (1 + |cost|) < 1e-8。
- 步长判据:||dx|| < 1e-8 · (||x|| + 1e-8)。加 1e-8 是防止 x 接近零时判据失效。
- 目标函数判据:|cost_new - cost| < 1e-10 · (1 + cost)。同样用相对形式。
- 最大迭代次数:一定要设,100 到 200 是常见值。防止死循环。
调试技巧:把每次迭代的 cost、||g||、||dx||、λ 都打印出来。如果 cost 震荡不降,看 λ 是不是太小;如果 cost 几乎不变但 ||g|| 还很大,看雅可比是不是算错了;如果 ||dx|| 突然变得巨大,看 J^T J 是不是奇异了。
6. 常见问题与排查技巧实录
6.1 不收敛、发散、震荡的排查路径
我把实际项目中遇到的典型问题整理成一张速查表:
| 现象 | 可能原因 | 排查方法 | 解决手段 |
|---|---|---|---|
| 迭代几步后 NaN | 步长爆炸,J^T J 奇异 | 打印 | |
| cost 震荡不降 | λ 太小,步太激进 | 看 rho 是否频繁为负 | 增大 λ 更新因子,加线搜索 |
| cost 几乎不变 | 雅可比错误或参数不可辨识 | 对比解析/数值雅可比 | 修正雅可比,精简参数 |
| 收敛到错误解 | 局部极小或初值差 | 多组初值对比 | 换初值,加先验约束 |
| 收敛慢 | λ 太大或缩放不当 | 看 λ 变化曲线 | 调小 λ0,用 Marquardt 缩放 |
| 参数物理意义不对 | 参数冗余或过拟合 | 看 J^T J 条件数 | 加正则,减参数 |
这张表是我踩坑踩出来的,基本覆盖了 80% 的现场问题。排查的第一原则是打印中间量,别靠猜。cost、梯度范数、步长范数、λ、rho,这五个量打出来,问题基本就定位了。
6.2 初值选取的实战经验
初值选取没有万能公式,但有几条通用策略:
- 物理意义约束:参数有物理含义时,初值必须落在合理范围。比如焦距不能为负,深度不能为零。
- 网格搜索:参数少(2 到 3 个)时,粗网格搜一遍,取 cost 最小的点做初值。成本可接受,效果很好。
- 分步求解:先固定一部分参数解另一部分,再联合优化。比如标定里先解内参再解外参。
- 多初值重启:随机撒几组初值,各自跑 LM,取最优。适合参数少、单次优化快的场景。
- 利用先验:如果有历史数据或相似问题的解,直接拿来当初值,往往比随机猜好得多。
我个人的习惯是:任何非线性优化任务,先花 20% 的时间在初值上。初值选对了,后面 80% 的调参时间都能省下来。
6.3 数值精度与尺度问题的避坑
数值精度问题很隐蔽,但杀伤力大。几个典型场景:
- 残差量纲差异大:比如同时优化像素误差和角度误差。解决方法是给每个残差乘一个权重,让它们的量级接近。权重怎么定?看你对哪个更在意,或者用观测噪声的倒数。
- 参数尺度差异大:比如一个参数是 1e6,另一个是 1e-3。J^T J 的条件数会爆炸。解决方法是参数归一化,或者用 Marquardt 缩放(D 取对角元素)。
- 数值雅可比步长不当:前面讲过,h 取 1e-6 到 1e-8,中心差分优先。
- 累加误差:迭代几百次后,浮点误差累积可能让 cost 不再下降。这时候用双精度,或者定期重新计算残差。
提示:如果 J^T J 的条件数超过 1e12,基本可以断定有尺度问题或者参数冗余。先做参数归一化,再考虑优化算法。
6.4 局部极小与全局优化的取舍
非线性最小二乘只能保证收敛到局部极小。全局最优需要额外手段:
- 多初值重启:最简单粗暴,适合低维问题。
- 模拟退火/遗传算法:全局搜索能力强,但慢,通常只用来找初值,再用 LM 精修。
- 凸松弛:某些特定问题(如旋转平均)有凸松弛方法,能保证全局最优,但适用范围窄。
- 问题重构:换个参数化方式,让目标函数更接近凸。比如用四元数代替欧拉角,避免万向锁和周期跳变。
实际工程里,多初值重启 + LM 精修是性价比最高的组合。全局优化算法除非问题特别难,否则不划算。
7. 工程落地的一些个人体会
写到这里,方法体系基本讲完了。最后分享几个我在实际项目里反复验证过的经验。
第一,别迷信算法,先检查模型和雅可比。我遇到过好几次"LM 不收敛",最后发现是雅可比推导错了一个符号。花十分钟对比解析和数值雅可比,比调一天 λ 有用得多。
第二,λ 的自适应逻辑比初始值重要。初始 λ 大一点没关系,只要更新逻辑对,几步就能降下来。但如果更新因子太保守(比如用 2 和 0.5),收敛会慢很多。我一般用 10 和 0.1,配合增益比判断,实测很稳。
第三,收敛判据要松紧适度。太松提前停,结果精度不够;太紧死循环,浪费时间。我的默认配置是梯度 1e-8、步长 1e-8、目标 1e-10,配合最大 100 次迭代,覆盖大多数场景。
第四,把优化过程可视化。cost 随迭代的曲线、参数轨迹、残差分布,画出来一眼就能看出问题。纯看数字容易漏掉模式。
第五,留好退路。优化失败时,返回最后一次有效的参数和 cost,别返回 NaN。工程代码里,鲁棒性比理论最优更重要。
这个内容后续还可以往几个方向扩展:一是稀疏雅可比和 Schur 补在 BA 里的具体实现;二是鲁棒核函数(Huber、Cauchy)怎么处理外点;三是自动微分替代手推雅可比。这几个都是实际项目里绕不开的话题,有机会再单独整理。