use faer::Side;
use gam_linalg::faer_ndarray::strict_symmetric_eigh;
use gam_math::probability::{chi_square_sf, fisher_snedecor_sf};
use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
pub use crate::inference::smooth_test::SmoothTestScale;
const ESTIMABLE_DIRECTION_FLOOR: f64 = 1.0e-9;
pub struct BasisAdequacyInput<'a> {
pub enrichment: ArrayView2<'a, f64>,
pub design: ArrayView2<'a, f64>,
pub hessian_weights: ArrayView1<'a, f64>,
pub score_weights: ArrayView1<'a, f64>,
pub score: ArrayView1<'a, f64>,
pub design_gram: &'a DesignGramFactor,
pub dispersion: f64,
pub residual_df: Option<f64>,
pub scale: SmoothTestScale,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BasisAdequacyResult {
pub statistic: f64,
pub rank: usize,
pub p_value: f64,
}
pub fn basis_adequacy_score_test(
input: BasisAdequacyInput<'_>,
) -> Option<BasisAdequacyResult> {
let n = input.design.nrows();
let p = input.design.ncols();
let q = input.enrichment.ncols();
if n == 0
|| p == 0
|| q == 0
|| input.enrichment.nrows() != n
|| input.hessian_weights.len() != n
|| input.score_weights.len() != n
|| input.score.len() != n
|| input.design_gram.dimension() != p
|| !(input.dispersion.is_finite() && input.dispersion > 0.0)
{
return None;
}
const ROW_BLOCK: usize = 4096;
let mut cross = Array2::<f64>::zeros((p, q)); let mut energy = Array1::<f64>::zeros(q);
let mut start = 0usize;
while start < n {
let stop = (start + ROW_BLOCK).min(n);
let block = input.enrichment.slice(ndarray::s![start..stop, ..]);
let mut hessian_weighted = block.to_owned();
for local in 0..(stop - start) {
let curvature = input.hessian_weights[start + local];
let fisher = input.score_weights[start + local];
if !curvature.is_finite() || !(fisher.is_finite() && fisher >= 0.0) {
return None;
}
let source = block.row(local);
let mut target = hessian_weighted.row_mut(local);
for column in 0..q {
let value = source[column];
energy[column] += fisher * value * value;
target[column] = curvature * value;
}
}
cross += &input
.design
.slice(ndarray::s![start..stop, ..])
.t()
.dot(&hessian_weighted);
start = stop;
}
let energy_scale = energy.iter().cloned().fold(0.0_f64, f64::max);
if !(energy_scale > 0.0) || cross.iter().any(|value| !value.is_finite()) {
return None;
}
let coefficient_shift = input.design_gram.solve(&cross)?;
if coefficient_shift.iter().any(|value| !value.is_finite()) {
return None;
}
let mut information = Array2::<f64>::zeros((q, q));
let mut u = Array1::<f64>::zeros(q);
let mut start = 0usize;
while start < n {
let stop = (start + ROW_BLOCK).min(n);
let rows = stop - start;
let mut residualized = input
.enrichment
.slice(ndarray::s![start..stop, ..])
.to_owned();
residualized -= &input
.design
.slice(ndarray::s![start..stop, ..])
.dot(&coefficient_shift);
u += &residualized
.t()
.dot(&input.score.slice(ndarray::s![start..stop]));
let mut weighted = residualized.clone();
for local in 0..rows {
let weight = input.score_weights[start + local];
if !(weight.is_finite() && weight >= 0.0) {
return None;
}
weighted
.row_mut(local)
.iter_mut()
.for_each(|value| *value *= weight);
}
information += &residualized.t().dot(&weighted);
start = stop;
}
if information.iter().any(|value| !value.is_finite())
|| u.iter().any(|value| !value.is_finite())
{
return None;
}
let symmetric = 0.5 * (&information + &information.t());
let (eigenvalues, eigenvectors) = strict_symmetric_eigh(&symmetric, Side::Lower).ok()?;
let projected: Array1<f64> = eigenvectors.t().dot(&u);
let floor = energy_scale * ESTIMABLE_DIRECTION_FLOOR;
let mut statistic = 0.0_f64;
let mut rank = 0usize;
for (index, &eigenvalue) in eigenvalues.iter().enumerate() {
if eigenvalue > floor {
let component = projected[index];
statistic += component * component / eigenvalue;
rank += 1;
}
}
if rank == 0 {
return None;
}
let statistic = statistic / input.dispersion;
if !statistic.is_finite() || statistic < 0.0 {
return None;
}
let reference_df = rank as f64;
let p_value = match input.scale {
SmoothTestScale::Known => chi_square_sf(statistic, reference_df),
SmoothTestScale::Estimated => {
let residual_df = input
.residual_df
.filter(|value| value.is_finite() && *value > 0.0)?;
fisher_snedecor_sf(statistic / reference_df, reference_df, residual_df)
}
};
if !p_value.is_finite() {
return None;
}
Some(BasisAdequacyResult {
statistic,
rank,
p_value,
})
}
pub struct DesignGramFactor {
kind: DesignGramFactorKind,
dimension: usize,
}
enum DesignGramFactorKind {
Cholesky(gam_linalg::faer_ndarray::FaerCholeskyFactor),
SpectralPseudoInverse(Array2<f64>),
}
impl DesignGramFactor {
pub fn new(gram: ArrayView2<'_, f64>) -> Option<Self> {
use gam_linalg::faer_ndarray::FaerCholesky;
let dimension = gram.nrows();
if dimension == 0
|| gram.ncols() != dimension
|| gram.iter().any(|value| !value.is_finite())
{
return None;
}
let owned = gram.to_owned();
if let Ok(factor) = owned.cholesky(Side::Lower) {
return Some(Self {
kind: DesignGramFactorKind::Cholesky(factor),
dimension,
});
}
let symmetric = 0.5 * (&owned + &owned.t());
let (eigenvalues, eigenvectors) = strict_symmetric_eigh(&symmetric, Side::Lower).ok()?;
let largest = eigenvalues.iter().cloned().fold(0.0_f64, f64::max);
if !(largest > 0.0) {
return None;
}
let floor = largest * GRAM_RANK_FLOOR;
let mut scaled = eigenvectors.clone();
for (index, &eigenvalue) in eigenvalues.iter().enumerate() {
let factor = if eigenvalue > floor {
1.0 / eigenvalue
} else {
0.0
};
scaled.column_mut(index).iter_mut().for_each(|v| *v *= factor);
}
let pseudo_inverse = scaled.dot(&eigenvectors.t());
pseudo_inverse
.iter()
.all(|value| value.is_finite())
.then_some(Self {
kind: DesignGramFactorKind::SpectralPseudoInverse(pseudo_inverse),
dimension,
})
}
pub fn dimension(&self) -> usize {
self.dimension
}
fn solve(&self, rhs: &Array2<f64>) -> Option<Array2<f64>> {
let solved = match &self.kind {
DesignGramFactorKind::Cholesky(factor) => factor.solve_mat(rhs),
DesignGramFactorKind::SpectralPseudoInverse(inverse) => inverse.dot(rhs),
};
solved.iter().all(|value| value.is_finite()).then_some(solved)
}
}
const GRAM_RANK_FLOOR: f64 = 1.0e-12;
#[cfg(test)]
mod tests {
use super::*;
use ndarray::{Array1, Array2, array};
struct Lcg(u64);
impl Lcg {
fn next_uniform(&mut self) -> f64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
}
fn next_normal(&mut self) -> f64 {
let u1 = self.next_uniform().max(1e-12);
let u2 = self.next_uniform();
(-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
}
}
struct GaussianHarness {
design: Array2<f64>,
enrichment: Array2<f64>,
weights: Array1<f64>,
score: Array1<f64>,
design_gram: DesignGramFactor,
}
impl GaussianHarness {
fn new(design: Array2<f64>, enrichment: Array2<f64>, y: Array1<f64>, ridge: f64) -> Self {
let n = design.nrows();
let p = design.ncols();
let gram = design.t().dot(&design);
let mut hessian = gram.clone();
for index in 0..p {
hessian[(index, index)] += ridge;
}
let beta = invert_symmetric(&hessian).dot(&design.t().dot(&y));
let score = &y - &design.dot(&beta);
Self {
design,
enrichment,
weights: Array1::ones(n),
score,
design_gram: DesignGramFactor::new(gram.view())
.expect("test harness Gram is factorable"),
}
}
fn input(&self) -> BasisAdequacyInput<'_> {
BasisAdequacyInput {
enrichment: self.enrichment.view(),
design: self.design.view(),
hessian_weights: self.weights.view(),
score_weights: self.weights.view(),
score: self.score.view(),
design_gram: &self.design_gram,
dispersion: 1.0,
residual_df: None,
scale: SmoothTestScale::Known,
}
}
}
fn invert_symmetric(matrix: &Array2<f64>) -> Array2<f64> {
let (values, vectors) = strict_symmetric_eigh(matrix, Side::Lower)
.expect("test harness matrix is symmetric positive definite");
let mut inverse = Array2::<f64>::zeros(matrix.raw_dim());
for (index, &value) in values.iter().enumerate() {
let column = vectors.column(index);
let scale = 1.0 / value;
for row in 0..matrix.nrows() {
for col in 0..matrix.ncols() {
inverse[(row, col)] += scale * column[row] * column[col];
}
}
}
inverse
}
#[test]
fn enrichment_inside_the_fitted_span_has_no_estimable_direction() {
let design = array![
[1.0, 0.0],
[1.0, 1.0],
[1.0, 2.0],
[1.0, 3.0],
[1.0, 4.0],
[1.0, 5.0]
];
let enrichment = design.dot(&array![[2.0, -1.0], [0.5, 3.0]]);
let y = array![0.3, -0.2, 0.7, 0.1, -0.5, 0.4];
let harness = GaussianHarness::new(design, enrichment, y, 0.0);
assert_eq!(basis_adequacy_score_test(harness.input()), None);
}
#[test]
fn rank_counts_only_directions_outside_the_fitted_span() {
let mut rng = Lcg(20_260_823);
let n = 200;
let mut design = Array2::<f64>::zeros((n, 2));
let mut enrichment = Array2::<f64>::zeros((n, 3));
let mut y = Array1::<f64>::zeros(n);
for row in 0..n {
let x = row as f64 / n as f64;
design[(row, 0)] = 1.0;
design[(row, 1)] = x;
enrichment[(row, 0)] = x; enrichment[(row, 1)] = x * x;
enrichment[(row, 2)] = x * x * x;
y[row] = 0.5 + 2.0 * x + 0.1 * rng.next_normal();
}
let harness = GaussianHarness::new(design, enrichment, y, 0.0);
let out = basis_adequacy_score_test(harness.input())
.expect("two enrichment directions remain estimable");
assert_eq!(out.rank, 2);
}
#[test]
fn null_statistic_has_mean_near_its_reference_df() {
let n = 400;
let replicates = 200;
let mut rng = Lcg(1_234_567);
let mut total = 0.0;
let mut rank_seen = 0usize;
let mut rejections = 0usize;
for _ in 0..replicates {
let mut design = Array2::<f64>::zeros((n, 2));
let mut enrichment = Array2::<f64>::zeros((n, 3));
let mut y = Array1::<f64>::zeros(n);
for row in 0..n {
let x = (row as f64 + 0.5) / n as f64;
design[(row, 0)] = 1.0;
design[(row, 1)] = x;
enrichment[(row, 0)] = x * x;
enrichment[(row, 1)] = x * x * x;
enrichment[(row, 2)] = (6.0 * x).sin();
y[row] = 0.5 + 2.0 * x + rng.next_normal();
}
let harness = GaussianHarness::new(design, enrichment, y, 0.0);
let out = basis_adequacy_score_test(harness.input()).expect("estimable enrichment");
total += out.statistic;
rank_seen = out.rank;
if out.p_value < 0.05 {
rejections += 1;
}
}
let mean = total / replicates as f64;
let expected = rank_seen as f64;
assert!(
(mean - expected).abs() < 0.7,
"null mean statistic {mean} should sit near rank {expected}"
);
let size = rejections as f64 / replicates as f64;
assert!(size < 0.12, "null rejection rate {size} is inflated");
}
#[test]
fn missing_curvature_is_detected() {
let n = 400;
let mut rng = Lcg(7_654_321);
let mut design = Array2::<f64>::zeros((n, 2));
let mut enrichment = Array2::<f64>::zeros((n, 3));
let mut y = Array1::<f64>::zeros(n);
for row in 0..n {
let x = (row as f64 + 0.5) / n as f64;
design[(row, 0)] = 1.0;
design[(row, 1)] = x;
enrichment[(row, 0)] = x * x;
enrichment[(row, 1)] = x * x * x;
enrichment[(row, 2)] = (6.0 * x).sin();
y[row] = 0.5 + 2.0 * x + 3.0 * x * x + rng.next_normal();
}
let harness = GaussianHarness::new(design, enrichment, y, 0.0);
let out = basis_adequacy_score_test(harness.input()).expect("estimable enrichment");
assert!(
out.p_value < 1e-3,
"quadratic lack of fit should be detected, got p={}",
out.p_value
);
}
#[test]
fn statistic_is_invariant_to_shifting_the_enrichment_by_design_columns() {
let n = 300;
let mut rng = Lcg(4_242);
let mut design = Array2::<f64>::zeros((n, 3));
let mut enrichment = Array2::<f64>::zeros((n, 2));
let mut y = Array1::<f64>::zeros(n);
for row in 0..n {
let x = (row as f64 + 0.5) / n as f64;
design[(row, 0)] = 1.0;
design[(row, 1)] = x;
design[(row, 2)] = (3.0 * x).cos();
enrichment[(row, 0)] = x * x;
enrichment[(row, 1)] = (5.0 * x).sin();
y[row] = 1.0 + x + 0.8 * x * x + 0.4 * rng.next_normal();
}
let base = GaussianHarness::new(design.clone(), enrichment.clone(), y.clone(), 40.0);
let shift = array![[7.0, -2.0], [0.5, 3.0], [-1.5, 4.0]];
let shifted_enrichment = &enrichment + &design.dot(&shift);
let shifted = GaussianHarness::new(design, shifted_enrichment, y, 40.0);
let a = basis_adequacy_score_test(base.input()).expect("base result");
let b = basis_adequacy_score_test(shifted.input()).expect("shifted result");
assert_eq!(a.rank, b.rank);
assert!(
(a.statistic - b.statistic).abs() <= 1e-8 * a.statistic.max(1.0),
"statistic must not move under Z -> Z + X·A; got {} vs {}",
a.statistic,
b.statistic
);
}
#[test]
fn statistic_does_not_track_the_penalty_strength_under_the_null() {
let n = 500;
let mut rng = Lcg(31_337);
let mut design = Array2::<f64>::zeros((n, 3));
let mut enrichment = Array2::<f64>::zeros((n, 4));
let mut y = Array1::<f64>::zeros(n);
for row in 0..n {
let x = (row as f64 + 0.5) / n as f64;
design[(row, 0)] = 1.0;
design[(row, 1)] = x;
design[(row, 2)] = x * x;
enrichment[(row, 0)] = x * x * x;
enrichment[(row, 1)] = (7.0 * x).sin();
enrichment[(row, 2)] = (7.0 * x).cos();
enrichment[(row, 3)] = (11.0 * x).sin();
y[row] = 1.0 + 3.0 * x - 2.0 * x * x + rng.next_normal();
}
let mut statistics = Vec::new();
for ridge in [0.0, 1.0, 1.0e2, 1.0e4] {
let harness =
GaussianHarness::new(design.clone(), enrichment.clone(), y.clone(), ridge);
let out = basis_adequacy_score_test(harness.input()).expect("estimable enrichment");
statistics.push(out.statistic);
}
let span = statistics
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max)
- statistics.iter().cloned().fold(f64::INFINITY, f64::min);
assert!(
span < 4.0,
"the ridge must not drive the null statistic; got {statistics:?}"
);
}
#[test]
fn degenerate_inputs_refuse() {
let design = array![[1.0, 0.0], [1.0, 1.0], [1.0, 2.0]];
let enrichment = array![[0.0], [1.0], [4.0]];
let y = array![0.1, 0.2, 0.3];
let harness = GaussianHarness::new(design, enrichment, y, 1.0);
let mut bad_dispersion = harness.input();
bad_dispersion.dispersion = 0.0;
assert_eq!(basis_adequacy_score_test(bad_dispersion), None);
let mismatched = Array2::<f64>::zeros((2, 1));
let mut bad_rows = harness.input();
bad_rows.enrichment = mismatched.view();
assert_eq!(basis_adequacy_score_test(bad_rows), None);
let mut estimated_without_df = harness.input();
estimated_without_df.scale = SmoothTestScale::Estimated;
estimated_without_df.residual_df = None;
assert_eq!(basis_adequacy_score_test(estimated_without_df), None);
}
}