做非线性优化的朋友应该都有体会,Ceres里最常用的就是CostFunction、LossFunction、LocalParameterization这三件套。但如果你遇到过“残差已经算好了,我还想在它进入优化器之前再做一层加工”这种需求,多少会有点别扭。改CostFunction里的Evaluate吧,数学逻辑和工程细节全搅在一起;不改吧,某些场景又确实需要这一步。我在读Ceres源码的时候发现它其实提供了一个现成的包装类:ConditionedCostFunction。这篇文章就对着源码把它的实现拆开讲清楚,包括它到底解决了什么问题、Evaluate里那个雅可比矩阵是怎么被巧妙改写的,以及在什么场景下你才值得用它。下面直接进入正文。
1. 为什么需要ConditionedCostFunction:残差后处理的典型场景
1.1 初看类名,第一反应是“多包一层有什么用”
我第一次在Ceres头文件里看到ConditionedCostFunction时,内心其实是有点不以为然的:既然我能手写Evaluate,那在函数最后一句话把residuals改一改不就行了吗,为什么要专门包一层?这种想法在上手写了几个自定义CostFunction之后就会被现实打脸。
最核心的问题是耦合。你写好的量测模型,比如点到平面距离、重投影误差,这些函数本质上是“传感器物理模型 + 几何关系”的数学表达。但实际优化时,你往往还想对残差做别的事情:某个分量要从米制改成毫米制、某个分量要往一个目标值上拉、某个分量在超过一定范围后要减弱它对优化方向的牵引力。如果这些都塞进同一个Evaluate,量测模型和后处理策略就彻底绑死了。
而ConditionedCostFunction的思路非常直接:它把“残差后处理”单独抽出来,做成一个独立的包装器,内部持有一个普通的CostFunction和一组Condition。CostFunction负责算原始残差和雅可比,Condition负责把每个残差分量再做一次标量变换。
1.2 它和LossFunction的分工是不一样的
很多读者会想:残差加权不是有LossFunction吗?HuberLoss、SoftLOneLoss这些不是现成的吗?这里要先把两者的边界划清楚。
LossFunction的作用点在整个残差块的平方范数上。Ceres算完一个残差块的平方和之后,用rho(f(x)^T f(x))乘一下,从整体上改变这个残差块对梯度方向的贡献。它不改变残差向量里的具体数值。
ConditionedCostFunction则完全不同。它改变的是残差向量本身:每个残差分量经过Condition变换后,数值被替换成新的值,雅可比矩阵也会按照链式法则同步更新。之后再进入LossFunction做整体加权。所以两者是“先变换、后加权”的串联关系,不是同一层作用。
1.3 三个值得用它而不是手写Evaluate的场景
| 场景 | 不用它时的做法 | 用它时的做法 |
|---|---|---|
| 不同残差分量量纲不一致(角度和米混在一起) | 在CostFunction里硬乘固定系数,散落各处的magic number | 对指定残差分量套一个ScaleCondition |
| 希望某个残差分量尽量逼近一个先验目标值 | 额外开一个残差项,和原残差形成两段逻辑 | 对已有残差项套一个EqualityCondition,目标值作为构造参数传入 |
| 残差超过一定范围后需要弱化牵引力(软截断) | 在Evaluate里加if分支,导数也要跟着分情况 | 套一个分段线性或soft clamping的Condition |
这三种需求有一个共同点:它们都是对残差的“后处理”,而不是对参数化模型的修改。所以用一个包装类来做,反而比改CostFunction本身更干净。
2. 源码骨架解读:构造、析构与成员隐藏约定
2.1 头文件里藏着的那点成员
不管手头是哪个小版本,头文件里这个类的主体结构基本都长这样:
namespace ceres { class CERES_EXPORT ConditionedCostFunction : public CostFunction { public: ConditionedCostFunction(CostFunction* wrapped_cost_function, const std::vector<Condition*>& conditions); ~ConditionedCostFunction() override; bool Evaluate(double const* const* parameters, double* residuals, double** jacobians) const override; private: std::unique_ptr<CostFunction> wrapped_cost_function_; std::vector<Condition*> conditions_; }; } // namespace ceres注意这里有几个信息量很大的点。
第一,它本身是一个CostFunction的子类,所以你可以直接把它的实例交给Problem::AddResidualBlock,对外完全透明。第二,它没有自己的参数块,所有参数块的配置全部透传给内部被包装的那个CostFunction。第三,被包装的CostFunction用智能指针管理,而conditions_是一个裸指针的vector。这个细节直接决定了后面要注意的所有权问题。
2.2 构造函数的两个核心职责:数量校验与元数据透传
从源码阅读的角度看,构造函数其实只做了两件事,但每一件都必不可少。
第一件事是防御性校验。既然Condition是逐分量绑定残差的,那么conditions的数量就必须和底层CostFunction的残差维数完全一致。不一致的话程序会直接崩溃在CHECK上,而不是等到优化迭代时才报一些莫名其妙的错。
第二件事是把元数据透传上去。底层CostFunction有num_residuals()和parameter_block_sizes()这两个接口,ConditionedCostFunction不能只是转发调用,因为它自己也是CostFunction,Problem在AddResidualBlock阶段会直接读它自身的元数据来分配内存、初始化雅可比矩阵。
ConditionedCostFunction::ConditionedCostFunction( CostFunction* wrapped_cost_function, const std::vector<Condition*>& conditions) : wrapped_cost_function_(wrapped_cost_function), conditions_(conditions) { CHECK(wrapped_cost_function_ != nullptr); CHECK_EQ(wrapped_cost_function_->num_residuals(), conditions_.size()); set_num_residuals(wrapped_cost_function_->num_residuals()); *mutable_parameter_block_sizes() = wrapped_cost_function_->parameter_block_sizes(); }这个透传逻辑是我读了源码之后才注意到的重要细节。如果漏掉这一步,外部看起来这个CostFunction的残差数是0,参数块列表是空的,后面优化器在分配内存的时候直接就是灾难。
2.3 析构函数与所有权约定
新版本里wrapped_cost_function_是智能指针,析构时自动释放。而conditions_是裸指针数组,需要手动遍历释放。两个加起来,意味着一个最重要的所有权约定:一旦你把一个Condition对象传给了ConditionedCostFunction,它的生命周期就交给这个包装类了,外面不能再手动delete。
我见过好几次“双重释放”和“悬垂指针”都是从这来的。典型错误是:在函数栈上建了一个Condition局部对象,然后传给包装类,包装类析构时对一个栈对象做delete,直接崩溃;或者两个ConditionedCostFunction共享同一个Condition*,第一个析构把对象释放了,第二个析构时重复释放。
从工程实践角度,我的习惯是让ConditionedCostFunction成为唯一的所有者,并且尽可能用一个函数把所有new出来的对象一次性组装好,减少中途出错的概率。
3. Evaluate第一步:原始残差是怎么被Condition逐个替换的
3.1 一切从调用底层CostFunction开始
Evaluate是整个包装器的心脏,也是源码阅读的重点。它的第一步非常朴素:先把参数原封不动传给内部那个被包装的CostFunction,让它计算原始残差和原始雅可比。
bool ConditionedCostFunction::Evaluate(double const* const* parameters, double* residuals, double** jacobians) const { bool success = wrapped_cost_function_->Evaluate(parameters, residuals, jacobians); if (!success) { return false; } // 接下来是残差逐分量变换的循环 // ... }这个设计有一个很好的副作用:底层CostFunction内部对雅可比矩阵的布局要求完全不用变,ConditionedCostFunction只是站在外面“加工”它的输出。所以你可以拿着任何已有的CostFunction直接包一层,而不需要改原类。
3.2 逐分量处理:每个Condition只面对一个标量
接下来是核心循环。源码逻辑大致是这样的:
int num_residuals = wrapped_cost_function_->num_residuals(); int num_parameter_blocks = wrapped_cost_function_->parameter_block_sizes().size(); std::vector<double> condition_residuals(num_residuals); std::vector<double> condition_jacobians(num_residuals); for (int i = 0; i < num_residuals; ++i) { bool alloc_jacobian = (jacobians != nullptr); if (conditions_[i]->Evaluate(&residuals[i], &condition_residuals[i], alloc_jacobian ? &condition_jacobians[i] : nullptr)) { return false; } residuals[i] = condition_residuals[i]; }这里面的结构非常清晰。对第i个残差分量,把旧的residuals[i]作为Condition的输入,Condition计算出新的残差值,先存进condition_residuals[i],最后再统一写回residuals[i]。
这里有两个细节值得停下来想一下。
细节一是为什么要用临时数组。因为Condition是逐个执行的,如果每算完一个就直接写回residuals[i],后面的Condition拿到的就不是原始残差,而是被前面处理过的残差了。源码这里的“先全部算完再统一写回”其实是一种很典型的规约模式,避免前后条件互相污染。
细节二是第三个参数jacobian的非空判断。如果调用方传入的jacobians是nullptr,说明优化器此刻不要求解析雅可比,那Condition实现里可以跳过导数计算,省掉一次不必要的数学运算。这个细节在后面的雅可比处理里非常重要。
3.3 失败传播语义:Condition返回true就是出错了
还有一个容易踩坑的细节:Condition::Evaluate的返回值和CostFunction::Evaluate的返回值语义是反的。CostFunction返回false表示失败,而Condition返回true表示失败。
为什么会有这种不一致?因为CostFunction沿袭了Ceres主接口的约定,而Condition接口在早期设计时参考了另一套内部习惯。所以读源码的时候不要靠直觉判断,一定要看清楚注释里“true is failure”的说明。
一旦某个Condition返回了true,整个Evaluate立刻返回false,优化器会认为这个残差块不可用,进而终止迭代或者跳过这一步。这其实是一个很有用的防御机制:如果你的Condition在某个输入位置没有定义(比如根号下出现了负数),你可以返回true让优化器知道这里出了问题,而不是给一个非法数值把它带偏。
4. Evaluate第二步:雅可比矩阵的行缩放与链式法则落地
4.1 链式法则公式推导
ConditionedCostFunction最精华的部分,也是我读完后觉得“这个类真不简单”的部分,在于它对雅可比矩阵的处理。
假设原始残差是r_i,经过Condition变换得到新残差r_i':
r_i' = C_i(r_i)其中r_i = f_i(p)是底层CostFunction关于某些参数块p的原始残差。根据链式法则:
∂r_i' / ∂p = C_i'(r_i) * ∂r_i / ∂p这里C_i'(r_i)是一个标量,∂r_i / ∂p是一行向量。乘法的效果就是:原始雅可比矩阵的第i行,整体乘以C_i'(r_i)。
把所有残差分量放在一起看,整个雅可比矩阵的变换等价于左乘一个对角矩阵:
J_new = diag(C_1'(r_1), C_2'(r_2), ..., C_n'(r_n)) * J这就是源码里所谓“行缩放”的由来。你不需要重新构建什么复杂的矩阵乘积,只需要对旧雅可比矩阵的每一行做一次标量乘法。
4.2 源码里那个行缩放循环
有了公式之后再看源码,一切都顺理成章了:
if (jacobians != nullptr) { for (int j = 0; j < num_parameter_blocks; ++j) { if (jacobians[j] != nullptr) { const int num_parameters = wrapped_cost_function_->parameter_block_sizes()[j]; for (int i = 0; i < num_residuals; ++i) { VectorRef(jacobians[j] + i * num_parameters, num_parameters) *= condition_jacobians[i]; } } } } return true;这个双层循环的顺序很有讲究。
外层先遍历参数块。一个残差块可能关联多个参数块,每个参数块在自己的雅可比数组里占据一段连续内存。内层再遍历残差维度。Ceres的雅可比矩阵布局是行优先的:jacobians[j]这一段内存里,第i个残差分量对应的导数,存放在下标i * num_parameters开始的位置。
所以VectorRef(jacobians[j] + i * num_parameters, num_parameters)这行代码,本质上是把“第i行”包装成一个Eigen的Map对象,然后整个乘上condition_jacobians[i]。用*=的好处是原地更新,不会为新矩阵额外分配内存。
4.3 一个容易被忽视的推论:雅可比为0的语义
既然雅可比每一行都要乘condition_jacobians[i],那么一个很自然的推论是:如果你写了一个Condition,它的导数在某段输入范围内恒等于0,那么对应残差行的雅可比也会变成0。
这意味着什么?意味着这个残差分量在当前迭代点附近不提供任何梯度信息。但注意,残差本身可能并不是0。在LM算法里,这会表现为信息矩阵的某个方向降秩,优化器可能认为这个约束已经“被满足”了,从而放弃对它进行校正。
我在实际项目里踩过一次这种坑:写了一个硬截断的Condition,残差超过阈值直接给一个常数,导数写0,结果优化进程在边界处卡了很久。后来把导数从0改成一个小斜坡,比如1e-6,问题立刻消失。后面避坑章节还会再展开。
5. Condition接口拆解与自定义一个非线性条件
5.1 Condition的纯虚接口
看ConditionedCostFunction源码的时候,顺带要把Condition接口也读了。它比想象中简单:
class CERES_EXPORT Condition { public: virtual ~Condition() {} virtual bool Evaluate(const double* parameters, double* residuals, double* jacobian) const = 0; virtual int num_parameters() const = 0; };这里parameters是这个Condition的输入,residuals是输出,两者都是标量指针。jacobian指向一个长度至少为num_parameters()的数组,存放“输出对每个输入的偏导数”。
在ConditionedCostFunction的使用上下文里,Condition的输入被绑定到某个残差分量上,所以实际上num_parameters()几乎总是1。少数特例可能是0,代表一个不依赖输入、直接输出常量的条件。
5.2 从源码看官方实现的套路
Ceres源码里给出的Condition实现,大体上有两条路线。
一条是数值微分路线。把残差变换函数写成一个模板函数,然后用中心差分对输入求导。优点是实现成本低,你只需要关心数学表达式,不需要手搓导数;缺点是每次Evaluate要多算两次函数值,而且差分布长选得不合适时,导数精度会受影响。
另一条是解析雅可比路线。要求实现者同时给出函数值和函数对输入的导数。这里有个常见的误解需要澄清:Condition的雅可比是对“输入残差”求导,而不是对“优化参数块”求导。对优化参数块的导数是由ConditionedCostFunction通过行缩放完成的。如果你在自定义Condition时把这两者搞反,包装后的整个雅可比矩阵都会是错的,而且很难排查。
5.3 手写一个ScaleThenClampCondition
理解了接口,写一个自定义Condition就不难了。我以一个“缩放后再截断”的条件为例,这个在工程里很实用:
class ScaleThenClampCondition final : public ceres::Condition { public: ScaleThenClampCondition(double scale, double lower, double upper) : scale_(scale), lower_(lower), upper_(upper) {} int num_parameters() const override { return 1; } bool Evaluate(const double* parameters, double* residuals, double* jacobian) const override { const double r = parameters[0]; const double scaled = scale_ * r; if (scaled < lower_) { *residuals = lower_; if (jacobian != nullptr) { jacobian[0] = 0.0; } } else if (scaled > upper_) { *residuals = upper_; if (jacobian != nullptr) { jacobian[0] = 0.0; } } else { *residuals = scaled; if (jacobian != nullptr) { jacobian[0] = scale_; } } return true; } private: double scale_; double lower_; double upper_; };这个类本身很直观:在非饱和区,输出是scale乘输入,导数就是scale;在饱和区,输出恒定,导数就是0。
但正如前面说的,jacobian[0] = 0.0的做法要非常谨慎。如果你真的要做硬截断,可以考虑把饱和区的导数设成一个极小的正数,而不是严格的0,这样至少优化器不会在边界处完全失去方向。
6. 完整实战:给一个点到平面距离残差加软截断
6.1 底层CostFunction:简单的点到平面距离
为了把上面的源码讲通,这里用一个最简单的例子串一遍。假设你在做一个点云对齐问题,需要优化一个三维点p的位置,让它尽量落在某个平面n·p + d = 0上。残差定义为一个标量:
class PlaneDistanceCostFunction final : public ceres::CostFunction { public: PlaneDistanceCostFunction(const Eigen::Vector3d& n, double d) : n_(n), d_(d) { set_num_residuals(1); mutable_parameter_block_sizes()->push_back(3); } bool Evaluate(const double* const* parameters, double* residuals, double** jacobians) const override { const Eigen::Map<const Eigen::Vector3d> p(parameters[0]); residuals[0] = n_.dot(p) + d_; if (jacobians != nullptr && jacobians[0] != nullptr) { Eigen::Map<Eigen::RowVector3d>(jacobians[0]) = n_.transpose(); } return true; } private: Eigen::Vector3d n_; double d_; };这个类非常简单,残差维数是1,参数块是1个三维向量,雅可比就是平面的法向量。
6.2 用Condition做软截断
现在设想一个真实场景:初始位姿特别差,点到平面的距离可能达到10米甚至20米。如果直接用线性距离残差,优化初期会产生非常大的梯度,LM迭代容易“冲过头”。
这时可以用一个tanh软截断,让距离超过一定范围后,对梯度的贡献慢慢减小:
class SoftTanhCondition final : public ceres::Condition { public: explicit SoftTanhCondition(double scale) : scale_(scale) {} int num_parameters() const override { return 1; } bool Evaluate(const double* parameters, double* residuals, double* jacobian) const override { const double r = parameters[0]; *residuals = scale_ * std::tanh(r / scale_); if (jacobian != nullptr) { const double c = std::cosh(r / scale_); jacobian[0] = 1.0 / (c * c); } return true; } private: double scale_; };当输入r绝对值很小时,tanh(r)近似等于r,此时约等于没变换;当r绝对值很大时,tanh趋向于1,导数趋向于0。这样优化初期就不会被一个巨大距离残差带偏方向。
6.3 组装并加入优化Problem
组装方式如下:
std::vector<ceres::Condition*> conditions; conditions.push_back(new SoftTanhCondition(2.0)); ceres::CostFunction* cost = new ceres::ConditionedCostFunction( new PlaneDistanceCostFunction(n, d), conditions); ceres::Problem problem; problem.AddResidualBlock(cost, nullptr, point_param);point_param是一个长度为3的double数组。把所有初始点放在距离平面很远的地方,分别跑一次“不加Condition”和“加Condition”的优化,能明显看到前者在头几步迭代的步长非常夸张,后者则更温和。
用表格对比一下这个差异:
| 方案 | 迭代初期的行为 | 收敛稳定性 | 实现成本 |
|---|---|---|---|
| 直接线性残差 | 梯度大,步长容易过大 | 依赖信赖域策略兜底 | 最低 |
| 加SoftTanhCondition | 距离越大牵引越小,步长平滑 | 差初值下表现更稳定 | 低 |
| 只换LossFunction | 整体加权,但残差数值不变 | 比线性好,但对单个分量控制力弱 | 最低 |
这里也回应了上文说的边界:LossFunction是整体权重缩放,而Condition是对单个残差分量做非线性映射。它是另一个维度的工具。
7. 读完后必须提醒的几个坑
7.1 所有权与double delete
这是用这个类最容易踩的坑,没有之一。ConditionedCostFunction析构时,会释放内部被包装的CostFunction,也会释放所有Condition对象。所以:
- 不要把栈上创建的
Condition对象塞进去; - 不要让两个包装类共享同一个
Condition*; - 如果自己又额外
delete了一份,那就离崩溃不远了。
我自己的实践是:在建图优化、多传感器标定这类项目里,统一用一个工厂函数创建整个残差块,所有权链非常清晰。
7.2 饱和区雅可比为0的数值风险
上面已经提到过,硬截断会让导数在某段区间为0。这种设计在数值上非常危险:残差不为0,但梯度为0,优化器会认定这个约束已经满足,从而不再移动参数。很多时候表现为优化进程卡在某个不太好的位置,怎么迭代都不动。
如果确实需要硬截断,有几种变通方案:
- 把饱和区的导数设成一个很小的斜坡,比如
1e-6; - 改用
tanh这类平滑函数把“截断”软化; - 在
Condition内部做一次带死区的线性映射,保证整个定义域内导数不为0。
7.3 别拿它做跨残差的耦合变换
ConditionedCostFunction的哲学是“一个Condition处理一个残差分量”。每个Condition的输入都只是一个标量,天然无法看到其他残差分量的值。
如果有人想用它做残差白化、把二维向量投影到切平面、或者对不同残差分量做线性组合,那这个类就不适合。跨维度的耦合变换应该直接写一个新的CostFunction,在Evaluate里自己控制多维残差和完整的雅可比矩阵。
7.4 调试技巧:从雅可比入手
最后分享一个排查问题的实用方法。如果你发现套了Condition之后优化结果不对,第一步先验证雅可比。方法很简单:写一个中心差分的小脚本,把一个参数分量扰动1e-6,计算残差变化量,再对比ConditionedCostFunction返回的解析雅可比。
这个检查往往能很快定位到问题根源:八成是自定义Condition里的jacobian写错了,或者对输入求导和对参数块求导搞混了。因为ConditionedCostFunction本身的行缩放逻辑是纯粹的线性代数,只要头尾两个环节(底层CostFunction雅可比和Condition雅可比)是对的,结果就一定是对的。
读这套源码给我最大的收获,并不是那个行缩放循环本身有多精妙,而是Ceres把“残差后处理”抽象成独立组件的思路。后来我在做多传感器标定的时候,把不同传感器残差的量纲归一化全部挪到Condition里实现,主CostFunction保持纯粹的量测模型,代码结构一下子就清晰了。如果你也想在自己的项目里引入它,建议先从一个最简单的ScaleCondition跑通整体流程,再逐步加重非线性变换。另外千万别忽略Condition接口那第三个形参jacobian——在源码里它被一个vector收集后,直接决定了雅可比矩阵每一行的缩放因子。把这一点吃透,ConditionedCostFunction的源码阅读就算真正到位了。