use faer::{
Mat, MatRef, Side,
linalg::solvers::Solve,
sparse::linalg::solvers::{Llt, SymbolicLlt},
};
use slotmap::{SecondaryMap, SlotMap};
use thiserror::Error;
use tracing::warn;
use crate::core::{VarKey, problem::Problem, variable::ManifoldVariable};
use crate::linearizer::LinearizerError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CovarianceAlgorithm {
#[default]
SparseCholesky,
DenseSvd,
}
#[derive(Debug, Clone, Copy)]
pub struct CovarianceOptions {
pub algorithm: CovarianceAlgorithm,
pub apply_variance_scaling: bool,
pub min_reciprocal_condition_number: f64,
}
impl Default for CovarianceOptions {
fn default() -> Self {
Self {
algorithm: CovarianceAlgorithm::default(),
apply_variance_scaling: false,
min_reciprocal_condition_number: 1e-14,
}
}
}
impl CovarianceOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_algorithm(mut self, algorithm: CovarianceAlgorithm) -> Self {
self.algorithm = algorithm;
self
}
pub fn with_variance_scaling(mut self, apply_variance_scaling: bool) -> Self {
self.apply_variance_scaling = apply_variance_scaling;
self
}
pub fn with_min_reciprocal_condition_number(mut self, value: f64) -> Self {
self.min_reciprocal_condition_number = value;
self
}
}
#[derive(Debug, Error)]
pub enum CovarianceError {
#[error(
"Hessian is singular or not positive definite ({context}). This usually means \
unconstrained gauge freedom (e.g. a pose graph with no prior anchoring the first \
pose) or a variable that no residual observes. Add a PriorFactor to fix the gauge, \
or use CovarianceAlgorithm::DenseSvd to obtain the pseudo-inverse."
)]
RankDeficient {
context: String,
},
#[error("Failed to linearize the problem for covariance estimation: {0}")]
Linearization(#[from] LinearizerError),
#[error("Covariance requires at least one variable and one residual block")]
EmptyProblem,
#[error("Failed to assemble the Hessian for covariance estimation: {0}")]
Assembly(String),
#[error("SVD failed during covariance estimation: {0}")]
SvdFailed(String),
}
pub type CovarianceResult<T> = Result<T, CovarianceError>;
#[derive(Debug, Clone)]
pub struct Covariance {
matrix: Mat<f64>,
index_map: SecondaryMap<VarKey, usize>,
full_dofs: SecondaryMap<VarKey, usize>,
fixed_indices: SecondaryMap<VarKey, std::collections::HashSet<usize>>,
}
fn local_free_offset(fixed: &std::collections::HashSet<usize>, local: usize) -> Option<usize> {
if fixed.is_empty() {
return Some(local);
}
if fixed.contains(&local) {
return None;
}
Some(local - fixed.iter().filter(|&&i| i < local).count())
}
impl Covariance {
pub fn compute(
options: CovarianceOptions,
problem: &Problem,
variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
) -> CovarianceResult<Self> {
if variables.is_empty() || problem.num_residual_blocks() == 0 {
return Err(CovarianceError::EmptyProblem);
}
let (index_map, _sorted_vars, total_dof) =
crate::optimizer::build_variable_index_map(variables);
let symbolic = crate::linearizer::cpu::sparse::build_symbolic_structure(
problem, variables, &index_map, total_dof,
)?;
let mut workspace = crate::linearizer::AssemblyWorkspace::build(problem);
let (residuals, jacobian) = crate::linearizer::cpu::sparse::assemble_sparse(
problem,
variables,
&index_map,
&symbolic,
&mut workspace,
)?;
let hessian = {
let mut ne_cache = crate::linalg::sparse::normal_eq::LazyNormalEquations::default();
ne_cache
.compute(&residuals, &jacobian)
.map_err(|e| CovarianceError::Assembly(format!("failed to form H = JᵀJ: {e}")))?
.hessian
};
let mut matrix = match options.algorithm {
CovarianceAlgorithm::SparseCholesky => invert_via_cholesky(&hessian, total_dof)?,
CovarianceAlgorithm::DenseSvd => {
invert_via_svd(&hessian, total_dof, options.min_reciprocal_condition_number)?
}
};
if options.apply_variance_scaling
&& let Some(sigma_squared) = estimate_variance(problem, &residuals, total_dof)
{
matrix *= faer::Scale(sigma_squared);
}
let full_dofs = variables.iter().map(|(k, v)| (k, v.dof())).collect();
let fixed_indices = variables
.iter()
.map(|(k, v)| (k, v.get_fixed_indices().clone()))
.collect();
Ok(Self {
matrix,
index_map,
full_dofs,
fixed_indices,
})
}
pub fn block(&self, key: VarKey) -> Option<Mat<f64>> {
self.block_pair(key, key)
}
pub fn block_pair(&self, a: VarKey, b: VarKey) -> Option<Mat<f64>> {
let (&free_row0, &free_col0) = (self.index_map.get(a)?, self.index_map.get(b)?);
let (full_a, full_b) = (*self.full_dofs.get(a)?, *self.full_dofs.get(b)?);
let (fixed_a, fixed_b) = (self.fixed_indices.get(a)?, self.fixed_indices.get(b)?);
Some(Mat::from_fn(full_a, full_b, |local_a, local_b| {
let (Some(free_a), Some(free_b)) = (
local_free_offset(fixed_a, local_a),
local_free_offset(fixed_b, local_b),
) else {
return 0.0;
};
self.matrix[(free_row0 + free_a, free_col0 + free_b)]
}))
}
pub fn per_variable(&self) -> SecondaryMap<VarKey, Mat<f64>> {
self.index_map
.keys()
.filter_map(|key| Some((key, self.block(key)?)))
.collect()
}
pub fn full(&self) -> MatRef<'_, f64> {
self.matrix.as_ref()
}
pub fn index_map(&self) -> &SecondaryMap<VarKey, usize> {
&self.index_map
}
}
fn invert_via_cholesky(
hessian: &faer::sparse::SparseColMat<usize, f64>,
n: usize,
) -> CovarianceResult<Mat<f64>> {
let symbolic = SymbolicLlt::try_new(hessian.symbolic(), Side::Lower).map_err(|e| {
CovarianceError::RankDeficient {
context: format!("symbolic Cholesky analysis failed: {e:?}"),
}
})?;
let llt = Llt::try_new_with_symbolic(symbolic, hessian.as_ref(), Side::Lower).map_err(|e| {
CovarianceError::RankDeficient {
context: format!("numeric Cholesky factorization failed: {e:?}"),
}
})?;
Ok(llt.solve(Mat::<f64>::identity(n, n)))
}
fn invert_via_svd(
hessian: &faer::sparse::SparseColMat<usize, f64>,
n: usize,
rcond: f64,
) -> CovarianceResult<Mat<f64>> {
let dense = Mat::from_fn(n, n, |i, j| hessian.get(i, j).copied().unwrap_or(0.0));
let svd = dense
.as_ref()
.svd()
.map_err(|e| CovarianceError::SvdFailed(format!("{e:?}")))?;
let singular = svd.S();
let sigma_max = (0..n).map(|i| singular[i]).fold(0.0_f64, f64::max);
if sigma_max <= 0.0 {
return Err(CovarianceError::RankDeficient {
context: "Hessian is entirely zero (rank 0)".to_string(),
});
}
let threshold = sigma_max * rcond;
let inverse_singular: Vec<f64> = (0..n)
.map(|i| {
let s = singular[i];
if s > threshold { 1.0 / s } else { 0.0 }
})
.collect();
let rank = inverse_singular.iter().filter(|&&x| x != 0.0).count();
if rank < n {
warn!(
"Hessian is rank deficient: numeric rank {rank} of {n}. Reporting the \
Moore-Penrose pseudo-inverse, which assigns zero variance along the {} \
null-space direction(s). This usually indicates unconstrained gauge freedom.",
n - rank
);
}
let (u, v) = (svd.U(), svd.V());
let mut scaled_v = Mat::<f64>::zeros(n, n);
for j in 0..n {
for i in 0..n {
scaled_v[(i, j)] = v[(i, j)] * inverse_singular[j];
}
}
Ok(scaled_v * u.transpose())
}
fn estimate_variance(problem: &Problem, residuals: &Mat<f64>, n: usize) -> Option<f64> {
let m = problem.total_residual_dimension();
if m <= n {
warn!(
"Cannot estimate noise variance: {m} residuals for {n} parameters leaves no \
residual degrees of freedom. Returning the unscaled H^-1."
);
return None;
}
let cost = 0.5 * residuals.squared_norm_l2();
Some(2.0 * cost / (m - n) as f64)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ManifoldType;
use crate::factors::Factor;
use crate::linalg::JacobianMode;
use faer::prelude::ReborrowMut;
use nalgebra::dvector;
type TestResult = Result<(), Box<dyn std::error::Error>>;
struct ScaledFactor {
a: f64,
b: f64,
}
impl Factor for ScaledFactor {
fn linearize(
&self,
params: &[&[f64]],
residual: &mut [f64],
jacobian: Option<faer::mat::MatMut<'_, f64>>,
) {
residual[0] = self.a * params[0][0] - self.b;
if let Some(mut jac) = jacobian {
*jac.rb_mut().get_mut(0, 0) = self.a;
}
}
fn residual_dim(&self) -> usize {
1
}
fn jacobian_shape(&self) -> (usize, usize) {
(1, 1)
}
}
struct DifferenceFactor;
impl Factor for DifferenceFactor {
fn linearize(
&self,
params: &[&[f64]],
residual: &mut [f64],
jacobian: Option<faer::mat::MatMut<'_, f64>>,
) {
residual[0] = params[0][0] - params[1][0];
if let Some(mut jac) = jacobian {
*jac.rb_mut().get_mut(0, 0) = 1.0;
*jac.rb_mut().get_mut(0, 1) = -1.0;
}
}
fn residual_dim(&self) -> usize {
1
}
fn jacobian_shape(&self) -> (usize, usize) {
(1, 2)
}
}
fn two_variable_problem() -> (Problem, VarKey, VarKey) {
let mut problem = Problem::new(JacobianMode::Sparse);
let x = problem.add_variable(ManifoldType::RN, dvector![0.0]);
let y = problem.add_variable(ManifoldType::RN, dvector![0.0]);
problem.add_residual_block(&[x], Box::new(ScaledFactor { a: 2.0, b: 1.0 }), None);
problem.add_residual_block(&[y], Box::new(ScaledFactor { a: 10.0, b: 1.0 }), None);
(problem, x, y)
}
#[test]
fn computes_analytic_inverse_hessian() -> TestResult {
let (problem, x, y) = two_variable_problem();
let cov = Covariance::compute(
CovarianceOptions::default(),
&problem,
&problem.variables.clone(),
)?;
let sigma_x = cov.block(x).ok_or("missing block for x")?;
let sigma_y = cov.block(y).ok_or("missing block for y")?;
assert!(
(sigma_x[(0, 0)] - 0.25).abs() < 1e-12,
"expected 1/a² = 0.25, got {}",
sigma_x[(0, 0)]
);
assert!(
(sigma_y[(0, 0)] - 0.01).abs() < 1e-12,
"expected 1/a² = 0.01, got {}",
sigma_y[(0, 0)]
);
Ok(())
}
#[test]
fn independent_variables_have_zero_cross_covariance() -> TestResult {
let (problem, x, y) = two_variable_problem();
let cov = Covariance::compute(
CovarianceOptions::default(),
&problem,
&problem.variables.clone(),
)?;
let cross = cov.block_pair(x, y).ok_or("missing cross block")?;
assert!(cross[(0, 0)].abs() < 1e-12, "got {}", cross[(0, 0)]);
Ok(())
}
#[test]
fn per_variable_matches_individual_blocks() -> TestResult {
let (problem, x, y) = two_variable_problem();
let cov = Covariance::compute(
CovarianceOptions::default(),
&problem,
&problem.variables.clone(),
)?;
let per_var = cov.per_variable();
assert_eq!(per_var.len(), 2);
for key in [x, y] {
let expected = cov.block(key).ok_or("missing block")?;
let actual = per_var.get(key).ok_or("missing per-variable entry")?;
assert!((actual[(0, 0)] - expected[(0, 0)]).abs() < 1e-15);
}
Ok(())
}
#[test]
fn rank_deficient_problem_reports_error_under_cholesky() {
let mut problem = Problem::new(JacobianMode::Sparse);
let x = problem.add_variable(ManifoldType::RN, dvector![0.0]);
let y = problem.add_variable(ManifoldType::RN, dvector![0.0]);
problem.add_residual_block(&[x, y], Box::new(DifferenceFactor), None);
let variables = problem.variables.clone();
match Covariance::compute(CovarianceOptions::default(), &problem, &variables) {
Ok(_) => panic!("rank-deficient H must not silently succeed"),
Err(err) => {
assert!(
matches!(err, CovarianceError::RankDeficient { .. }),
"expected RankDeficient, got {err:?}"
);
let message = err.to_string();
assert!(
message.contains("PriorFactor") && message.contains("DenseSvd"),
"error must tell the user how to proceed, got: {message}"
);
}
}
}
#[test]
fn rank_deficient_problem_succeeds_under_dense_svd() -> TestResult {
let mut problem = Problem::new(JacobianMode::Sparse);
let x = problem.add_variable(ManifoldType::RN, dvector![0.0]);
let y = problem.add_variable(ManifoldType::RN, dvector![0.0]);
problem.add_residual_block(&[x, y], Box::new(DifferenceFactor), None);
let variables = problem.variables.clone();
let cov = Covariance::compute(
CovarianceOptions::new().with_algorithm(CovarianceAlgorithm::DenseSvd),
&problem,
&variables,
)?;
let full = cov.full();
for (i, j, expected) in [(0, 0, 0.25), (0, 1, -0.25), (1, 0, -0.25), (1, 1, 0.25)] {
assert!(
(full[(i, j)] - expected).abs() < 1e-10,
"pinv[{i},{j}] = {} expected {expected}",
full[(i, j)]
);
}
let ones = Mat::from_fn(2, 1, |_, _| 1.0);
let quadratic = (ones.transpose() * full * &ones)[(0, 0)];
assert!(quadratic.abs() < 1e-10, "got {quadratic}");
Ok(())
}
#[test]
fn empty_problem_is_rejected() {
let problem = Problem::new(JacobianMode::Sparse);
let variables = problem.variables.clone();
match Covariance::compute(CovarianceOptions::default(), &problem, &variables) {
Ok(_) => panic!("empty problem must be rejected"),
Err(err) => assert!(matches!(err, CovarianceError::EmptyProblem), "got {err:?}"),
}
}
#[test]
fn variance_scaling_skipped_without_residual_dof() -> TestResult {
let (problem, x, _) = two_variable_problem();
let variables = problem.variables.clone();
let unscaled = Covariance::compute(CovarianceOptions::default(), &problem, &variables)?;
let scaled = Covariance::compute(
CovarianceOptions::new().with_variance_scaling(true),
&problem,
&variables,
)?;
let a = unscaled.block(x).ok_or("missing block")?[(0, 0)];
let b = scaled.block(x).ok_or("missing block")?[(0, 0)];
assert!((a - b).abs() < 1e-15, "{a} vs {b}");
Ok(())
}
#[test]
fn variance_scaling_applies_estimated_noise() -> TestResult {
let mut problem = Problem::new(JacobianMode::Sparse);
let x = problem.add_variable(ManifoldType::RN, dvector![0.0]);
problem.add_residual_block(&[x], Box::new(ScaledFactor { a: 1.0, b: 1.0 }), None);
problem.add_residual_block(&[x], Box::new(ScaledFactor { a: 1.0, b: 3.0 }), None);
let variables = problem.variables.clone();
let cov = Covariance::compute(
CovarianceOptions::new().with_variance_scaling(true),
&problem,
&variables,
)?;
let value = cov.block(x).ok_or("missing block")?[(0, 0)];
let expected = 10.0 * 0.5; assert!(
(value - expected).abs() < 1e-12,
"expected {expected}, got {value}"
);
Ok(())
}
}