
参数调整方法在 Ceres 里90% 的“优化效果不好 / 发散 / 很慢”问题本质都不是模型错而是参数与数值尺度没调对。调 Ceres 不是拍脑袋改max_num_iterations而是有一套非常固定、非常工程化的调参顺序。按这个顺序来基本都能稳。一、先理解 Ceres 优化的本质Ceres 在做的事情最小化 ‖ residual(x) ‖²核心是雅可比矩阵 J 的数值性质。如果出现收敛慢震荡发散cost 不降步长极小99% 是 J 的数值尺度和条件数出了问题。所以调参的本质是把问题变成一个“数值上好解的最小二乘问题”二、调参黄金顺序非常重要严格按这个顺序调别乱跳第 1 步检查 residual 尺度最重要打印初始 residualproblem.Evaluate(...);你会看到类似residual: 1e-6 residual: 1e3 residual: 50如果 residual 跨越6 个数量级以上Ceres 基本必炸。原则所有 residual 的数量级要在 1 ~ 10 之间比如类型原始量必须除以像素误差0~200 px100米0~100 m10 或 100角度rad0.1时间秒1不做这个后面所有调参都白费第 2 步开启正确的线性求解器小规模 BA / SLAMoptions.linear_solver_typeceres::SPARSE_NORMAL_CHOLESKY;大规模options.linear_solver_typeceres::ITERATIVE_SCHUR;options.preconditioner_typeceres::SCHUR_JACOBI;默认 DENSE_QR 是最坑的经常让人误以为算法有问题。第 3 步信赖域策略决定是否稳定如果发散 / 抖动options.trust_region_strategy_typeceres::LEVENBERG_MARQUARDT;如果已经比较稳但慢options.trust_region_strategy_typeceres::DOGLEG;经验现象解决发散LM很慢DOGLEG第 4 步参数块尺度极关键但很少人知道如果一个参数是位姿米外参米时间延迟秒 1e-3bias1e-5必须加 LocalParameterization 或手动 scale例如时间延迟residual/0.01;// 等价于对参数放大 100 倍否则 Ceres 认为它“几乎不重要”永远不更新。第 5 步Robust Loss防止被野值带飞真实数据一定要加newceres::HuberLoss(1.0);如果不加少量 outlier 就会让你误以为“Ceres 收敛性很差”其实是你没加鲁棒核。第 6 步步长相关参数解决“几乎不动”如果你看到step_norm: 1e-12说明步长被限制死了options.min_trust_region_radius1e-12;options.max_trust_region_radius1e12;options.initial_trust_region_radius1e4;这一步能救活大量“死优化”。第 7 步迭代策略不要上来就 100 次options.max_num_iterations20;如果 20 次都没明显下降不是次数问题是前面没调对。三、典型现象 → 对应解法工程速查表现象根因解法cost 不降residual 尺度乱归一化 residual一直震荡trust region 不对LM收敛极慢solver 不对SPARSE_NORMAL_CHOLESKY某些参数不更新参数尺度太小手动 scale一加真实数据就炸没加 Huber加鲁棒核step 很小trust region 限死调 radius结果漂没固定 gauge固定一个 pose四、调参调试神器必须开options.minimizer_progress_to_stdouttrue;options.logging_typeceres::PER_MINIMIZER_ITERATION;观察cost 下降曲线step_normtrust_region_radius只看这三项就能判断哪里有问题五、真正高手的调参顺序总结1. 先调 residual 尺度2. 再选 solver3. 再选 LM / Dogleg4. 再调参数尺度5. 再加 Huber6. 最后才是 iterations很多人反着来所以永远调不好。六、一句话精髓Ceres 从来不会“算错”是你把一个数值病态的问题交给它了。很多人以为 Ceres “不好调”其实是不会用它自带的调试工具。Ceres 给的调试能力非常强强到你可以精确看到是哪一个 residual、哪一个参数块把优化搞坏了。下面是工程里真正有用的一整套 Ceres 调试工具链不是官方文档那种泛泛介绍。调试方法一、第一件事打开最关键的日志必做options.minimizer_progress_to_stdouttrue;options.logging_typeceres::PER_MINIMIZER_ITERATION;你会看到每次迭代iter cost cost_change |gradient| |step| tr_radius这 5 列就是诊断全部问题的核心。现象问题cost 几乎不变residual 尺度错gradient 巨大Jacobian 数值爆炸step 很小trust region 卡死 / 参数尺度问题tr_radius 一直变小LM 在救场问题病态cost 上下震荡outlier / 没加 robust loss二、Problem::Evaluate —— 最强武器90%的人不知道在优化前ceres::Problem::EvaluateOptions eval_options;std::vectordoubleresiduals;problem.Evaluate(eval_options,nullptr,residuals,nullptr,nullptr);打印 residual 分布for(autor:residuals)std::coutrstd::endl;你会立刻发现0.00001 1200 35 0.3这一步直接暴露尺度灾难这是最重要的调试手段。三、检查 Jacobian 是否数值正确非常关键Ceres 自带数值求导对比工具options.check_gradientstrue;options.gradient_check_relative_precision1e-6;如果你的残差或雅可比写错会直接报Gradient error detected!这比你肉眼查代码快 100 倍。强烈建议新残差函数必须开这个跑一次四、查看每个残差块的贡献定位“坏因子”ceres::Problem::EvaluateOptions eval_options;eval_options.apply_loss_functionfalse;eval_options.num_threads1;eval_options.parameter_blocks{};eval_options.residual_blocks{};然后逐个 residual block evaluate。你会发现某一个 residual 的 cost 是别人的 1000 倍这就是优化发散的罪魁祸首。五、查看参数是否真的在更新很多时候你以为在优化其实参数根本没动。优化前后打印std::coutpose[0]std::endl;或者用summary.FullReport();看Parameter blocks: 20 Parameters: 140 Effective parameters: 134如果 effective 很少说明被固定 / 尺度太小 / 局部参数化错误。六、查看条件数问题隐藏杀手打开options.linear_solver_typeceres::DENSE_QR;options.minimizer_progress_to_stdouttrue;如果 DENSE_QR 都算得很慢就是条件数爆炸尺度问题。这是判断“是不是数值病态”的简单方法。七、可视化 trust region 行为判断 LM 是否在救火看这一列tr_radius如果看到1e4 → 1e2 → 1e0 → 1e-3 → 1e-6说明LM 在疯狂缩步长因为问题非常不健康不是迭代次数问题。八、关闭鲁棒核看问题定位 outliereval_options.apply_loss_functionfalse;如果 cost 突然变得巨大数据里有野值Huber 在帮你兜底九、Ceres 内置调试神器Solver::Summarystd::coutsummary.BriefReport()std::endl;std::coutsummary.FullReport()std::endl;重点看字段含义initial_cost / final_cost是否真的下降num_successful_steps是否一直失败linear_solver_iterations线性系统是否难解termination_type为什么停十、高手常用调试流程真实工程Evaluate看 residual 尺度check_gradients查 Jacobian看 iter 日志的 5 列关闭 loss 看野值看 tr_radius 判断健康度打印参数前后变化按这个流程任何 Ceres 问题都能定位到根因。一句话总结Ceres 最大的调试工具不是调参数而是用 Evaluate 和日志把问题“数值化地看出来”。可视化工具Ceres 本身不提供可视化界面但它把所有你需要可视化的数据都暴露出来了每次迭代的 cost / step / gradient / trust region每个 residual block 的误差每个参数块的变化量Jacobian 数值质量所以工程上真正的做法是把 Ceres 的内部数值过程“导出来”自己画图这是调 Ceres 最有效、最专业的方法。一、第一步记录每次迭代数据核心利用IterationCallbackstructLogCallback:publicceres::IterationCallback{std::ofstream file;LogCallback(){file.open(ceres_log.csv);}ceres::CallbackReturnTypeoperator()(constceres::IterationSummarys)override{files.iteration,s.cost,s.gradient_norm,s.step_norm,s.trust_region_radius\n;returnceres::SOLVER_CONTINUE;}};注册LogCallback*cbnewLogCallback();options.callbacks.push_back(cb);options.update_state_every_iterationtrue;你会得到iter, cost, grad, step, tr 0, 1523, ... 1, 820, ... ...二、用 Python / matplotlib 画图效果极好importpandasaspdimportmatplotlib.pyplotasplt dfpd.read_csv(ceres_log.csv,names[iter,cost,grad,step,tr])plt.figure()plt.plot(df[iter],df[cost])plt.title(Cost)plt.figure()plt.plot(df[iter],df[step])plt.title(Step norm)plt.figure()plt.plot(df[iter],df[tr])plt.title(Trust region)plt.show()三张图直接告诉你图能看出什么cost 曲线是否正常收敛 / 震荡step 曲线是否被卡死trust regionLM 是否在救火三、可视化每个 residual 的分布定位坏因子优化前后std::vectordoubleresiduals;problem.Evaluate(...,residuals,...);保存到文件再画直方图plt.hist(residuals,bins100)如果看到长尾某些残差把优化拖死了四、可视化参数变化量很多人忽略记录每次迭代参数filepose[0],pose[1],pose[2]\n;画轨迹plt.plot(x,y)能直接看到参数是否真的在动是否震荡是否卡在局部极小五、Jacobian 质量可视化高级但杀伤力极大用check_gradientstrue把数值误差输出重定向到文件。统计每个 residual 的 gradient error画柱状图。你能精准定位是哪个残差的雅可比写错。六、推荐现成可视化工具组合工程常用工具用途Python matplotlib画收敛曲线最常用Jupyter交互分析 residual 分布PlotJugglerROS实时看 cost / stepExcel快速看 csv 曲线gnuplot服务器无 GUI 时七、真正有用的 4 张图必画Cost vs IterStep norm vs IterTrust region vs IterResidual histogram只看这 4 张图80% 的 Ceres 问题一眼就能看出来。八、一句话核心Ceres 调不好是因为你在“盲调”。一旦把内部数值过程画出来问题会自己跳出来。这是 Ceres 里非常关键、但文档讲得很抽象的一点。很多人只知道LM 稳定Dogleg 快但不知道为什么以及什么时候谁一定更好。本质上这是两种完全不同的信赖域Trust Region走法。Dogleg和LM区别一句话本质区别方法在信赖域里怎么走LM (Levenberg–Marquardt)缩短 Gauss-Newton 步长Dogleg换一条折线路径走到边界先理解Gauss-Newton 想走哪一步理想步长是[\Delta x_{GN} (J^T J)^{-1} J^T r]但这个步子经常太大会发散所以要加信赖域限制。LM 在干嘛缩步子LM 做的是[(J^T J \lambda I)\Delta x J^T r]λ 变大 ⇒ 步子变短。特点始终沿着Gauss-Newton 方向只是走得更短像这样GN 方向 ---------- LM 方向 ----它不改变方向只减小长度。Dogleg 在干嘛换路线Dogleg 先算两步最速下降方向Cauchy pointGauss-Newton 方向然后走一条“折线”Cauchy ---- \ \------ GN如果 GN 太远Dogleg 会停在折线和信赖域边界的交点。它不是缩短 GN而是换一条更安全的路径。为什么 Dogleg 通常快很多因为当问题已经接近可解时GN 方向是对的只是步子太大LM把 GN 步子压小 ⇒ 很保守Dogleg先走一小段最速下降再快速接近 GN ⇒更激进所以迭代次数常常是 LM 的1/3 ~ 1/5。为什么 LM 更稳当问题数值很差时SLAM 初值差 / 尺度乱 / 外参时间延迟GN 方向本身是错的。Dogleg 还会“想往 GN 靠”容易抖。LM 因为加了 ( \lambda I )等价于往“梯度下降”方向过渡非常稳。典型现象对比非常实用现象LM 表现Dogleg 表现初值差稳定慢慢收震荡 / 发散已接近最优很慢飞快收敛Jacobian 条件数差能扛住容易抖残差尺度不统一能活容易炸大规模 BA慢很快工程黄金法则非常准先 LM 跑到 cost 明显下降 → 再切 Doglegoptions.trust_region_strategy_typeceres::LEVENBERG_MARQUARDT;收敛到一半后options.trust_region_strategy_typeceres::DOGLEG;这是很多 SLAM / VIO / BA 系统的真实做法。从日志上怎么判断该换谁看这两列step_norm tr_radius如果看到tr_radius 一直在缩 step 很小 LM 在救场问题不健康 →必须 LM如果看到cost 下降很慢 tr_radius 很大 step 很小 方向没问题但太保守 →换 Dogleg本质总结LM相信方向是对的只是步子要小点Dogleg方向不一定对先找条安全路线再冲一句话记忆LM 是“刹车”Dogleg 是“绕路超车”tr_radiustr_radiustrust region radius不是拍脑袋给的它是 Ceres 在每次迭代后根据“这一步的预测效果”和“真实效果的差异”自动算出来、自动调大的。理解它等于看懂 Ceres 为什么缩步 / 放步。一、信赖域的核心思想在当前点 (x)Ceres 用二次模型近似真实 cost[m(\Delta x) \frac12 | r J\Delta x |^2]但这个近似只在小范围内靠谱所以限制[|\Delta x| \le tr_radius]这个半径就是tr_radius。二、每一步都会算一个关键比值 ρrho这是整个 trust region 的灵魂[\rho \frac{\text{真实 cost 降低量}}{\text{模型预测 cost 降低量}}]展开就是[\rho \frac{f(x) - f(x\Delta x)}{m(0) - m(\Delta x)}]含义ρ 值说明ρ ≈ 1二次模型非常准ρ 0方向是对的ρ 0方向是错的发散三、Ceres 如何用 ρ 更新 tr_radius这是 Ceres 源码里的真实逻辑简化版条件操作ρ 0.25tr_radius * 0.25大幅缩小ρ 0.75 且步长打到边界tr_radius * 2放大其他情况不变并且如果 ρ 0这一步直接作废参数回滚四、为什么你会看到 tr_radius 一直变小日志里tr_radius: 1e4 → 1e2 → 1 → 1e-3 → 1e-6说明Ceres 预测这一步很好但实际效果很差ρ 很小也就是Jacobian 数值病态residual 尺度不对参数尺度差异大LM 在拼命缩步救你。五、为什么 tr_radius 会突然变很大当ρ ≈ 1而且步长刚好顶到信赖域边界Ceres 会认为“这模型非常准可以走更大步”于是 radius ×2。这是非常健康的优化特征。六、LM 和 Dogleg 对 tr_radius 的使用区别LMDogleg如何限制步长通过 λ 间接限制直接限制在球内tr_radius 作用间接直接决定步长对 tr_radius 敏感度中非常高所以你会发现Dogleg 对 tr_radius 变化非常敏感七、初始 tr_radius 怎么来的默认options.initial_trust_region_radius1e4;这是经验值假设参数尺度在 1~100 之间。如果你参数是时间延迟 1e-3bias 1e-5这个初始值就完全不合适。八、从 tr_radius 反推问题非常实用现象结论一直缩数值病态 / 尺度错一直很大问题健康来回震荡有 outlier / 模型不准很小且 step 很小参数尺度不对九、一句话本质tr_radius是 Ceres 对你问题“数值健康度”的实时评分。它不是参数是诊断仪表盘。ASAN使用cmakelists增加下面两行直接运行即可有问题会在运行中或结束时报错set(CMAKE_CXX_FLAGS${CMAKE_CXX_FLAGS} -fsanitizeaddress -fno-omit-frame-pointer -g)set(CMAKE_LINKER_FLAGS${CMAKE_LINKER_FLAGS} -fsanitizeaddress)打印雅可比矩阵ceres::ResidualBlockId res_blockproblem.AddResidualBlock();voidprintIMUBlock(ceres::Problemproblem,std::vectorstd::pairdouble,ceres::ResidualBlockIdresidual_ids,doublechi20){doublecost;for(longunsignedinti0;iresidual_ids.size();i){doubleresiduals[9];intjaco_r6;Eigen::Matrixdouble,9,7,Eigen::RowMajorjacobian_pose_iEigen::Matrixdouble,9,7,Eigen::RowMajor::Zero();Eigen::Matrixdouble,9,3,Eigen::RowMajorjacobian_speed_iEigen::Matrixdouble,9,3,Eigen::RowMajor::Zero();Eigen::Matrixdouble,9,7,Eigen::RowMajorjacobian_pose_jEigen::Matrixdouble,9,7,Eigen::RowMajor::Zero();Eigen::Matrixdouble,9,3,Eigen::RowMajorjacobian_speed_jEigen::Matrixdouble,9,3,Eigen::RowMajor::Zero();Eigen::Matrixdouble,9,3,Eigen::RowMajorjacobian_baEigen::Matrixdouble,9,3,Eigen::RowMajor::Zero();Eigen::Matrixdouble,9,3,Eigen::RowMajorjacobian_bgEigen::Matrixdouble,9,3,Eigen::RowMajor::Zero();double*jaco[jaco_r]{jacobian_pose_i.data(),jacobian_speed_i.data(),jacobian_pose_j.data(),jacobian_speed_j.data(),jacobian_ba.data(),jacobian_bg.data()};problem.EvaluateResidualBlock(residual_ids[i].second,false,cost,residuals,jaco);std::string inter_str,;std::ofstreamf_out(,std::ios::app);f_outresidual_ids[i].firstinter_strchi2inter_strcost*2inter_strresiduals[0]inter_strresiduals[1]inter_strresiduals[2]inter_strresiduals[3]inter_strresiduals[4]inter_strresiduals[5]inter_strresiduals[6]inter_strresiduals[7]inter_strresiduals[8]std::endlTi,jacobian_pose_istd::endlVi,jacobian_speed_istd::endlTj,jacobian_pose_jstd::endl;// Vj, jacobian_speed_j std::endl// Ba: jacobian_ba std::endl// Bg: jacobian_bg std::endl;f_out.close();}}problem.RemoveResidualBlock(id);cost_function-Evaluate(parameter_blocks.data(),residuals.data(),raw_jacobians);