news 2026/4/18 0:51:40

最小二乘问题详解9:使用Ceres求解非线性最小二乘

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
最小二乘问题详解9:使用Ceres求解非线性最小二乘

如果要构建安装 Ceres Solver,可以参考文章《CMake构建学习笔记30-Ceres Solver库的构建》。Ceres Solver 的构建过程还是挺麻烦的,推荐直接找预编译版本,比如 GISBasic3rdParty。

还是求解与《最小二乘问题详解8:Levenberg-Marquardt方法》一样的最小二乘问题,模型函数为:f(x;θ)=exp(ax2+bx+c),具体代码如下:

#include <ceres/autodiff_cost_function.h> #include <ceres/ceres.h> #include <ceres/covariance.h> #include <iostream> #include <random> #include <vector> using namespace std; // 残差计算结构体(用于自动微分) struct ExpModelResidual { ExpModelResidual(double x, double y) : x_(x), y_(y) {} // 模板 operator() 支持自动微分 template <typename T> bool operator()(const T* const a, const T* const b, const T* const c, T* residual) const { // 计算指数部分: a*x^2 + b*x + c T exponent = (*a) * T(x_) * T(x_) + (*b) * T(x_) + (*c); // 防止 exp 溢出(Ceres 内部对梯度也有保护,但这里加一层更稳) const T kMaxExp = T(300.0); if (exponent > kMaxExp) exponent = kMaxExp; if (exponent < -kMaxExp) exponent = -kMaxExp; T y_pred = ceres::exp(exponent); residual[0] = T(y_) - y_pred; return true; } private: const double x_; const double y_; }; int main() { // ======================== // 1. 真实参数 // ======================== double a_true = 0.05, b_true = -0.4, c_true = 1.0; cout << "真实参数: a=" << a_true << ", b=" << b_true << ", c=" << c_true << endl; // ======================== // 2. 生成带噪声的数据 // ======================== int N = 50; vector<double> x_data(N), y_data(N); random_device rd; mt19937 gen(rd()); uniform_real_distribution<double> x_dis(-5.0, 5.0); normal_distribution<double> noise_gen(0.0, 0.1); // 噪声标准差 0.1 for (int i = 0; i < N; ++i) { x_data[i] = x_dis(gen); double y_true = exp(a_true * x_data[i] * x_data[i] + b_true * x_data[i] + c_true); y_data[i] = y_true + noise_gen(gen); } // ======================== // 3. 初始化参数(猜测) // ======================== double a = 0.0, b = 0.0, c = 0.0; cout << "初始猜测: a=" << a << ", b=" << b << ", c=" << c << endl; // ======================== // 4. 构建 Ceres 问题 // ======================== ceres::Problem problem; for (int i = 0; i < N; ++i) { ceres::CostFunction* cost_function = new ceres::AutoDiffCostFunction<ExpModelResidual, 1, 1, 1, 1>( new ExpModelResidual(x_data[i], y_data[i])); problem.AddResidualBlock(cost_function, nullptr, &a, &b, &c); } // ======================== // 5. 配置并运行求解器 // ======================== ceres::Solver::Options options; options.linear_solver_type = ceres::DENSE_NORMAL_CHOLESKY; options.minimizer_progress_to_stdout = true; // 打印迭代过程 options.max_num_iterations = 50; options.function_tolerance = 1e-12; options.gradient_tolerance = 1e-10; options.parameter_tolerance = 1e-8; ceres::Solver::Summary summary; ceres::Solve(options, &problem, &summary); cout << summary.BriefReport() << "\n"; // ======================== // 6. 输出结果 // ======================== cout << "--- 拟合完成 ---" << endl; cout << "估计参数: a=" << a << ", b=" << b << ", c=" << c << endl; cout << "真实参数: a=" << a_true << ", b=" << b_true << ", c=" << c_true << endl; // 计算最终残差平方和 double final_cost = 0.0; for (int i = 0; i < N; ++i) { double pred = exp(a * x_data[i] * x_data[i] + b * x_data[i] + c); double r = y_data[i] - pred; final_cost += r * r; } cout << "最终残差平方和: " << final_cost << endl; // ======================== // 7. (可选)计算参数协方差与标准差 // ======================== ceres::Covariance::Options cov_options; ceres::Covariance covariance(cov_options); vector<pair<const double*, const double*>> covariance_blocks; covariance_blocks.emplace_back(&a, &a); covariance_blocks.emplace_back(&b, &b); covariance_blocks.emplace_back(&c, &c); if (!covariance.Compute(covariance_blocks, &problem)) { cerr << "协方差计算失败!" << endl; return 1; } double cov_a, cov_b, cov_c; covariance.GetCovarianceBlock(&a, &a, &cov_a); covariance.GetCovarianceBlock(&b, &b, &cov_b); covariance.GetCovarianceBlock(&c, &c, &cov_c); // 估计噪声方差 σ² = RSS / (N - p) double sigma2 = final_cost / (N - 3); double std_a = sqrt(cov_a * sigma2); double std_b = sqrt(cov_b * sigma2); double std_c = sqrt(cov_c * sigma2); cout << "\n参数标准差 (近似):" << endl; cout << "a: ±" << std_a << endl; cout << "b: ±" << std_b << endl; cout << "c: ±" << std_c << endl; return 0; }

