C++学习笔记—Ceres库
1. 安装
略
2. 问题定义
以 $y = \exp \left( {a{x}^{2} + {bx} + c}\right)$ 为例
我们现在拥有一系列的观测值
x | $x_1$ | $x_2$ | … | $x_n$ |
---|---|---|---|---|
y | $y_1$ | $y_2$ | … | $y_n$ |
目标是用函数 $y = \exp(ax^2 + bx + c)$ 拟合这些数据点,确定参数 $a$、$b$ 和 $c$ 的最佳值
在迭代的某一步,假设我们有参数的当前估计值 $a_k$、$b_k$ 和 $c_k$。
对于每个数据点 $x_i$,我们可以计算模型预测值 :$\hat{y}_i$
$\hat{y}_i = \exp(a_k x_i^2 + b_k x_i + c_k)$
那么残差就是实际观测值与预测值的差
$r_i = y_i - \hat{y}_i = y_i - \exp(a_k x_i^2 + b_k x_i + c_k)$
我们不希望正负残差相互抵消,所以计算残差的平方,然后求和得到代价函数:
$J(a, b, c) = \sum_{i=1}^{n} r_i^2 = \sum_{i=1}^{n} [y_i - \exp(a x_i^2 + b x_i + c)]^2$
最小二乘法的目标是找到参数 $a$、$b$ 和 $c$ 的值,使得代价函数 $J(a, b, c)$ 最小。即:
$\min_{a, b, c} J(a, b, c) = \min_{a, b, c} \sum_{i=1}^{n} [y_i - \exp(a x_i^2 + b x_i + c)]^2$
3. 三种求解方式
3.1 方法一:解析求导
这种方法需要我们手动计算雅可比矩阵,并将其提供给 Ceres。
3.1.1 计算雅可比矩阵
我们有残差 $r(a, b, c) = y - \exp(ax^2 + bx + c)$
令 $E = \exp(ax^2 + bx + c)$
使用链式法则求偏导:
- $\frac{\partial r}{\partial a} = - \frac{\partial E}{\partial a} = - E \cdot \frac{\partial (ax^2 + bx + c)}{\partial a} = - E \cdot x^2$
- $\frac{\partial r}{\partial b} = - \frac{\partial E}{\partial b} = - E \cdot \frac{\partial (ax^2 + bx + c)}{\partial b} = - E \cdot x$
- $\frac{\partial r}{\partial c} = - \frac{\partial E}{\partial c} = - E \cdot \frac{\partial (ax^2 + bx + c)}{\partial c} = - E \cdot 1$
3.1.2 实现 Ceres Cost Function
在Ceres Solver中实现解析求导,我们需要继承的是ceres::SizedCostFunction
类。
SizedCostFunction
是一个模板类,参数格式如下:
1 | ceres::SizedCostFunction<残差数量, 参数块1大小, 参数块2大小, ...> |
对于这个问题(拟合模型 y = exp(ax² + bx + c)):
- 残差数量 = 1(每个观测点产生1个残差值)
- 参数块大小 = 3(我们优化的参数是a, b, c)
所以我们需要继承的类是:
1 | ceres::SizedCostFunction<1, 3> |
定义我们的CostFunction类
1 | // 解析求导的Cost Function |
实现Evaluate函数
这是最重要的一步,它包含两部分:
- 计算残差值(必须)
- 计算雅可比矩阵(可选,但解析求导的关键)
1 | bool ExponentialResidualAnalytic::Evaluate(double const* const* parameters, |
3.1.3 Evaluate函数参数详解
1. double const* const* parameters
- 这是一个指向指针数组的指针
parameters[i]
访问第i个参数块(我们只有一个参数块,所以使用parameters[0]
)parameters[0][j]
访问第一个参数块的第j个元素
2. double* residuals
- 用于存储计算的残差值
residuals[i]
写入第i个残差值(我们只有一个残差,所以使用residuals[0]
)
3. double** jacobians
- 用于存储计算的雅可比矩阵
- 如果为
nullptr
,表示优化器不需要计算雅可比矩阵 jacobians[i]
指向与第i个参数块相关的雅可比矩阵jacobians[0][j]
存储残差对第一个参数块中第j个参数的偏导数
3.2 方法二:自动求导 (Automatic Differentiation)
这是 Ceres 推荐的方法之一,因为它结合了易用性和效率。我们只需要定义一个计算残差的模板化 “Functor”,Ceres 会自动计算导数。
3.2.1 Functor 结构基本形式
1 | struct ExponentialResidualAutoDiff { |
3.2.2 关键点详解
模板化 operator()
1 | template <typename T> |
这是自动求导的核心。这里的类型 T
是关键,Ceres 会:
- 使用
T = double
调用此函数来计算残差值 - 使用
T = ceres::Jet<double, N>
调用此函数来计算导数
ceres::Jet
类型是 Ceres 的特殊类型,它存储了值和对应的导数信息。当函数使用 Jet
类型执行计算时,它自动跟踪导数。
参数和返回值
const T* const parameters
:包含优化参数的数组T* residual
:输出残差的数组- 返回
bool
:表示计算是否成功
3.2.3 详细示例:分步解析
对于模型 $y = \exp(ax^2 + bx + c)$,以下是详细的自动求导 Functor 实现:
1 | struct ExponentialResidualAutoDiff { |
3.3 方法三:数值求导 (Numeric Differentiation)
这种方法最容易实现,因为我们只需要提供计算残差的函数。Ceres 会通过微小地改变参数值并观察残差的变化来近似计算雅可比矩阵。但它通常比自动或解析求导慢,并且可能不太精确。
3.3.1 实现 Functor (与自动求导类似,但非模板化)
创建一个结构体(或类),重载 operator()
,但这次只使用 double
类型。
1 | // 数值求导的 Functor |
4. 后续步骤
流程:
定义残差函数:使用解析求导、自动求导或数值求导
创建和配置 Problem:
在 Ceres 中,
Problem
是整个优化问题的容器,它包含了所有残差块和参数块。1
2ceres::Problem problem;
上面这行代码创建了一个空的 Problem 对象。接下来,我们需要向它添加残差块(即我们前面定义的成本函数)。
- 添加残差块
对于每个数据点 $(x_i, y_i)$,我们需要添加一个残差块。这是通过
AddResidualBlock
方法完成的:1
2
3
4
5
6
7
8
9
10for (size_t i = 0; i < x_data.size(); ++i) {
ceres::CostFunction* cost_function =
new ceres::AutoDiffCostFunction<ExponentialResidualAutoDiff, 1, 3>(
new ExponentialResidualAutoDiff(x_data[i], y_data[i])
);
problem.AddResidualBlock(cost_function,
nullptr, // 使用默认的平方损失函数
parameters);
}这段代码的详细解释:
- 创建成本函数:
- 对于自动求导,我们使用
AutoDiffCostFunction<>
类 - 模板参数
ExponentialResidualAutoDiff
是我们的函数对象类型 1
表示残差的维度(每个观测产生一个标量残差)3
表示参数块的大小(我们的参数是 a, b, c 三个值)- 构造函数参数是我们的函数对象实例,它保存了特定的 $x_i$ 和 $y_i$ 值
- 对于自动求导,我们使用
- 添加残差块:
AddResidualBlock
将成本函数添加到问题中第一个参数是成本函数指针
第二个参数是损失函数指针(
nullptr
表示使用默认的平方损失函数)损失函数用于减轻离群点(outliers)的影响。当为
nullptr
时,Ceres 使用默认的平方损失函数,即直接对残差平方求和。如果需要使用鲁棒损失函数,比如 Huber 损失,可以这样写:
1
2
3ceres::LossFunction* loss_function = new ceres::HuberLoss(1.0);
problem.AddResidualBlock(cost_function, loss_function, parameters);第三个参数是指向我们要优化的参数数组的指针
设置参数约束(如有需要)
除了添加残差块外,还可以对参数设置一些约束:
固定某些参数(使它们在优化过程中不变):
1
2problem.SetParameterBlockConstant(parameters + 2); // 固定第三个参数 c
设置参数的上下界:
1
2
3problem.SetParameterLowerBound(parameters, 0, 0.0); // 设置 a >= 0
problem.SetParameterUpperBound(parameters, 1, 0.0); // 设置 b <= 0
配置求解器选项
求解器负责实际运行优化算法。我们通过
Solver::Options
对象来配置求解器:1
2
3
4
5ceres::Solver::Options options;
options.linear_solver_type = ceres::DENSE_QR;
options.minimizer_progress_to_stdout = true;
options.max_num_iterations = 50;详细解释:
linear_solver_type
:- 在每次迭代中,非线性最小二乘问题会被线性化,产生一个线性子问题
- 这个选项决定如何求解这个线性子问题
DENSE_QR
适合小规模问题(参数和残差数量都不太多)- 对于大规模问题,可以考虑
SPARSE_NORMAL_CHOLESKY
或ITERATIVE_SCHUR
等
minimizer_progress_to_stdout
:- 设为
true
时会在优化过程中打印进度信息 - 非常有用,可以看到成本函数的下降情况和收敛过程
- 设为
max_num_iterations
:- 最大迭代次数
- 防止算法在难以收敛的情况下无限循环
其他常用选项包括:
function_tolerance
:当成本函数相对变化小于此值时停止gradient_tolerance
:当梯度范数小于此值时停止parameter_tolerance
:当参数相对变化小于此值时停止num_threads
:使用的线程数量(并行化)
运行求解器
设置好 Problem 和 Options 后,调用
Solve
函数执行优化:1
2ceres::Solver::Summary summary;
ceres::Solve(options, &problem, &summary);这会执行优化算法并将结果存储在
summary
对象中。优化过程完成后,最优参数值会被直接写入到我们提供给 Problem 的parameters
数组中。分析结果
优化完成后,通常需要:
检查优化状态:
1
std::cout << summary.BriefReport() << "\n";
这会打印一个简短报告,包括终止状态、迭代次数、求解时间等
查看最终参数值:
1
2
3
4std::cout << "a = " << parameters[0] << " (True: " << a_true << ")\n";
std::cout << "b = " << parameters[1] << " (True: " << b_true << ")\n";
std::cout << "c = " << parameters[2] << " (True: " << c_true << ")\n";计算最终代价:
1
2
3
4
5
6
7
8double final_cost = 0;
for (size_t i = 0; i < x_data.size(); ++i) {
double residual = y_data[i] - std::exp(parameters[0] * x_data[i] * x_data[i] +
parameters[1] * x_data[i] + parameters[2]);
final_cost += residual * residual;
}
std::cout << "Final Total Cost: " << final_cost << std::endl;
5. 完整代码实现
1 | // 用于生成模拟数据的真实参数 |
6. 如何选择求导方法
- 自动求导 (AutoDiff): 通常是首选。它易于实现(只需写 Functor),准确(不像数值求导有近似误差),并且通常比数值求导快。Ceres 对其进行了高度优化。
- 解析求导 (Analytic): 如果你能正确无误地推导出雅可比矩阵并实现它,这通常是最快的方法。但是,手动求导和实现很容易出错,尤其对于复杂的模型。调试起来也比较困难。
- 数值求导 (Numeric): 最容易实现,因为你只需要提供计算残差的函数。但它通常是最慢的,并且其精度受限于步长选择(Ceres 会尝试选择合适的步长),可能在某些情况下导致收敛性问题。适合快速原型设计或当解析/自动求导难以实现时。