1. 项目概述:为什么Ceres的损失函数选型是个技术活
在非线性优化领域,Ceres Solver早已是工程师和研究者手中的一把瑞士军刀。无论是做视觉SLAM中的Bundle Adjustment,还是做机器人运动学标定,甚至是金融模型的参数拟合,Ceres都以其稳健性和灵活性著称。但很多朋友,包括我早期在内,常常会陷入一个误区:把注意力全放在构建残差项和雅可比矩阵上,而把损失函数(LossFunction)的配置当作一个简单的“鲁棒核函数”开关,默认用个Huber或者Cauchy就完事了。实际上,损失函数的选择,直接决定了你的优化器在面对数据中的“捣蛋鬼”——也就是异常值(Outliers)时,是英勇“阵亡”还是从容“排雷”。它不是一个可有可无的装饰,而是整个优化问题鲁棒性的基石。
简单来说,Ceres中的损失函数,包裹在每一个残差项外面,它的核心作用是对残差的平方项(即我们通常说的二范数平方 \( r^2 \))进行重新加权。当某个残差特别大(很可能对应一个异常观测)时,一个设计良好的损失函数可以抑制其影响力,防止它“带偏”整个优化过程。今天,我们就来深挖一下Ceres中LossFunction的选择门道。这不仅仅是选哪个函数的问题,更是理解你的数据特性、问题本质,并在计算效率与模型鲁棒性之间找到最佳平衡点的过程。无论你是刚接触Ceres的新手,还是已经用它跑过不少实验的老兵,相信关于损失函数的这些细节和实战经验,都能让你对优化问题的构建有新的认识。
2. 损失函数的核心原理与Ceres中的角色定位
2.1 从最小二乘到鲁棒优化:损失函数的本质
要理解损失函数,我们得从最基础的最小二乘法(Least Squares)说起。标准的非线性最小二乘问题长这样: \[ \min_x \frac{1}{2}\sum_i \rho_i(\|f_i(x)\|^2) \] 在经典最小二乘中,\( \rho \) 就是恒等函数 \( \rho(s) = s \),即我们直接最小化所有残差 \( f_i(x) \) 的平方和。这里的隐含假设是:所有观测数据都服从高斯噪声,且没有异常值。
但现实很骨感。传感器会抽风,特征匹配会出错,总会混进来一些“不听话”的数据点。这些异常值会产生巨大的残差。由于平方项的增长速度极快(二次增长),这些大残差在目标函数中会占据主导地位。优化算法为了最小化这个被严重扭曲的目标函数,会不惜大幅调整其他所有正常参数去“迎合”这些异常值,导致最终估计结果严重偏离真实值。
损失函数 \( \rho(s) \) 的使命,就是改造这个平方项 \( s = \|f_i(x)\|^2 \)。它通过一个非线性变换,改变大残差对应的目标函数贡献度的增长速度。一个理想的损失函数应该具有这样的性质:对于小的残差(内点),其行为近似于平方函数,以保证在高斯噪声下的统计效率;对于大的残差(外点),其增长应该放缓(例如变为线性增长甚至饱和),从而限制异常值的影响力。
在Ceres中,当你为一个残差块添加LossFunction时,你并不是在替换残差模型,而是在修改优化目标中对这个残差项的“看法”和“容忍度”。
2.2 Ceres中损失函数的接口与计算机制
Ceres定义了一个纯虚基类LossFunction,核心是Evaluate方法。这个方法接收残差的平方值 \( s \),输出三个值:
rho[0]: \( \rho(s) \),即变换后的损失值。rho[1]: \( \rho'(s) \),一阶导数。rho[2]: \( \rho''(s) \),二阶导数。
你可能好奇,优化算法通常只需要一阶梯度(雅可比矩阵),为什么这里需要二阶导数?这是因为Ceres在内部使用高斯-牛顿法、列文伯格-马夸尔特法等算法时,需要构建近似的海森矩阵(\( J^T J \))。当引入损失函数后,海森矩阵的近似需要用到 \( \rho''(s) \)。具体地,加权后的梯度为 \( \rho'(s) \cdot J^T f \),而加权后的海森矩阵近似为 \( \rho'(s) \cdot J^T J + 2 \rho''(s) \cdot (J^T f)(f^T J) \)。Ceres的TrivialLoss(即标准最小二乘)对应 \( \rho'(s)=1, \rho''(s)=0 \),就退化回了我们熟悉的 \( J^T J \)。
理解这个计算机制很重要,因为它解释了:
- 为什么损失函数能生效:通过 \( \rho'(s) \) 缩放梯度,大残差对应的梯度被减小,从而其在参数更新中的影响力下降。
- 对算法的影响:不同的 \( \rho''(s) \) 会影响海森矩阵的结构,进而影响优化步长的计算。某些损失函数在 \( s \) 很大时 \( \rho''(s) \) 为负,可能导致海森矩阵不正定,影响算法稳定性。Ceres的实现已经妥善处理了这些问题。
注意:我们通常说的“核函数”(Kernel Function)在Ceres的语境里就是
LossFunction。在统计学和鲁棒估计领域,它可能被称为“影响函数”(Influence Function)或“鲁棒核函数”(Robust Kernel)。在Ceres的代码和文档中,统一使用LossFunction这个术语。
3. 内建损失函数全解析与选型指南
Ceres提供了一系列开箱即用的损失函数,每个都有其独特的性格和适用场景。选择哪一个,取决于你对异常值的假设和容忍度。
3.1 经典三剑客:Huber、Cauchy、Arctan
这是最常用的一组,它们提供了一个标度参数(a),用于控制函数从“二次行为”过渡到“线性行为”或“饱和行为”的拐点。
1. HuberLoss这是许多人的首选,也是我的默认起点。它的行为非常直观:
- 当残差小(\( s \leq a^2 \))时,它是标准的二次函数 \( \rho(s) = s \)。
- 当残差大(\( s > a^2 \))时,它变为线性函数 \( \rho(s) = 2a\sqrt{s} - a^2 \)。
- 参数
a的意义:可以直观理解为残差的阈值。当残差的绝对值(注意是 \( \sqrt{s} \))大于a时,损失函数切换为线性模式。a通常设置为测量噪声标准差的倍数,例如在视觉SLAM中,重投影误差的像素单位下,a=1.0(对应1像素)或a=1.345(一个统计学上的常用值)都是常见选择。 - 优点:计算简单,具有凸性,能保证全局收敛性(在满足条件下)。它对中等程度的异常值有良好的抑制效果。
- 缺点:对于极大的异常值,其线性增长意味着它仍然会赋予一定的权重,可能不足以完全屏蔽极端外点。
- 适用场景:数据质量相对较好,异常值比例不高(<10%),且异常值不是特别极端的情况。非常适合作为基线配置。
2. CauchyLoss柯西损失来源于柯西分布,其函数形式为 \( \rho(s) = a^2 \log(1 + s / a^2) \)。
- 行为:对于小残差,近似为二次;对于大残差,增长极其缓慢,近似对数增长。这意味着它对非常大的异常值有很强的抑制能力。
- 参数
a的意义:同样是一个尺度参数。减小a会使函数更“敏感”,更早地进入缓慢增长区;增大a则使其更接近二次损失。 - 优点:对重尾噪声和极端异常值的鲁棒性非常强。
- 缺点:在异常值比例很高时,可能会过度抑制数据,导致收敛变慢;且其非线性更强,可能带来更多的局部极小点。
- 适用场景:数据中存在显著且可能非常巨大的异常值,例如动态物体干扰下的视觉里程计、带有错误匹配的点云配准。
3. ArctanLoss反正切损失函数:\( \rho(s) = a^2 \cdot \arctan(s / a^2) \)。
- 行为:与Cauchy类似,但对大残差的抑制更加“温和”且具有饱和性。当 \( s \to \infty \) 时,\( \rho(s) \to a^2 \cdot \pi/2 \),即损失值有上界。
- 优点:饱和特性意味着它对极大异常值的影响力削减最为彻底,几乎完全忽略。
- 缺点:饱和特性也可能导致在优化后期,当参数接近真值时,那些被判定为“异常”但可能包含有用信息的数据点完全失去作用。
- 适用场景:你非常确信数据中存在完全错误的、需要彻底剔除的观测,而不是仅仅降低其权重。
3.2 其他内建损失函数速览
除了上述三个,Ceres还提供了其他选项,用于更特殊的场景:
SoftLOneLoss: 形式为 \( \rho(s) = 2 (\sqrt{1+s} - 1) \)。它是Huber损失的一种平滑近似,处处可微,没有显式的阈值a,其过渡是平滑的。在Bundle Adjustment的某些实现中(如OpenMVG)被使用。TolerantLoss: 这是一个双参数损失函数 \( \rho(s) = b \log(1 + e^{(s-a)/b}) - b\log(1+e^{-a/b}) \)。它通过参数a和b可以精确控制函数在原点附近的弯曲程度和线性区域的斜率,提供了极大的灵活性,适合当你对误差分布有非常具体的先验知识时进行精细调优。TrivialLoss: 即 \( \rho(s) = s \),标准最小二乘。用于确认问题本身或数据清洗后,无需鲁棒核函数。ScaledLoss: 这是一个包装器,可以对任何损失函数进行全局缩放。例如,new ScaledLoss(new HuberLoss(1.0), 0.5)会将Huber损失计算出的损失值乘以0.5。这在多传感器融合中,当你需要手动调整不同传感器残差的相对权重时非常有用,但通常更推荐通过信息矩阵(协方差的逆)来加权。
3.3 实战选型决策树与参数设置经验
面对这么多选择,如何快速决策?我总结了一个简单的决策流程:
第一步:评估数据质量
- 如果数据经过严格预处理,异常值极少 → 优先尝试
TrivialLoss(无) 或HuberLoss。 - 如果数据来源复杂,已知存在匹配错误或噪声较大 → 直接上
CauchyLoss或HuberLoss。
- 如果数据经过严格预处理,异常值极少 → 优先尝试
第二步:确定异常值严重程度
- 异常值大但比例不高 →
HuberLoss。 - 异常值巨大且可能很多 →
CauchyLoss。 - 需要完全剔除某些确信的错误数据 →
ArctanLoss。
- 异常值大但比例不高 →
第三步:设置尺度参数
a这是调参的关键。没有放之四海而皆准的值,但有一些经验法则:- 基于测量单位:如果残差是像素误差,
a=1.0是个好起点。如果是距离误差(米),a=0.1或a=0.01可能更合适。a的单位与残差f(x)的单位一致。 - 统计学方法:对于Huber,常取
a=1.345σ(σ为测量噪声的标准差),这能在高斯分布假设下保持95%的渐近效率。 - 试错法:这是最常用的。从一个估计值开始(如
a=1.0),运行优化,观察最终的重投影误差或残差直方图。如果很多残差远大于a,说明a设小了,异常值影响仍被过度抑制(对于Huber)或未被充分抑制(对于Cauchy/Arctan)。如果几乎所有残差都小于a,说明a设大了,损失函数没怎么起作用。理想情况是,大部分内点残差小于a,少数外点残差远大于a。 - 可视化:将优化后的残差绘制成直方图,
a的值应该落在内点分布尾部和外点起始区域之间。
- 基于测量单位:如果残差是像素误差,
第四步:交叉验证与效果评估
- 用不同的损失函数和参数
a运行优化。 - 比较最终的目标函数值、内点残差的均值/中位数、参数估计的合理性(如果已知ground truth)。
- 在SLAM或SfM中,可以直观查看重建的点云或轨迹,检查是否还有明显的“拉飞”的点或相机位姿。
- 用不同的损失函数和参数
实操心得:在大多数视觉几何问题中,我个人的经验是,
CauchyLoss配合一个稍保守的a值(例如a=0.5或a=1.0对于像素误差),往往能提供一个非常稳健的基线性能。它的“重尾”特性对视觉数据中常见的、由错误匹配导致的极端异常值非常有效。HuberLoss则更适用于对收敛速度和全局最优性有更高要求的场景,或者数据相对干净时。
4. 自定义损失函数:当内建函数无法满足需求
Ceres的内建函数覆盖了90%的场景,但总有那10%的特殊需求。比如,你的误差分布有特殊的形状,或者你想实现一个文献中提出的新核函数。这时就需要自定义。
4.1 实现自定义LossFunction类
自定义需要继承ceres::LossFunction并实现三个纯虚函数:Evaluate和两个Check方法(通常直接返回true)。核心是正确计算rho[0],rho[1],rho[2]。
举个例子,假设我们想实现一个“线性阈值损失”(Linear Threshold Loss),它的思想是:当残差绝对值小于阈值t时,用二次函数;大于t时,损失值保持为一个常数c(完全饱和)。
class LinearThresholdLoss : public ceres::LossFunction { public: explicit LinearThresholdLoss(double threshold, double constant_value) : t_squared_(threshold * threshold), c_(constant_value) {} virtual void Evaluate(double s, double rho[3]) const override { if (s <= t_squared_) { // 二次区域 rho[0] = s; rho[1] = 1.0; rho[2] = 0.0; } else { // 饱和区域 rho[0] = c_; rho[1] = 0.0; // 导数为0,梯度消失 rho[2] = 0.0; } } private: const double t_squared_; const double c_; };关键点解析:
- 导数连续性:在上面的简单实现中,在
s = t_squared_处,一阶导数从1.0跳变到0.0,是不连续的。这可能导致优化在阈值附近震荡。一个更工程化的实现会使用一个平滑的过渡函数(例如sigmoid)来连接两段,确保一阶甚至二阶导数的连续性。 - 二阶导数:在饱和区,我们设置了
rho[2] = 0.0。这对于Ceres内部计算海森矩阵近似是必要的。当rho[1]为0时,对应的残差项对海森矩阵没有贡献,这符合“完全忽略该观测”的直觉。 Check方法:示例中省略了。通常实现为bool Check() const { return true; },用于验证对象状态,在自定义中一般直接返回true。
4.2 自定义函数的应用场景与调试技巧
何时需要自定义?
- 特定的噪声模型:你的传感器或物理过程有已知的、非标准的误差分布。
- 算法对比实验:你需要复现论文中某个特定损失函数的效果。
- 性能优化:内建函数的计算在某些极端情况下可能成为瓶颈,而你有一个计算更简单的近似版本。
- 特殊逻辑:例如,你想实现一个随着迭代次数增加,阈值
a逐渐减小的“自适应”损失函数(这通常通过每轮迭代重新构建Problem来实现,而非在LossFunction内部)。
调试自定义损失函数:
- 单元测试:编写简单的测试程序,输入一系列
s值,打印rho[0],rho[1],rho[2],并手动计算或绘图验证其正确性。特别检查在阈值过渡点附近的数值行为。 - 梯度检查:利用Ceres的自动微分与你的自定义函数组合,使用有限差分法检查梯度的正确性。虽然Ceres不直接提供该工具,但你可以手动实现或利用其他库(如Google Test)进行数值梯度检验。
- 小规模问题验证:先在一个小的、可控的优化问题上(比如拟合一条直线,并人工添加异常值)测试你的自定义损失函数,观察其优化行为是否符合预期。
注意事项:自定义损失函数增加了代码复杂性和出错风险。除非内建函数确实无法满足需求,否则应优先使用经过充分测试的内建函数。如果必须自定义,务必进行严格的数学推导和数值测试,确保其导数计算正确,避免引入数值不稳定问题。
5. 高级策略与性能调优实战
选对了损失函数类型和参数,只算成功了一半。在实际的大型优化问题(如大规模BA)中,如何高效、正确地使用它们,还需要一些策略。
5.1 混合使用不同损失函数
一个Problem中的不同残差块,可以使用不同的损失函数。这是非常强大的功能。
- 场景示例:在视觉惯性SLAM中,视觉重投影误差可能使用
CauchyLoss来对抗错误匹配,而IMU预积分误差的噪声模型更接近高斯,且通常更可靠,可能使用HuberLoss甚至TrivialLoss。 - 实现方法:在调用
AddResidualBlock时,为每个代价函数传入对应的LossFunction指针即可。 - 好处:可以为不同来源、不同置信度的传感器数据施加不同级别的鲁棒性,更贴合物理实际。
5.2 损失函数与参数块规模的协同考虑
Ceres的LossFunction是作用于单个标量残差上的。对于一个残差块输出多维残差(例如,重投影误差是2维)的情况,损失函数是分别应用于每个残差分量后再求和。这意味着,一个2D重投影误差点,如果其在x和y方向上都因为异常值而很大,那么它会受到两次抑制。
这通常是合理的,但需要注意:对于某些定义为“距离”的残差(例如点到直线的距离、点到平面的距离),它本身就是一个标量,直接应用损失函数即可。
5.3 迭代重加权最小二乘的关联
使用鲁棒损失函数的优化过程,在数学上等价于迭代重加权最小二乘。每一次高斯-牛顿或LM迭代,可以看作是用当前残差计算权重(即 \( \rho'(s) \)),然后求解一个加权最小二乘问题。
- 启示:你可以手动实现IRLS。先不用损失函数运行一次优化,根据残差分布计算每个观测的权重(例如,用Huber或Cauchy的权重函数),然后以这些权重作为固定值,再进行一次(或多次)加权最小二乘优化。这在某些需要精确控制权重或调试权重计算时有用。
- Ceres的自动化:Ceres将这个过程完全自动化并集成在每一次迭代中,效率更高,且能处理权重随参数变化而动态变化的情况。
5.4 计算开销分析与选择建议
不同的损失函数有轻微的计算开销差异,但在绝大多数应用中,计算残差和雅可比矩阵的成本远高于计算损失函数及其导数。因此,性能通常不是选择损失函数的主要考量。
不过,在极端边缘场景(例如嵌入式设备上运行超大规模优化)下,可以遵循以下原则:
TrivialLoss(无) 是最快的。HuberLoss和SoftLOneLoss涉及开平方和条件判断,稍慢。CauchyLoss和ArctanLoss涉及对数、反正切等超越函数,理论上最慢。- 结论:除非在性能测试中明确发现损失函数成为瓶颈,否则应基于鲁棒性需求而非微小的计算差异来做选择。
6. 常见问题排查与实战避坑指南
在实际使用中,你会遇到各种奇怪的现象。下面是我踩过的一些坑和对应的解决方案。
6.1 优化结果不理想或发散
- 问题现象:加了损失函数后,优化反而发散,或者结果比不用损失函数更差。
- 排查思路:
- 检查尺度参数
a:这是最常见的原因。a值设置过小,会导致几乎所有数据都被当作异常值抑制,有效信息丢失,优化失去方向。尝试将a值增大一个数量级再试。 - 检查损失函数类型:对于初值很差的问题,使用像
ArctanLoss这样饱和性很强的函数,可能会过早地“杀死”所有梯度,导致优化停滞。初期尝试使用HuberLoss这种更温和的函数。 - 检查残差定义:确认你的残差
f(x)计算是否正确。一个错误的残差符号或尺度,会使损失函数基于错误的值进行判断。在添加损失函数前,先用TrivialLoss运行,确保优化能收敛到一个合理的结果。 - 可视化中间结果:在迭代回调函数中,输出当前迭代的损失值、梯度范数,以及残差的统计信息(如最大值、最小值、中位数)。观察损失函数是否在正常下降,大残差是否被有效抑制。
- 检查尺度参数
6.2 如何诊断损失函数是否起作用
- 方法一:比较最终残差分布。分别用
TrivialLoss和你选择的损失函数运行优化,将最终的所有残差值绘制成直方图。如果损失函数有效,你应该能看到使用鲁棒核后,残差分布的“长尾”(大值部分)被显著削减,整体分布更集中。 - 方法二:观察参数更新过程。在LM算法中,可以输出
mu(阻尼因子)的变化。如果使用损失函数后,优化过程需要更少的迭代次数或mu能更快地下降,说明问题条件数改善,优化更顺畅。 - 方法三:检查特定残差。如果你知道某些数据点很可能是外点,记录它们在优化过程中的残差变化。一个有效的损失函数应该能将这些点的残差权重(
rho[1])降得很低。
6.3 与自动微分的配合注意事项
当使用自动微分(AutoDiffCostFunction)时,损失函数是在自动微分之外起作用的。自动微分负责计算残差f(x)的雅可比矩阵J。然后,Ceres内部会计算rho[1]和rho[2],并用它们来缩放梯度并修正海森矩阵。
这意味着:
- 自定义损失函数的导数必须手动实现正确,自动微分不会帮你求
rho的导数。 - 不影响残差计算逻辑:你无需为了使用损失函数而修改残差仿函数的实现。
6.4 内存与所有权管理
这是一个C++编程的常见坑。Ceres的Problem::AddResidualBlock方法会接管你传入的CostFunction和LossFunction指针的所有权。这意味着:
- 不要手动删除:在
AddResidualBlock之后,切勿再delete这些指针,否则会导致程序崩溃。 - 使用堆内存:必须使用
new关键字在堆上创建这些对象。 - 对于内建损失函数,Ceres提供了工厂函数(如
new HuberLoss(1.0)),直接使用即可。 - 对于自定义损失函数,同样需要
new出来传入。
一个良好的实践是,在构建完整个Problem后,如果后续不再需要添加残差块,可以将这些指针保存到std::vector或其他容器中(尽管Ceres已接管所有权,我们保存指针是为了避免重复创建的逻辑,但实际内存管理权已移交),但更清晰的做法是让Problem对象在作用域结束时自动清理一切。
6.5 尺度参数a的自适应设置策略
手动调a很麻烦,有没有自适应的方法?有,但需要一些工程技巧。
- 两阶段法:
- 第一阶段:使用一个较宽松的损失函数(如
HuberLosswith a largea)或不用损失函数,进行初步优化。 - 第二阶段:根据第一阶段优化后的残差统计(例如,计算残差的中位数绝对偏差MAD),估算出内点残差的尺度,据此设置第二阶段的
a值(例如,a = 1.4826 * MAD,其中1.4826是针对高斯分布的系数)。
- 第一阶段:使用一个较宽松的损失函数(如
- 迭代更新法(更复杂):
- 在Ceres的迭代回调函数中,每次迭代后根据当前残差重新计算所有观测的权重(或
a值),然后在下一次迭代前,重建Problem(Ceres不支持动态修改已添加的LossFunction参数)。这种方法开销大,仅适用于特殊研究场景。
- 在Ceres的迭代回调函数中,每次迭代后根据当前残差重新计算所有观测的权重(或
对于绝大多数工程应用,基于对数据尺度的理解,进行几次手动调参并确定一个经验值,是最高效可靠的方式。例如,在视觉里程计中,重投影误差超过3-5个像素的点通常值得怀疑,那么a可以设置在1.5到2.5之间进行尝试。
最后,关于损失函数的选择,我的个人体会是,它更像是一门“艺术”而非纯粹的“科学”。它依赖于你对数据噪声特性的直觉和经验。从HuberLoss开始,它是一个安全且有效的起点。如果发现仍有明显的异常值干扰,就切换到抑制能力更强的CauchyLoss。永远记得,在调整损失函数的同时,前端数据处理的质量(如特征匹配的筛选、RANSAC)同样至关重要。一个好的损失函数是后端的“安全网”,但前端的“过滤器”做得越好,后端优化就越轻松、越稳定。