可以看到,Ceres Solver 的实现思路与原生的基于 Eigen 的实现思路完全不同。原生的基于 Eigen 的实现只要按照 Levenberg-Marquardt 方法的原理来实现即可,反而更容易理解;但是 Ceres Solver 的实现则需要考虑通用性,提供的接口更加抽象。所以这也是笔者写那么多前置文章的原因,对于一个需要专业知识的程序库,如果你不理解其中的原理,很可能也没法正确地使用它。

2.1 构建问题

从前置文章中可以知道,无论是 Gauss-Newton 法、梯度下降法还是 Levenberg-Marquardt 方法,关键的问题在于求解问题模型的雅可比矩阵。但是,对于用户来说更加熟悉的是最小二乘问题的残差,也就是y−f(x;θ)。因此,Ceres Solver 的核心思想就是以残差为中心组织代码,关于雅可比矩阵的数值计算则作为 Ceres Solver 优化器的内部机制。参看如下关键代码:

// ======================== // 4. 构建 Ceres 问题 // ======================== ceres::Problem problem; for (int i = 0; i < N; ++i) { ceres::CostFunction* cost_function = new ceres::AutoDiffCostFunction<ExpModelResidual, 1, 1, 1, 1>( new ExpModelResidual(x_data[i], y_data[i])); problem.AddResidualBlock(cost_function, nullptr, &a, &b, &c); }

这里的CostFunction(代价函数)是什么意思呢?简而言之,可以将其理解成一个残差项ri(θ)=yi−f(xi;θ);但是更准确地说,它是一个“残差块生成器”——封装了“如何计算一个残差向量及其对参数的导数”的完整逻辑。在 Ceres Solver 中:ceres::CostFunction是一个接口类,它的核心方法是:

bool Evaluate(double const* const* parameters, double* residuals, double** jacobians);

从这个接口参数可以看到,ceres::CostFunction不仅可以计算residuals(即 ri),还可以计算jacobians(即 ∂ri∂θ)。所以,它不仅仅是残差函数,而是残差 + 导数的联合计算单元

Ceres Solver 支持三种雅可比矩阵的计算方式:

  • 自动微分AutoDiffCostFunction):精度高、无需手写Jacobian。例如这里的ExpModelResidual
  • 数值微分NumericDiffCostFunction):用有限差分近似导数,用于黑盒函数。
  • 解析微分(继承CostFunction):用户自定义Jacobian,适用于需要极致性能的情况。

但无论哪种方式,残差的定义不变,一个ceres::CostFunction代表一个残差项,最后都通过AddResidualBlock接口将残差项对象添加到定义好的问题模型ceres::Problem中。大概来说,可以这样理解:

数学概念Ceres 实现
残差函数ri(θ)ExpModelResidual::operator()(只算值)
残差 + Jacobian 计算ceres::AutoDiffCostFunction<...>(自动微分包装后)
可被优化器调用的完整残差项ceres::CostFunction*

展开自定义的残差计算单元ExpModelResidual

