1use faer::{
53 Mat, MatRef, Side,
54 linalg::solvers::Solve,
55 sparse::linalg::solvers::{Llt, SymbolicLlt},
56};
57use slotmap::{SecondaryMap, SlotMap};
58use thiserror::Error;
59use tracing::warn;
60
61use crate::core::{VarKey, problem::Problem, variable::ManifoldVariable};
62use crate::linearizer::LinearizerError;
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub enum CovarianceAlgorithm {
71 #[default]
77 SparseCholesky,
78
79 DenseSvd,
86}
87
88#[derive(Debug, Clone, Copy)]
92pub struct CovarianceOptions {
93 pub algorithm: CovarianceAlgorithm,
95
96 pub apply_variance_scaling: bool,
105
106 pub min_reciprocal_condition_number: f64,
109}
110
111impl Default for CovarianceOptions {
112 fn default() -> Self {
113 Self {
114 algorithm: CovarianceAlgorithm::default(),
115 apply_variance_scaling: false,
116 min_reciprocal_condition_number: 1e-14,
117 }
118 }
119}
120
121impl CovarianceOptions {
122 pub fn new() -> Self {
124 Self::default()
125 }
126
127 pub fn with_algorithm(mut self, algorithm: CovarianceAlgorithm) -> Self {
129 self.algorithm = algorithm;
130 self
131 }
132
133 pub fn with_variance_scaling(mut self, apply_variance_scaling: bool) -> Self {
135 self.apply_variance_scaling = apply_variance_scaling;
136 self
137 }
138
139 pub fn with_min_reciprocal_condition_number(mut self, value: f64) -> Self {
141 self.min_reciprocal_condition_number = value;
142 self
143 }
144}
145
146#[derive(Debug, Error)]
155pub enum CovarianceError {
156 #[error(
158 "Hessian is singular or not positive definite ({context}). This usually means \
159 unconstrained gauge freedom (e.g. a pose graph with no prior anchoring the first \
160 pose) or a variable that no residual observes. Add a PriorFactor to fix the gauge, \
161 or use CovarianceAlgorithm::DenseSvd to obtain the pseudo-inverse."
162 )]
163 RankDeficient {
164 context: String,
166 },
167
168 #[error("Failed to linearize the problem for covariance estimation: {0}")]
170 Linearization(#[from] LinearizerError),
171
172 #[error("Covariance requires at least one variable and one residual block")]
174 EmptyProblem,
175
176 #[error("Failed to assemble the Hessian for covariance estimation: {0}")]
178 Assembly(String),
179
180 #[error("SVD failed during covariance estimation: {0}")]
182 SvdFailed(String),
183}
184
185pub type CovarianceResult<T> = Result<T, CovarianceError>;
187
188#[derive(Debug, Clone)]
204pub struct Covariance {
205 matrix: Mat<f64>,
207 index_map: SecondaryMap<VarKey, usize>,
209 full_dofs: SecondaryMap<VarKey, usize>,
211 fixed_indices: SecondaryMap<VarKey, std::collections::HashSet<usize>>,
213}
214
215fn local_free_offset(fixed: &std::collections::HashSet<usize>, local: usize) -> Option<usize> {
220 if fixed.is_empty() {
221 return Some(local);
222 }
223 if fixed.contains(&local) {
224 return None;
225 }
226 Some(local - fixed.iter().filter(|&&i| i < local).count())
227}
228
229impl Covariance {
230 pub fn compute(
235 options: CovarianceOptions,
236 problem: &Problem,
237 variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
238 ) -> CovarianceResult<Self> {
239 if variables.is_empty() || problem.num_residual_blocks() == 0 {
240 return Err(CovarianceError::EmptyProblem);
241 }
242
243 let (index_map, _sorted_vars, total_dof) =
249 crate::optimizer::build_variable_index_map(variables);
250
251 let symbolic = crate::linearizer::cpu::sparse::build_symbolic_structure(
255 problem, variables, &index_map, total_dof,
256 )?;
257 let mut workspace = crate::linearizer::AssemblyWorkspace::build(problem);
258 let (residuals, jacobian) = crate::linearizer::cpu::sparse::assemble_sparse(
259 problem,
260 variables,
261 &index_map,
262 &symbolic,
263 &mut workspace,
264 )?;
265
266 let hessian = {
269 let mut ne_cache = crate::linalg::sparse::normal_eq::LazyNormalEquations::default();
270 ne_cache
271 .compute(&residuals, &jacobian)
272 .map_err(|e| CovarianceError::Assembly(format!("failed to form H = JᵀJ: {e}")))?
273 .hessian
274 };
275
276 let mut matrix = match options.algorithm {
277 CovarianceAlgorithm::SparseCholesky => invert_via_cholesky(&hessian, total_dof)?,
278 CovarianceAlgorithm::DenseSvd => {
279 invert_via_svd(&hessian, total_dof, options.min_reciprocal_condition_number)?
280 }
281 };
282
283 if options.apply_variance_scaling
284 && let Some(sigma_squared) = estimate_variance(problem, &residuals, total_dof)
285 {
286 matrix *= faer::Scale(sigma_squared);
287 }
288
289 let full_dofs = variables.iter().map(|(k, v)| (k, v.dof())).collect();
290 let fixed_indices = variables
291 .iter()
292 .map(|(k, v)| (k, v.get_fixed_indices().clone()))
293 .collect();
294
295 Ok(Self {
296 matrix,
297 index_map,
298 full_dofs,
299 fixed_indices,
300 })
301 }
302
303 pub fn block(&self, key: VarKey) -> Option<Mat<f64>> {
306 self.block_pair(key, key)
307 }
308
309 pub fn block_pair(&self, a: VarKey, b: VarKey) -> Option<Mat<f64>> {
312 let (&free_row0, &free_col0) = (self.index_map.get(a)?, self.index_map.get(b)?);
313 let (full_a, full_b) = (*self.full_dofs.get(a)?, *self.full_dofs.get(b)?);
314 let (fixed_a, fixed_b) = (self.fixed_indices.get(a)?, self.fixed_indices.get(b)?);
315
316 Some(Mat::from_fn(full_a, full_b, |local_a, local_b| {
317 let (Some(free_a), Some(free_b)) = (
318 local_free_offset(fixed_a, local_a),
319 local_free_offset(fixed_b, local_b),
320 ) else {
321 return 0.0;
322 };
323 self.matrix[(free_row0 + free_a, free_col0 + free_b)]
324 }))
325 }
326
327 pub fn per_variable(&self) -> SecondaryMap<VarKey, Mat<f64>> {
329 self.index_map
330 .keys()
331 .filter_map(|key| Some((key, self.block(key)?)))
332 .collect()
333 }
334
335 pub fn full(&self) -> MatRef<'_, f64> {
340 self.matrix.as_ref()
341 }
342
343 pub fn index_map(&self) -> &SecondaryMap<VarKey, usize> {
345 &self.index_map
346 }
347}
348
349fn invert_via_cholesky(
358 hessian: &faer::sparse::SparseColMat<usize, f64>,
359 n: usize,
360) -> CovarianceResult<Mat<f64>> {
361 let symbolic = SymbolicLlt::try_new(hessian.symbolic(), Side::Lower).map_err(|e| {
362 CovarianceError::RankDeficient {
363 context: format!("symbolic Cholesky analysis failed: {e:?}"),
364 }
365 })?;
366 let llt = Llt::try_new_with_symbolic(symbolic, hessian.as_ref(), Side::Lower).map_err(|e| {
367 CovarianceError::RankDeficient {
368 context: format!("numeric Cholesky factorization failed: {e:?}"),
369 }
370 })?;
371
372 Ok(llt.solve(Mat::<f64>::identity(n, n)))
373}
374
375fn invert_via_svd(
381 hessian: &faer::sparse::SparseColMat<usize, f64>,
382 n: usize,
383 rcond: f64,
384) -> CovarianceResult<Mat<f64>> {
385 let dense = Mat::from_fn(n, n, |i, j| hessian.get(i, j).copied().unwrap_or(0.0));
386
387 let svd = dense
388 .as_ref()
389 .svd()
390 .map_err(|e| CovarianceError::SvdFailed(format!("{e:?}")))?;
391
392 let singular = svd.S();
393 let sigma_max = (0..n).map(|i| singular[i]).fold(0.0_f64, f64::max);
394 if sigma_max <= 0.0 {
395 return Err(CovarianceError::RankDeficient {
396 context: "Hessian is entirely zero (rank 0)".to_string(),
397 });
398 }
399
400 let threshold = sigma_max * rcond;
401 let inverse_singular: Vec<f64> = (0..n)
402 .map(|i| {
403 let s = singular[i];
404 if s > threshold { 1.0 / s } else { 0.0 }
405 })
406 .collect();
407 let rank = inverse_singular.iter().filter(|&&x| x != 0.0).count();
408
409 if rank < n {
410 warn!(
411 "Hessian is rank deficient: numeric rank {rank} of {n}. Reporting the \
412 Moore-Penrose pseudo-inverse, which assigns zero variance along the {} \
413 null-space direction(s). This usually indicates unconstrained gauge freedom.",
414 n - rank
415 );
416 }
417
418 let (u, v) = (svd.U(), svd.V());
420 let mut scaled_v = Mat::<f64>::zeros(n, n);
421 for j in 0..n {
422 for i in 0..n {
423 scaled_v[(i, j)] = v[(i, j)] * inverse_singular[j];
424 }
425 }
426
427 Ok(scaled_v * u.transpose())
428}
429
430fn estimate_variance(problem: &Problem, residuals: &Mat<f64>, n: usize) -> Option<f64> {
435 let m = problem.total_residual_dimension();
436 if m <= n {
437 warn!(
438 "Cannot estimate noise variance: {m} residuals for {n} parameters leaves no \
439 residual degrees of freedom. Returning the unscaled H^-1."
440 );
441 return None;
442 }
443 let cost = 0.5 * residuals.squared_norm_l2();
444 Some(2.0 * cost / (m - n) as f64)
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use crate::ManifoldType;
451 use crate::factors::Factor;
452 use crate::linalg::JacobianMode;
453 use faer::prelude::ReborrowMut;
454 use nalgebra::dvector;
455
456 type TestResult = Result<(), Box<dyn std::error::Error>>;
457
458 struct ScaledFactor {
460 a: f64,
461 b: f64,
462 }
463
464 impl Factor for ScaledFactor {
465 fn linearize(
466 &self,
467 params: &[&[f64]],
468 residual: &mut [f64],
469 jacobian: Option<faer::mat::MatMut<'_, f64>>,
470 ) {
471 residual[0] = self.a * params[0][0] - self.b;
472 if let Some(mut jac) = jacobian {
473 *jac.rb_mut().get_mut(0, 0) = self.a;
474 }
475 }
476 fn residual_dim(&self) -> usize {
477 1
478 }
479 fn jacobian_shape(&self) -> (usize, usize) {
480 (1, 1)
481 }
482 }
483
484 struct DifferenceFactor;
487
488 impl Factor for DifferenceFactor {
489 fn linearize(
490 &self,
491 params: &[&[f64]],
492 residual: &mut [f64],
493 jacobian: Option<faer::mat::MatMut<'_, f64>>,
494 ) {
495 residual[0] = params[0][0] - params[1][0];
496 if let Some(mut jac) = jacobian {
497 *jac.rb_mut().get_mut(0, 0) = 1.0;
498 *jac.rb_mut().get_mut(0, 1) = -1.0;
499 }
500 }
501 fn residual_dim(&self) -> usize {
502 1
503 }
504 fn jacobian_shape(&self) -> (usize, usize) {
505 (1, 2)
506 }
507 }
508
509 fn two_variable_problem() -> (Problem, VarKey, VarKey) {
512 let mut problem = Problem::new(JacobianMode::Sparse);
513 let x = problem.add_variable(ManifoldType::RN, dvector![0.0]);
514 let y = problem.add_variable(ManifoldType::RN, dvector![0.0]);
515 problem.add_residual_block(&[x], Box::new(ScaledFactor { a: 2.0, b: 1.0 }), None);
516 problem.add_residual_block(&[y], Box::new(ScaledFactor { a: 10.0, b: 1.0 }), None);
517 (problem, x, y)
518 }
519
520 #[test]
521 fn computes_analytic_inverse_hessian() -> TestResult {
522 let (problem, x, y) = two_variable_problem();
523 let cov = Covariance::compute(
524 CovarianceOptions::default(),
525 &problem,
526 &problem.variables.clone(),
527 )?;
528
529 let sigma_x = cov.block(x).ok_or("missing block for x")?;
530 let sigma_y = cov.block(y).ok_or("missing block for y")?;
531
532 assert!(
533 (sigma_x[(0, 0)] - 0.25).abs() < 1e-12,
534 "expected 1/a² = 0.25, got {}",
535 sigma_x[(0, 0)]
536 );
537 assert!(
538 (sigma_y[(0, 0)] - 0.01).abs() < 1e-12,
539 "expected 1/a² = 0.01, got {}",
540 sigma_y[(0, 0)]
541 );
542 Ok(())
543 }
544
545 #[test]
546 fn independent_variables_have_zero_cross_covariance() -> TestResult {
547 let (problem, x, y) = two_variable_problem();
548 let cov = Covariance::compute(
549 CovarianceOptions::default(),
550 &problem,
551 &problem.variables.clone(),
552 )?;
553
554 let cross = cov.block_pair(x, y).ok_or("missing cross block")?;
555 assert!(cross[(0, 0)].abs() < 1e-12, "got {}", cross[(0, 0)]);
556 Ok(())
557 }
558
559 #[test]
560 fn per_variable_matches_individual_blocks() -> TestResult {
561 let (problem, x, y) = two_variable_problem();
562 let cov = Covariance::compute(
563 CovarianceOptions::default(),
564 &problem,
565 &problem.variables.clone(),
566 )?;
567
568 let per_var = cov.per_variable();
569 assert_eq!(per_var.len(), 2);
570 for key in [x, y] {
571 let expected = cov.block(key).ok_or("missing block")?;
572 let actual = per_var.get(key).ok_or("missing per-variable entry")?;
573 assert!((actual[(0, 0)] - expected[(0, 0)]).abs() < 1e-15);
574 }
575 Ok(())
576 }
577
578 #[test]
581 fn rank_deficient_problem_reports_error_under_cholesky() {
582 let mut problem = Problem::new(JacobianMode::Sparse);
583 let x = problem.add_variable(ManifoldType::RN, dvector![0.0]);
584 let y = problem.add_variable(ManifoldType::RN, dvector![0.0]);
585 problem.add_residual_block(&[x, y], Box::new(DifferenceFactor), None);
586
587 let variables = problem.variables.clone();
588 match Covariance::compute(CovarianceOptions::default(), &problem, &variables) {
589 Ok(_) => panic!("rank-deficient H must not silently succeed"),
590 Err(err) => {
591 assert!(
592 matches!(err, CovarianceError::RankDeficient { .. }),
593 "expected RankDeficient, got {err:?}"
594 );
595 let message = err.to_string();
596 assert!(
597 message.contains("PriorFactor") && message.contains("DenseSvd"),
598 "error must tell the user how to proceed, got: {message}"
599 );
600 }
601 }
602 }
603
604 #[test]
607 fn rank_deficient_problem_succeeds_under_dense_svd() -> TestResult {
608 let mut problem = Problem::new(JacobianMode::Sparse);
609 let x = problem.add_variable(ManifoldType::RN, dvector![0.0]);
610 let y = problem.add_variable(ManifoldType::RN, dvector![0.0]);
611 problem.add_residual_block(&[x, y], Box::new(DifferenceFactor), None);
612
613 let variables = problem.variables.clone();
614 let cov = Covariance::compute(
615 CovarianceOptions::new().with_algorithm(CovarianceAlgorithm::DenseSvd),
616 &problem,
617 &variables,
618 )?;
619
620 let full = cov.full();
623 for (i, j, expected) in [(0, 0, 0.25), (0, 1, -0.25), (1, 0, -0.25), (1, 1, 0.25)] {
624 assert!(
625 (full[(i, j)] - expected).abs() < 1e-10,
626 "pinv[{i},{j}] = {} expected {expected}",
627 full[(i, j)]
628 );
629 }
630
631 let ones = Mat::from_fn(2, 1, |_, _| 1.0);
633 let quadratic = (ones.transpose() * full * &ones)[(0, 0)];
634 assert!(quadratic.abs() < 1e-10, "got {quadratic}");
635 Ok(())
636 }
637
638 #[test]
639 fn empty_problem_is_rejected() {
640 let problem = Problem::new(JacobianMode::Sparse);
641 let variables = problem.variables.clone();
642 match Covariance::compute(CovarianceOptions::default(), &problem, &variables) {
643 Ok(_) => panic!("empty problem must be rejected"),
644 Err(err) => assert!(matches!(err, CovarianceError::EmptyProblem), "got {err:?}"),
645 }
646 }
647
648 #[test]
651 fn variance_scaling_skipped_without_residual_dof() -> TestResult {
652 let (problem, x, _) = two_variable_problem();
653 let variables = problem.variables.clone();
654
655 let unscaled = Covariance::compute(CovarianceOptions::default(), &problem, &variables)?;
656 let scaled = Covariance::compute(
657 CovarianceOptions::new().with_variance_scaling(true),
658 &problem,
659 &variables,
660 )?;
661
662 let a = unscaled.block(x).ok_or("missing block")?[(0, 0)];
663 let b = scaled.block(x).ok_or("missing block")?[(0, 0)];
664 assert!((a - b).abs() < 1e-15, "{a} vs {b}");
665 Ok(())
666 }
667
668 #[test]
670 fn variance_scaling_applies_estimated_noise() -> TestResult {
671 let mut problem = Problem::new(JacobianMode::Sparse);
672 let x = problem.add_variable(ManifoldType::RN, dvector![0.0]);
673 problem.add_residual_block(&[x], Box::new(ScaledFactor { a: 1.0, b: 1.0 }), None);
676 problem.add_residual_block(&[x], Box::new(ScaledFactor { a: 1.0, b: 3.0 }), None);
677
678 let variables = problem.variables.clone();
679 let cov = Covariance::compute(
680 CovarianceOptions::new().with_variance_scaling(true),
681 &problem,
682 &variables,
683 )?;
684
685 let value = cov.block(x).ok_or("missing block")?[(0, 0)];
686 let expected = 10.0 * 0.5; assert!(
688 (value - expected).abs() < 1e-12,
689 "expected {expected}, got {value}"
690 );
691 Ok(())
692 }
693}