use gam_linalg::faer_ndarray::FaerSvd;
use ndarray::{Array2, ArrayView2};
use rayon::prelude::*;
use std::collections::BTreeMap;
pub(super) fn effective_dof(
decoder: ArrayView2<'_, f32>,
indices: ArrayView2<'_, u32>,
rho: f64,
) -> Result<f64, String> {
if !(rho.is_finite() && rho > 0.0) {
return Err("row-code evidence requires a finite positive variance ratio".into());
}
if indices.iter().any(|&atom| atom as usize >= decoder.nrows()) {
return Err("row-code evidence support index is out of range".into());
}
if indices.ncols() == 1 {
let norms: Vec<f64> = decoder
.outer_iter()
.map(|row| row.iter().map(|&v| (v as f64).powi(2)).sum())
.collect();
return Ok(indices
.column(0)
.iter()
.map(|&atom| {
let norm = norms[atom as usize];
norm / (norm + rho)
})
.sum());
}
let mut supports = BTreeMap::<Vec<u32>, usize>::new();
for row in indices.outer_iter() {
let mut support = row.to_vec();
support.sort_unstable();
support.dedup();
*supports.entry(support).or_default() += 1;
}
let supports: Vec<_> = supports.into_iter().collect();
let contributions: Result<Vec<f64>, String> = supports
.into_par_iter()
.map(|(support, count)| {
let basis =
Array2::from_shape_fn((decoder.ncols(), support.len()), |(feature, slot)| {
decoder[[support[slot] as usize, feature]] as f64
});
let (_, singular, _) = basis
.svd(false, false)
.map_err(|error| format!("row-code evidence SVD failed: {error}"))?;
if singular
.iter()
.any(|&value| !value.is_finite() || value < 0.0)
{
return Err("row-code evidence has an invalid singular spectrum".into());
}
let trace: f64 = singular
.iter()
.map(|&value| {
let energy = value * value;
energy / (energy + rho)
})
.sum();
Ok(trace * count as f64)
})
.collect();
Ok(contributions?.into_iter().sum())
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
#[test]
fn support_padding_and_distinct_collinear_atoms_have_different_prior_variance() {
let decoder = array![[1.0_f32, 0.0], [1.0, 0.0]];
let padding = array![[0_u32, 0], [1, 1]];
let separate = array![[0_u32, 1], [1, 0]];
assert!((effective_dof(decoder.view(), padding.view(), 1.0).unwrap() - 1.0).abs() < 1e-14);
assert!(
(effective_dof(decoder.view(), separate.view(), 1.0).unwrap() - 4.0 / 3.0).abs()
< 1e-14
);
}
#[test]
fn near_dependent_support_retains_its_resolved_small_direction() {
let delta = 1e-10_f32;
let decoder = array![[1.0_f32, 0.0], [1.0, delta]];
let indices = array![[0_u32, 1]];
let rho = (delta as f64).powi(2);
let trace = effective_dof(decoder.view(), indices.view(), rho).unwrap();
assert!((trace - 4.0 / 3.0).abs() < 1e-12, "{trace}");
}
#[test]
fn exact_support_trace_matches_the_response_space_smoother() {
use gam_linalg::faer_ndarray::FaerCholesky;
let decoder = array![[1.0_f32, 0.0, 0.0], [0.6, 0.8, 0.0], [0.0, 0.0, 1.0]];
let indices = array![[0_u32, 1], [1, 0], [1, 2], [2, 2]];
for rho in [0.01_f64, 0.5, 10.0] {
let mut expected = 0.0;
for row in indices.outer_iter() {
let mut support = row.to_vec();
support.sort_unstable();
support.dedup();
let b = Array2::from_shape_fn((3, support.len()), |(i, j)| {
decoder[[support[j] as usize, i]] as f64
});
let covariance = b.dot(&b.t());
let mut total = covariance.clone();
for i in 0..3 {
total[[i, i]] += rho;
}
let smoother = total
.cholesky(faer::Side::Lower)
.unwrap()
.solve_mat(&covariance);
expected += smoother.diag().sum();
}
let actual = effective_dof(decoder.view(), indices.view(), rho).unwrap();
assert!(
(actual - expected).abs() < 1e-12,
"rho={rho}: {actual} vs {expected}"
);
}
}
}