// 残差计算结构体(用于自动微分) struct ExpModelResidual { ExpModelResidual(double x, double y) : x_(x), y_(y) {} // 模板 operator() 支持自动微分 template <typename T> bool operator()(const T* const a, const T* const b, const T* const c, T* residual) const { // 计算指数部分: a*x^2 + b*x + c T exponent = (*a) * T(x_) * T(x_) + (*b) * T(x_) + (*c); // 防止 exp 溢出(Ceres 内部对梯度也有保护,但这里加一层更稳) const T kMaxExp = T(300.0); if (exponent > kMaxExp) exponent = kMaxExp; if (exponent < -kMaxExp) exponent = -kMaxExp; T y_pred = ceres::exp(exponent); residual[0] = T(y_) - y_pred; return true; } private: const double x_; const double y_; };

可以看到,ExpModelResidual::operator()的实现本质上就是残差函数 ri(θ) 的计算过程。当它被ceres::AutoDiffCostFunction包装后,Ceres 能够自动完成求导,从而生成对应的雅可比矩阵项。其关键在于这一行代码:

T y_pred = ceres::exp(exponent);

这里使用的是ceres::exp,而非标准库中的std::exp。这是因为在自动微分(AutoDiffCostFunction)过程中,Ceres 会将模板参数T实例化为一种特殊的数值类型——ceres::Jet<T, N>。该类型不仅存储函数值,还同时携带其对若干参数的偏导数。为了支持这种类型的运算,Ceres 为常见的数学函数提供了重载版本,这些版本能够正确传播导数信息(即应用链式法则)。

虽然底层涉及 C++ 模板和编译期“计算图录制”的机制,理解起来有一定门槛,但用户只需遵循一个简单规则:在残差函数的实现中,所有数学运算必须使用ceres::命名空间下的函数,而不能使用std::或全局命名空间中的版本。具体的需要替换的常见函数对照表如下:

标准写法(❌ 错误)Ceres 正确写法(✅ 必须使用)说明
std::exp(x)ceres::exp(x)指数函数
std::log(x)ceres::log(x)自然对数
std::log10(x)ceres::log10(x)常用对数
std::sqrt(x)ceres::sqrt(x)平方根
std::pow(x, y)ceres::pow(x, y)幂函数(注意:y也应为模板类型T
std::sin(x)ceres::sin(x)正弦函数
std::cos(x)ceres::cos(x)余弦函数
std::tan(x)ceres::tan(x)正切函数
std::asin(x)ceres::asin(x)反正弦函数
std::acos(x)ceres::acos(x)反余弦函数
std::atan(x)ceres::atan(x)反正切函数
std::sinh(x)ceres::sinh(x)双曲正弦
std::cosh(x)ceres::cosh(x)双曲余弦
std::tanh(x)ceres::tanh(x)双曲正切
std::abs(x)/std::fabs(x)ceres::abs(x)绝对值(⚠️ 在 0 处不可导,慎用)
std::max(a, b)ceres::max(a, b)最大值(⚠️ 在相等点不可导,需谨慎)
std::min(a, b)ceres::min(a, b)最小值(⚠️ 在相等点不可导,需谨慎)

只要遵守上述规范,Ceres 就能在运行时自动、高效且精确地计算出残差及其雅可比矩阵,无需用户手动推导或实现导数逻辑。

2.2 配置参数

接下来看一下核心配置部分的代码:

// ======================== // 5. 配置并运行求解器 // ======================== ceres::Solver::Options options; options.linear_solver_type = ceres::DENSE_NORMAL_CHOLESKY; options.minimizer_progress_to_stdout = true; // 打印迭代过程 options.max_num_iterations = 50; options.function_tolerance = 1e-12; options.gradient_tolerance = 1e-10; options.parameter_tolerance = 1e-8;

逐项详解求解器的配置参数:

  • options.linear_solver_type = ceres::DENSE_NORMAL_CHOLESKY;:指定每次迭代中求解线性子问题(即 (JTJ+λD)Δθ=JTr)所用的线性代数求解器类型。常见的选项如下所示:
类型适用场景说明
DENSE_NORMAL_CHOLESKY小规模稠密问题(p<100)构造正规方程A=JTJ,用 Cholesky 分解求解。
DENSE_QR小规模,但J 可能病态直接对J 做 QR 分解,更稳定但稍慢
SPARSE_NORMAL_CHOLESKY大规模稀疏问题
(如 SLAM、Bundle Adjustment)
利用JTJ 的稀疏性
ITERATIVE_SCHUR/CGNR超大规模问题迭代法,内存友好
  • options.minimizer_progress_to_stdout = true;:是否将优化过程的迭代信息打印到标准输出。通过打印内容可以看迭代过程是否收敛、是否震荡、步长是否合理。
  • options.max_num_iterations = 50;最大迭代次数。即使未收敛,达到此次数后强制停止。防止算法陷入死循环,控制计算时间上限。对于简单问题,20~50 次就足够了; 复杂 BA 这样的复杂问题,可能需要 100~200 次。
  • options.function_tolerance = 1e-12;目标函数(cost)的相对变化容差,即S(θk)−S(θk+1)S(θk)<function\_tolerance。当连续两次迭代的 cost 相对变化小于此值时,认为收敛。高精度拟合可设置为1e-12~1e-15;一般应用可设置为1e-6~1e-9
  • options.gradient_tolerance = 1e-10;梯度范数的绝对容差。当 ∥∇S(θ)∥=∥JTr∥ 小于此值时,认为达到极小点。通常设为1e-8~1e-12,不受 cost 绝对大小影响,比function_tolerance更可靠,是最常用的收敛判据之一
  • options.parameter_tolerance = 1e-8;参数更新量的绝对容差。当 ∥Δθ∥ 小于此值时,认为参数不再显著变化。注意如果参数尺度差异大(如 a≈0.01, b≈1000),此判据可能不合理,根据参数实际量级调整。

2.3 优化报告

案例运行的结果如下所示:

真实参数: a=0.05, b=-0.4, c=1 初始猜测: a=0, b=0, c=0 iter cost cost_change |gradient| |step| tr_ratio tr_radius ls_iter iter_time total_time 0 5.127046e+03 0.00e+00 4.99e+03 0.00e+00 0.00e+00 1.00e+04 0 7.05e-05 2.06e-04 1 8.359362e+34 -8.36e+34 4.99e+03 0.00e+00 -1.83e+31 5.00e+03 1 1.35e-04 6.28e-04 2 8.256445e+34 -8.26e+34 4.99e+03 0.00e+00 -1.81e+31 1.25e+03 1 1.89e-05 1.15e-03 3 7.666005e+34 -7.67e+34 4.99e+03 0.00e+00 -1.68e+31 1.56e+02 1 2.89e-05 1.56e-03 4 3.875752e+34 -3.88e+34 4.99e+03 0.00e+00 -8.50e+30 9.77e+00 1 1.43e-05 1.90e-03 5 2.984885e+30 -2.98e+30 4.99e+03 0.00e+00 -6.64e+26 3.05e-01 1 3.58e-05 2.18e-03 6 4.698247e+07 -4.70e+07 4.99e+03 0.00e+00 -2.43e+04 4.77e-03 1 6.20e-06 2.45e-03 7 5.075148e+03 5.19e+01 5.81e+03 0.00e+00 1.08e+00 1.43e-02 1 4.67e-05 2.84e-03 8 4.873439e+03 2.02e+02 9.07e+03 1.08e-01 1.26e+00 4.29e-02 1 2.08e-05 3.01e-03 9 3.776580e+03 1.10e+03 2.66e+04 2.86e-01 1.84e+00 1.29e-01 1 1.92e-05 3.29e-03 10 9.518702e+02 2.82e+03 4.90e+04 3.75e-01 1.74e+00 3.86e-01 1 1.77e-05 3.48e-03 11 1.461114e+02 8.06e+02 2.43e+04 1.29e-01 1.12e+00 1.16e+00 1 1.71e-05 3.69e-03 12 3.388251e+01 1.12e+02 3.01e+03 5.23e-02 1.02e+00 3.48e+00 1 1.71e-05 3.88e-03 13 2.579351e+01 8.09e+00 1.46e+03 2.50e-02 1.01e+00 1.04e+01 1 1.55e-05 4.20e-03 14 1.662811e+01 9.17e+00 1.35e+03 3.54e-02 9.96e-01 3.13e+01 1 1.38e-05 4.56e-03 15 6.626112e+00 1.00e+01 7.18e+02 4.17e-02 9.80e-01 9.39e+01 1 1.52e-05 4.70e-03 16 1.633045e+00 4.99e+00 2.45e+02 2.66e-02 9.64e-01 2.82e+02 1 4.87e-05 5.15e-03 17 3.715462e-01 1.26e+00 5.74e+01 2.98e-02 9.75e-01 8.45e+02 1 4.89e-05 5.78e-03 18 2.207972e-01 1.51e-01 8.88e+00 1.71e-02 9.98e-01 2.53e+03 1 5.97e-05 6.43e-03 19 2.156097e-01 5.19e-03 6.47e-01 3.87e-03 1.01e+00 7.60e+03 1 3.51e-05 7.07e-03 20 2.155782e-01 3.15e-05 1.99e-02 3.20e-04 1.01e+00 2.28e+04 1 3.61e-05 7.69e-03 21 2.155781e-01 3.38e-08 3.28e-04 1.05e-05 1.01e+00 6.84e+04 1 6.85e-05 8.13e-03 22 2.155781e-01 1.07e-11 3.90e-06 1.87e-07 1.01e+00 2.05e+05 1 2.97e-05 8.33e-03 Ceres Solver Report: Iterations: 23, Initial cost: 5.127046e+03, Final cost: 2.155781e-01, Termination: CONVERGENCE --- 拟合完成 --- 估计参数: a=0.0500655, b=-0.400392, c=0.996087 真实参数: a=0.05, b=-0.4, c=1 最终残差平方和: 0.431156 参数标准差 (近似): a: ±0.000357974 b: ±0.00223108 c: ±0.00464517

大部分内容是 Ceres 输出的优化报告:

ceres::Solver::Summary summary; ceres::Solve(options, &problem, &summary); cout << summary.BriefReport() << "\n";

先看看最终汇总行的内容:

Ceres Solver Report: Iterations: 23, Initial cost: 5.127046e+03, Final cost: 2.155781e-01, Termination: CONVERGENCE

汇总行中字段的含义是:

字段含义结果解读
Iterations: 23总迭代次数(从第 0 次到第 22 次)共执行 23 轮优化
Initial cost初始目标函数值(即12∑r2i)初值(0,0,0)下误差很大(≈5127)
Final cost最终目标函数值收敛到 ≈0.2156,非常小
Termination: CONVERGENCE终止原因✅ 正常收敛(满足梯度或步长停止条件)

注意 Ceres 中cost=12∑r2i,这是为了在计算 Jacobian 和梯度的的时候无需额外除以 2 或乘以 2,使用 LM 方法求解就能使用更为简洁的形式。

而在具体的迭代日志中,字段的含义如下:

列名全称 / 数学含义单位 / 类型说明
iter迭代序号整数从 0 开始计数
cost当前总代价C(θ)=12∑Ni=1ri(θ)2标量(浮点)越小越好;理想情况趋近于 0
cost_change上次 cost − 本次 cost浮点• 正值:代价下降(好)• 负值:代价上升(异常,通常因步长过大)• 接近 0:趋于收敛
gradient梯度范数|∇θC(θ)|2浮点衡量当前点是否接近极小值:• 大 → 远离最优• 小(如 < 1e-6)→ 接近收敛
step参数更新步长|Δθ|2浮点• 大:参数剧烈变化• 小(如 < 1e-6)→ 参数几乎不变,可能收敛
tr_ratio信任域比率(Trust Region Ratio)ρ=实际代价下降局部模型预测下降无量纲•ρ≈1:模型准确,可增大步长• ρ≪0:预测失败,需缩小步长• ρ<0:代价反而上升(步长过大)
tr_radius信任域半径(Trust Region Radius)参数空间尺度控制最大允许步长:• 小 → 保守更新• 大 → 激进更新(接近牛顿法)
ls_iter线搜索迭代次数整数在 Levenberg-Marquardt(LM)模式下恒为 1,因为 LM 是信任域方法,不使用线搜索
iter_time本次迭代耗时秒(s)通常为微秒级(1e-5 ~ 1e-4 s)
total_time累计总耗时秒(s)从开始到当前迭代的总时间

从迭代日志中可以看到,迭代过程大概分成三个阶段:

  1. 初期爆炸(iter 1–6)cost从 5e3 飙升到 8e34。原因是初始猜测(a,b,c)=(0,0,0)导致模型 y=e0=1,但真实数据可能远大于 1。第一步尝试大步长,但exp(a x² + ...)对参数极度敏感,导致数值溢出。Ceres 自动缩小tr_radius(1e4 → 1e-3),稳住优化——这是 LM 算法鲁棒性的体现。
  2. 中期快速下降(iter 7–15)cost从 5e3 快速降至 6.6;tr_ratio ≈ 1,说明局部线性模型有效;tr_radius开始扩大(1e-2 → 1e2),进入高效牛顿阶段。
  3. 后期精细收敛(iter 16–22)gradient从 245 降至3.9e-6step降至1.87e-7tr_ratio ≈ 1.01;直到满足默认收敛条件(比如梯度足够小),正常终止。

2.4 输出精度

精度是用户最为关心的问题,ceres::Covariance是 Ceres 提供的一个后处理工具类,用于在优化完成后估计参数的协方差矩阵,从而得到每个参数的不确定性(标准差)

首先是使用默认选项创建协方差计算器,其中cov_options可设置算法(如是否使用 sparse)、内存策略等:

// 1. 创建 Covariance 对象 ceres::Covariance::Options cov_options; ceres::Covariance covariance(cov_options);

为了提升效率,Covariance::Compute不会自动计算所有参数的完整协方差矩阵,因此需要显式声明只关心 a、b、c 各自的方差,即对角线元素:

// 2. 指定要计算哪些参数块之间的协方差 vector<pair<const double*, const double*>> covariance_blocks; covariance_blocks.emplace_back(&a, &a); // a 与 a 的协方差 → 方差 covariance_blocks.emplace_back(&b, &b); // b 的方差 covariance_blocks.emplace_back(&c, &c); // c 的方差

Ceres 会在当前参数值(即优化后的 a, b, c)处重新计算 Jacobian,然后构建并求解 (J⊤J)−1 的指定子块:

// 3. 执行协方差计算(在 problem 的当前解处线性化) if (!covariance.Compute(covariance_blocks, &problem)) { cerr << "协方差计算失败!" << endl; return 1; }

提取各参数的归一化方差:

// 4. 提取各参数的“归一化”方差(即 (JᵀJ)⁻¹ 的对角元) double cov_a, cov_b, cov_c; covariance.GetCovarianceBlock(&a, &a, &cov_a); // cov_a = [(JᵀJ)⁻¹]_{aa} covariance.GetCovarianceBlock(&b, &b, &cov_b); covariance.GetCovarianceBlock(&c, &c, &cov_c);

计算噪声尺度 σ2:

// 5. 估计噪声方差 σ² = RSS / (N - p) double sigma2 = final_cost / (N - 3);

计算最终标准差,也就是最终的精度:

// 6. 计算最终标准差:std = sqrt(σ² × (JᵀJ)⁻¹_ii) double std_a = sqrt(cov_a * sigma2); double std_b = sqrt(cov_b * sigma2); double std_c = sqrt(cov_c * sigma2);

3 补充

3.1 资源管理

回到构建 Ceres 问题的关键代码:

// ======================== // 4. 构建 Ceres 问题 // ======================== ceres::Problem problem; for (int i = 0; i < N; ++i) { ceres::CostFunction* cost_function = new ceres::AutoDiffCostFunction<ExpModelResidual, 1, 1, 1, 1>( new ExpModelResidual(x_data[i], y_data[i])); problem.AddResidualBlock(cost_function, nullptr, &a, &b, &c); }

可以看到这里使用了new但是没有对应的delete,不是代码中存在遗漏,而是 Ceres 的 API 设计是基于裸指针 + 显式所有权转移来实现的。这种设计在 C++ 中非常常见(比如Qt),简而言之,就是一个对象会托管住其数据成员的所有权。在这里就是Problem会在自身析构时自动delete所有它拥有的CostFunction,AutoDiffCostFunction又会托管住一个new出来的ExpModelResidual*。像这样一层一层嵌套,只用管理Problem对象就可以控制整个链条的资源。

3.2 自动求导

ExpModelResidual中,虽然定义了残差计算:

ri(a,b,c)=yi−exp(a∗xi²+b∗xi+c)

但 Ceres 需要:

  • 残差值:用于计算总代价 ∑r2i
  • 雅可比矩阵:用于构建 JTJ 和梯度 JTr

Ceres 的解决方案是通过模板参数类型T的不同,让同一个operator()既能算值又能算导数:

template <typename T> bool operator()(const T* a, const T* b, const T* c, T* residual) const { T exponent = (*a) * T(x_) * T(x_) + (*b) * T(x_) + (*c); T y_pred = ceres::exp(exponent); // ← 重要!用 ceres::exp residual[0] = T(y_) - y_pred; return true; }

在这里T可以是double(纯数值),也可以是ceres::Jet<double, 3>(数值+导数)。自动微分包装器AutoDiffCostFunction使用了这个ExpModelResidual作为模板参数:

new ceres::AutoDiffCostFunction<ExpModelResidual, 1, 1, 1, 1>(...)

这些模板参数含义是:

参数含义
ExpModelResidual你的残差计算类
1残差维度(每个观测产生 1 个残差)
1, 1, 1三个参数块的维度(a、b、c 各 1 维)

当 Ceres 执行 LM 算法时,会分两步调用AutoDiffCostFunction。当需要计算残差值时,Ceres 内部调用:

// T = double ExpModelResidual r(x_i, y_i); double residual_val; r(&a_val, &b_val, &c_val, &residual_val); // 正常数值计算

当需要计算雅可比矩阵时。Ceres 构造特殊的Jet 数值

// T = ceres::Jet<double, 3> ceres::Jet<double, 3> a_jet(0.05, {1, 0, 0}); // 当前 a=0.05,∂a/∂a=1 ceres::Jet<double, 3> b_jet(-0.4, {0, 1, 0}); // ∂b/∂b=1 ceres::Jet<double, 3> c_jet(1.0, {0, 0, 1}); // ∂c/∂c=1

然后调用:

ExpModelResidual r(x_i, y_i); ceres::Jet<double, 3> residual_jet; r(&a_jet, &b_jet, &c_jet, &residual_jet); // 关键!

注意这里 ceres::Jet<T, N> 是一个模板结构体,大概定义如下:

template<typename T, int N> struct Jet { T a; // 函数值(value) Eigen::Matrix<T, N, 1> v; // 对 N 个参数的偏导数(gradient) Jet() : a(0.0), v(Eigen::Matrix<T, N, 1>::Zero()) {} Jet(const T& value) : a(value), v(Eigen::Matrix<T, N, 1>::Zero()) {} Jet(const T& value, const Eigen::Matrix<T, N, 1>& derivatives) : a(value), v(derivatives) {} };

如果使用 Ceres 的内置函数计算 Jet 类型的数值,就可以自动传播导数。例如这里使用的ceres::exp

template<typename T, int N> inline Jet<T, N> exp(const Jet<T, N>& x) { T exp_a = std::exp(x.a); // 标量指数值 return Jet<T, N>(exp_a, exp_a * x.v); // 导数 = exp(x) * dx/dθ }

代入到ExpModelResidualoperator(),其运算的过程就是:

// exponent = a*x² + b*x + c // Jet 运算规则:值相加,导数也相加 exponent = a_jet * 4.0 + b_jet * 2.0 + c_jet; // x_i=2.0 // → exponent.a = 0.05*4 + (-0.4)*2 + 1.0 = 0.9 // → exponent.v = [4.0, 2.0, 1.0] ← 这是 ∂exponent/∂[a,b,c] // y_pred = ceres::exp(exponent) y_pred = ceres::exp(exponent); // → y_pred.a = exp(0.9) // → y_pred.v = exp(0.9) * [4.0, 2.0, 1.0] ← 链式法则! // residual = y_true - y_pred residual_jet = Jet(y_i, [0,0,0]) - y_pred; // → residual.a = y_i - exp(0.9) // → residual.v = [0,0,0] - exp(0.9)*[4,2,1] = -exp(0.9)*[4,2,1]

最终residual_jet.v就是雅可比行向量:

[∂ri∂a,∂ri∂b,∂ri∂c]=−eax2+bx+c⋅[x2,x,1]

4. 实践

尽管笔者在上一篇文章《最小二乘问题详解8:Levenberg-Marquardt方法》中手写实现了 Levenberg-Marquardt(LM)算法,但是求解非线性最小二乘问题是一个很复杂的工程,实践中更倾向于使用 Ceres 这样稳健、高效、通用的成熟库。Ceres 做的工作包含且不局限于:

  1. 线性代数求解器的深度优化:能够自动选择最优线性求解器,对于小规模稠密问题,可以配置DENSE_NORMAL_CHOLESKY,使用普通 Cholesky 求解,对于大规模稀疏问题(如 SLAM、Bundle Adjustment),可以配置SPARSE_NORMAL_CHOLESKY,实现稀疏 Cholesky 求解。
  2. 支持多种高度优化的稀疏线性代数库:例如SuiteSparse、Eigen等,能处理数十万参数、上百万残差项的 Bundle Adjustment 问题。
  3. 雅可比矩阵的高效构建与存储:对于大规模非线性最小二乘问题,雅可比矩阵非常占用内存,例如 1M 观测数据 × 1K 待定参数,存储 double 类型的雅可比矩阵需要 8GB 的内存空间。而 Ceres 可以实现对雅可比矩阵按需计算 + 稀疏表示,用户只需提供每个残差块对局部参数的导数(即AutoDiffCostFunction返回的小 Jacobian 块),自动拼接成全局雅可比矩阵,只存储非零块。
  4. 信任域策略的工业级鲁棒性:成熟的信任域管理,智能的 λ 调整逻辑(基于实际/预测下降比 ρ);在噪声大、初值差、目标函数非凸的情况下仍能稳定收敛。
  5. 参数约束与边界处理:支持参数边界约束。
  6. 损失函数(Loss Function)抗外点能力:内置多种鲁棒损失函数,例如Huber、Cauchy、SoftLOne、Tukey 等,自动降低大残差的权重,抑制 outlier 影响。
  7. 并行化与性能工程:通过CPU多线程或者GPU并行计算残差和雅克比矩阵,提升问题优化效率。

5. 总结

不得不说,要理解一个具有专业背景的库不是那么容易的事情,即使你理解底层算法的原理,但是工程实践又是另外一回事。只有将算法原理与工程实践融汇贯通,才能真正解决问题并创造价值。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/18 10:51:49

vue3和nodejs开发的基于SpringBoot大学生在线教育平台设计与实现18286549

文章目录具体实现截图主要技术与实现手段关于我本系统开发思路java类核心代码部分展示结论源码lw获取/同行可拿货,招校园代理 &#xff1a;文章底部获取博主联系方式&#xff01;具体实现截图 同行可拿货,招校园代理 vue3和nodejs开发的基于SpringBoot大学生在线教育平台设计…

作者头像 李华
网站建设 2026/4/18 8:49:49

WinFsp革命:在Windows上打造自定义文件系统的终极指南

WinFsp革命&#xff1a;在Windows上打造自定义文件系统的终极指南 【免费下载链接】winfsp Windows File System Proxy - FUSE for Windows 项目地址: https://gitcode.com/gh_mirrors/wi/winfsp 你是否曾想过将数据库、云存储甚至内存中的数据变成标准的Windows文件系统…

作者头像 李华
网站建设 2026/4/18 8:53:49

一键抠图神器:AI背景移除工具完全指南

一键抠图神器&#xff1a;AI背景移除工具完全指南 【免费下载链接】stable-diffusion-webui-rembg Removes backgrounds from pictures. Extension for webui. 项目地址: https://gitcode.com/gh_mirrors/st/stable-diffusion-webui-rembg 在当今数字时代&#xff0c;背…

作者头像 李华
网站建设 2026/4/17 21:22:12

Langchain-Chatchat支持哪些文档格式?TXT、PDF、Word一键解析

Langchain-Chatchat 支持哪些文档格式&#xff1f;TXT、PDF、Word一键解析 在企业知识管理日益复杂的今天&#xff0c;如何让散落在各个角落的制度文件、产品手册和会议纪要“活起来”&#xff0c;成为一线员工随手可查的智能助手&#xff0c;正成为一个关键挑战。通用大模型虽…

作者头像 李华
网站建设 2026/4/18 8:50:57

深度解析:Bruno脚本执行环境的阶段差异与最佳实践

深度解析&#xff1a;Bruno脚本执行环境的阶段差异与最佳实践 【免费下载链接】bruno 开源的API探索与测试集成开发环境&#xff08;作为Postman/Insomnia的轻量级替代方案&#xff09; 项目地址: https://gitcode.com/GitHub_Trending/br/bruno Bruno作为开源的API测试…

作者头像 李华
网站建设 2026/4/18 5:32:31

音乐创作的AI革命:腾讯LeVo如何重塑创作边界

在数字音乐创作领域&#xff0c;一场由人工智能引领的变革正在悄然发生。腾讯AI Lab开源的LeVo模型&#xff0c;以其独特的技术架构和多样化的创作能力&#xff0c;为音乐创作带来了前所未有的可能性。本文将从技术演进、创作流程重构和行业影响三个维度&#xff0c;深度解析这…

作者头像 李华