1use gam_problem::types::{GlmLikelihoodSpec, LikelihoodSpec};
30use gam_solve::estimate::{EstimationError, UnifiedFitResult};
31use gam_solve::model_types::SmoothingCorrectionMethod;
32use gam_solve::psis::pareto_smooth_weights;
33use ndarray::{Array1, ArrayView1, ArrayView2};
34
35#[derive(Debug, Clone)]
37pub struct AloElpd {
38 pub elpd: f64,
40 pub se: Option<f64>,
42 pub pointwise: Array1<f64>,
44 pub k_hat_max: Option<f64>,
48 pub n_k_bad: usize,
51}
52
53#[derive(Debug, Clone, Copy)]
57pub struct CorrectedEdf {
58 pub conditional: f64,
60 pub corrected: Option<f64>,
62 pub unavailable_reason: Option<CorrectedEdfUnavailable>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum CorrectedEdfUnavailable {
69 MissingWeightedGram,
70 MissingSmoothingCorrection,
71 MissingCovarianceScale,
72 MissingMethodProvenance,
73}
74
75impl CorrectedEdf {
76 pub fn rho_uncertainty_df(&self) -> Option<f64> {
79 self.corrected.map(|value| value - self.conditional)
80 }
81}
82
83#[derive(Debug, Clone)]
85pub struct ModelComparison {
86 pub log_lik: f64,
88 pub edf: CorrectedEdf,
90 pub aic_conditional: f64,
92 pub aic_corrected: Option<f64>,
94 pub loo: Option<AloElpd>,
97}
98
99pub fn corrected_edf(
115 edf_conditional: f64,
116 weighted_gram: Option<ArrayView2<'_, f64>>,
117 smoothing_correction: Option<ArrayView2<'_, f64>>,
118 covariance_scale: Option<f64>,
119 smoothing_dimension: usize,
120 method_certified_exact: bool,
121) -> Result<CorrectedEdf, EstimationError> {
122 if !edf_conditional.is_finite() || edf_conditional < 0.0 {
123 return Err(EstimationError::InvalidInput(format!(
124 "conditional EDF must be finite and non-negative; got {edf_conditional}"
125 )));
126 }
127 if smoothing_dimension == 0 {
128 return Ok(CorrectedEdf {
129 conditional: edf_conditional,
130 corrected: Some(edf_conditional),
131 unavailable_reason: None,
132 });
133 }
134 if !method_certified_exact {
135 return Ok(CorrectedEdf {
136 conditional: edf_conditional,
137 corrected: None,
138 unavailable_reason: Some(CorrectedEdfUnavailable::MissingMethodProvenance),
139 });
140 }
141 let Some(xwx) = weighted_gram else {
142 return Ok(CorrectedEdf {
143 conditional: edf_conditional,
144 corrected: None,
145 unavailable_reason: Some(CorrectedEdfUnavailable::MissingWeightedGram),
146 });
147 };
148 let Some(correction) = smoothing_correction else {
149 return Ok(CorrectedEdf {
150 conditional: edf_conditional,
151 corrected: None,
152 unavailable_reason: Some(CorrectedEdfUnavailable::MissingSmoothingCorrection),
153 });
154 };
155 let Some(scale) = covariance_scale else {
156 return Ok(CorrectedEdf {
157 conditional: edf_conditional,
158 corrected: None,
159 unavailable_reason: Some(CorrectedEdfUnavailable::MissingCovarianceScale),
160 });
161 };
162 let extra = wps_correction_term(xwx, correction, scale)?;
163 let corrected = edf_conditional + extra;
164 if !corrected.is_finite() {
165 return Err(EstimationError::InvalidInput(
166 "corrected EDF is outside f64 range".into(),
167 ));
168 }
169 Ok(CorrectedEdf {
170 conditional: edf_conditional,
171 corrected: Some(corrected),
172 unavailable_reason: None,
173 })
174}
175
176fn wps_correction_term(
179 xwx: ArrayView2<'_, f64>,
180 corr: ArrayView2<'_, f64>,
181 covariance_scale: f64,
182) -> Result<f64, EstimationError> {
183 let k = xwx.nrows();
184 if k == 0 || xwx.ncols() != k || corr.nrows() != k || corr.ncols() != k {
185 return Err(EstimationError::InvalidInput(format!(
186 "WPS correction dimension mismatch: XWX={:?}, correction={:?}",
187 xwx.dim(),
188 corr.dim()
189 )));
190 }
191 if !(covariance_scale.is_finite() && covariance_scale > 0.0) {
192 return Err(EstimationError::InvalidInput(format!(
193 "WPS coefficient covariance scale must be finite and positive; got {covariance_scale}"
194 )));
195 }
196 let max_x = xwx.iter().copied().map(f64::abs).fold(0.0, f64::max);
197 let max_c = corr.iter().copied().map(f64::abs).fold(0.0, f64::max);
198 if !max_x.is_finite() || !max_c.is_finite() {
199 return Err(EstimationError::InvalidInput(
200 "WPS inputs contain a non-finite matrix entry".into(),
201 ));
202 }
203 if max_x == 0.0 || max_c == 0.0 {
204 return Ok(0.0);
205 }
206 let mut normalized_terms = Vec::with_capacity(k * k);
207 for i in 0..k {
208 for j in 0..k {
209 normalized_terms.push((xwx[[i, j]] / max_x) * (corr[[j, i]] / max_c));
210 }
211 }
212 let mut normalized =
213 gam_solve::pirls::stable_finite_signed_sum(&normalized_terms, "WPS normalized trace")?;
214 let absolute_sum: f64 = normalized_terms.iter().map(|value| value.abs()).sum();
215 let roundoff = gam_linalg::roundoff::compensated_band(3, absolute_sum);
227 if normalized < 0.0 {
228 if normalized >= -roundoff {
229 normalized = 0.0;
230 } else {
231 return Err(EstimationError::InvalidInput(format!(
232 "WPS PSD trace is negative beyond roundoff: normalized={normalized}, bound={roundoff}"
233 )));
234 }
235 }
236 if normalized == 0.0 {
237 return Ok(0.0);
238 }
239 let log_value = normalized.ln() + max_x.ln() + max_c.ln() - covariance_scale.ln();
240 let value = log_value.exp();
241 if value.is_finite() {
242 Ok(value)
243 } else {
244 Err(EstimationError::InvalidInput(
245 "WPS correction is outside f64 range".into(),
246 ))
247 }
248}
249
250pub fn alo_elpd(
269 loglik_fitted: ArrayView1<'_, f64>,
270 loglik_loo: ArrayView1<'_, f64>,
271) -> Result<AloElpd, EstimationError> {
272 let reduction_values: Vec<f64> = loglik_loo.iter().copied().collect();
273 let elpd = gam_solve::pirls::stable_finite_signed_sum(&reduction_values, "ALO elpd reduction")?;
274 alo_elpd_with_total(loglik_fitted, loglik_loo, elpd)
275}
276
277fn alo_elpd_with_total(
278 loglik_fitted: ArrayView1<'_, f64>,
279 loglik_loo: ArrayView1<'_, f64>,
280 elpd: f64,
281) -> Result<AloElpd, EstimationError> {
282 let n = loglik_loo.len();
283 if n == 0 {
284 return Err(EstimationError::InvalidInput(
285 "ALO requires at least one observation".into(),
286 ));
287 }
288 if loglik_fitted.len() != n {
289 return Err(EstimationError::InvalidInput(format!(
290 "ALO likelihood length mismatch: fitted={}, loo={n}",
291 loglik_fitted.len()
292 )));
293 }
294 if !elpd.is_finite() {
295 return Err(EstimationError::InvalidInput(format!(
296 "ALO elpd total is non-finite: {elpd}"
297 )));
298 }
299 let mut log_ratio = Array1::zeros(n);
300 for row in 0..n {
301 let fitted = loglik_fitted[row];
302 let loo = loglik_loo[row];
303 if !fitted.is_finite() || !loo.is_finite() {
304 return Err(EstimationError::InvalidInput(format!(
305 "ALO non-finite log-likelihood at row {row}: fitted={fitted}, loo={loo}"
306 )));
307 }
308 let ratio = fitted - loo;
309 if !ratio.is_finite() {
310 return Err(EstimationError::InvalidInput(format!(
311 "ALO log influence ratio is outside f64 range at row {row}: fitted={fitted}, loo={loo}"
312 )));
313 }
314 log_ratio[row] = ratio;
315 }
316 let max_lr = log_ratio.iter().copied().fold(f64::NEG_INFINITY, f64::max);
320 let raw: Vec<f64> = log_ratio.iter().map(|&lr| (lr - max_lr).exp()).collect();
321
322 let (k_hat_max, n_k_bad);
323 match pareto_smooth_weights(&raw) {
324 Some(psis) => {
325 k_hat_max = Some(psis.k_hat);
326 n_k_bad = if psis.k_hat > 0.7 { psis.tail_count } else { 0 };
327 }
328 None => {
329 k_hat_max = None;
330 n_k_bad = 0;
331 }
332 }
333
334 let pointwise = loglik_loo.to_owned();
335 let mean = elpd / n as f64;
336 let se = if n > 1 {
339 let max_deviation = pointwise
340 .iter()
341 .map(|&value| (value - mean).abs())
342 .fold(0.0_f64, f64::max);
343 if max_deviation == 0.0 {
344 Some(0.0)
345 } else {
346 let scaled_sum_squares: f64 = pointwise
347 .iter()
348 .map(|&value| {
349 let scaled = (value - mean) / max_deviation;
350 scaled * scaled
351 })
352 .sum();
353 let multiplier = (n as f64 * scaled_sum_squares / (n - 1) as f64).sqrt();
354 let value = max_deviation * multiplier;
355 if !value.is_finite() {
356 return Err(EstimationError::InvalidInput(
357 "ALO standard error is outside f64 range".into(),
358 ));
359 }
360 Some(value)
361 }
362 } else {
363 None
364 };
365 Ok(AloElpd {
366 elpd,
367 se,
368 pointwise,
369 k_hat_max,
370 n_k_bad,
371 })
372}
373
374#[derive(Debug, Clone)]
379pub struct ComparisonReport {
380 pub delta_elpd: Option<f64>,
382 pub delta_elpd_se: Option<f64>,
385 pub delta_aic_corrected: Option<f64>,
387 pub rows_aligned: bool,
391}
392
393pub fn compare(
398 a: &ModelComparison,
399 b: &ModelComparison,
400) -> Result<ComparisonReport, EstimationError> {
401 let delta_aic_corrected = match (a.aic_corrected, b.aic_corrected) {
402 (Some(left), Some(right)) => {
403 let difference = left - right;
404 if !difference.is_finite() {
405 return Err(EstimationError::InvalidInput(
406 "corrected-AIC difference is outside f64 range".into(),
407 ));
408 }
409 Some(difference)
410 }
411 _ => None,
412 };
413 match (&a.loo, &b.loo) {
414 (Some(la), Some(lb))
415 if la.pointwise.len() == lb.pointwise.len() && !la.pointwise.is_empty() =>
416 {
417 let n = la.pointwise.len();
418 let mut diff = Array1::zeros(n);
419 for row in 0..n {
420 let value = la.pointwise[row] - lb.pointwise[row];
421 if !value.is_finite() {
422 return Err(EstimationError::InvalidInput(format!(
423 "paired elpd difference is outside f64 range at row {row}"
424 )));
425 }
426 diff[row] = value;
427 }
428 let values: Vec<f64> = diff.iter().copied().collect();
429 let delta_elpd =
430 gam_solve::pirls::stable_finite_signed_sum(&values, "paired elpd reduction")?;
431 let mean = delta_elpd / n as f64;
432 let se = if n > 1 {
435 let max_deviation = diff
436 .iter()
437 .map(|&value| (value - mean).abs())
438 .fold(0.0_f64, f64::max);
439 if max_deviation == 0.0 {
440 Some(0.0)
441 } else {
442 let scaled_sum_squares: f64 = diff
443 .iter()
444 .map(|&value| {
445 let scaled = (value - mean) / max_deviation;
446 scaled * scaled
447 })
448 .sum();
449 let multiplier = (n as f64 * scaled_sum_squares / (n - 1) as f64).sqrt();
450 let value = max_deviation * multiplier;
451 if !value.is_finite() {
452 return Err(EstimationError::InvalidInput(
453 "paired elpd standard error is outside f64 range".into(),
454 ));
455 }
456 Some(value)
457 }
458 } else {
459 None
460 };
461 Ok(ComparisonReport {
462 delta_elpd: Some(delta_elpd),
463 delta_elpd_se: se,
464 delta_aic_corrected,
465 rows_aligned: true,
466 })
467 }
468 _ => Ok(ComparisonReport {
469 delta_elpd: None,
470 delta_elpd_se: None,
471 delta_aic_corrected,
472 rows_aligned: false,
473 }),
474 }
475}
476
477pub fn model_comparison_from_unified(
490 fit: &UnifiedFitResult,
491 y: ArrayView1<'_, f64>,
492 eta_hat: ArrayView1<'_, f64>,
493 prior_weights: ArrayView1<'_, f64>,
494 alo_eta_tilde: Option<ArrayView1<'_, f64>>,
495) -> Result<ModelComparison, EstimationError> {
496 let phi = fit.dispersion_phi()?;
497 let edf_conditional = fit.edf_total().ok_or_else(|| {
498 EstimationError::InvalidInput("model comparison requires a retained conditional EDF".into())
499 })?;
500 let covariance_scale = fit
501 .likelihood_family
502 .as_ref()
503 .map(|spec| {
504 GlmLikelihoodSpec {
505 spec: spec.clone(),
506 scale: fit.likelihood_scale,
507 }
508 .coefficient_covariance_scale(phi)
509 .map_err(|error| {
510 EstimationError::InvalidInput(format!(
511 "model-comparison coefficient covariance scale: {error}"
512 ))
513 })
514 })
515 .transpose()?;
516 let method_certified_exact = matches!(
537 fit.smoothing_correction_method_first_order(),
538 Some(SmoothingCorrectionMethod::FirstOrderIdentifiedSubspace { .. })
539 );
540 let edf = corrected_edf(
541 edf_conditional,
542 fit.weighted_gram().map(|g| g.view()),
543 fit.smoothing_correction_first_order().map(|c| c.view()),
544 covariance_scale,
545 fit.log_lambdas.len(),
546 method_certified_exact,
547 )?;
548
549 let log_lik = if let Some(spec) = fit.likelihood_family.as_ref() {
558 let scale = reporting_scale(spec, &fit.likelihood_scale, phi);
559 full_loglikelihood_at_eta(y, eta_hat, prior_weights, spec, scale)?
560 } else {
561 fit.log_likelihood
565 };
566
567 let scale_dof = fit
572 .likelihood_family
573 .as_ref()
574 .map(|spec| scale_parameter_count(spec, &fit.likelihood_scale))
575 .unwrap_or(0.0);
576
577 let aic_conditional = -2.0 * log_lik + 2.0 * (edf.conditional + scale_dof);
578 let aic_corrected = edf
579 .corrected
580 .map(|corrected| -2.0 * log_lik + 2.0 * (corrected + scale_dof));
581
582 let loo = match (alo_eta_tilde, fit.likelihood_family.as_ref()) {
583 (Some(eta_tilde), Some(spec)) => {
584 let scale = reporting_scale(spec, &fit.likelihood_scale, phi);
585 Some(alo_elpd_from_family(
586 y,
587 eta_hat,
588 eta_tilde,
589 prior_weights,
590 spec,
591 scale,
592 )?)
593 }
594 _ => None,
595 };
596
597 Ok(ModelComparison {
598 log_lik,
599 edf,
600 aic_conditional,
601 aic_corrected,
602 loo,
603 })
604}
605
606pub fn alo_elpd_from_family(
611 y: ArrayView1<'_, f64>,
612 eta_hat: ArrayView1<'_, f64>,
613 eta_loo: ArrayView1<'_, f64>,
614 prior_weights: ArrayView1<'_, f64>,
615 spec: &LikelihoodSpec,
616 scale: gam_problem::types::LikelihoodScaleMetadata,
617) -> Result<AloElpd, EstimationError> {
618 use gam_solve::pirls::evaluate_full_log_likelihood_from_eta;
619
620 let glm = GlmLikelihoodSpec {
621 spec: spec.clone(),
622 scale,
623 };
624 let ll_hat = evaluate_full_log_likelihood_from_eta(y, eta_hat, &glm, prior_weights)?;
632 let ll_loo = evaluate_full_log_likelihood_from_eta(y, eta_loo, &glm, prior_weights)?;
633 alo_elpd_with_total(ll_hat.pointwise(), ll_loo.pointwise(), ll_loo.total())
634}
635
636fn full_loglikelihood_at_eta(
639 y: ArrayView1<'_, f64>,
640 eta_hat: ArrayView1<'_, f64>,
641 prior_weights: ArrayView1<'_, f64>,
642 spec: &LikelihoodSpec,
643 scale: gam_problem::types::LikelihoodScaleMetadata,
644) -> Result<f64, EstimationError> {
645 use gam_solve::pirls::evaluate_full_log_likelihood_from_eta;
646
647 let glm = GlmLikelihoodSpec {
648 spec: spec.clone(),
649 scale,
650 };
651 evaluate_full_log_likelihood_from_eta(y, eta_hat, &glm, prior_weights)
652 .map(|evaluation| evaluation.total())
653}
654
655fn reporting_scale(
666 spec: &LikelihoodSpec,
667 scale: &gam_problem::types::LikelihoodScaleMetadata,
668 phi: f64,
669) -> gam_problem::types::LikelihoodScaleMetadata {
670 use gam_problem::types::{LikelihoodScaleMetadata, ResponseFamily};
671 match spec.response {
672 ResponseFamily::Gaussian => match *scale {
673 fixed @ LikelihoodScaleMetadata::FixedDispersion { .. } => fixed,
674 LikelihoodScaleMetadata::ProfiledGaussian if phi.is_finite() && phi > 0.0 => {
675 LikelihoodScaleMetadata::FixedDispersion { phi }
676 }
677 other => other,
678 },
679 _ => scale.clone(),
680 }
681}
682
683fn scale_parameter_count(
690 spec: &LikelihoodSpec,
691 scale: &gam_problem::types::LikelihoodScaleMetadata,
692) -> f64 {
693 use gam_problem::types::{LikelihoodScaleMetadata, ResponseFamily};
694 let estimated = match spec.response {
695 ResponseFamily::Gaussian => {
696 !matches!(scale, LikelihoodScaleMetadata::FixedDispersion { .. })
697 }
698 ResponseFamily::Gamma => {
699 matches!(scale, LikelihoodScaleMetadata::EstimatedGammaShape { .. })
700 }
701 ResponseFamily::Beta { .. } => {
702 matches!(scale, LikelihoodScaleMetadata::EstimatedBetaPhi { .. })
703 }
704 ResponseFamily::Tweedie { .. } => {
705 matches!(scale, LikelihoodScaleMetadata::EstimatedTweediePhi { .. })
706 }
707 ResponseFamily::NegativeBinomial { .. } => {
708 matches!(scale, LikelihoodScaleMetadata::EstimatedNegBinTheta { .. })
709 }
710 ResponseFamily::Poisson | ResponseFamily::Binomial | ResponseFamily::RoystonParmar => false,
711 };
712 if estimated { 1.0 } else { 0.0 }
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718 use ndarray::{Array2, array};
719
720 #[test]
721 fn wps_correction_is_trace_of_h_f_sigma_over_phi() {
722 let xwx = Array2::<f64>::eye(3);
724 let corr = array![[2.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 6.0]];
725 let edf = corrected_edf(3.0, Some(xwx.view()), Some(corr.view()), Some(2.0), 1, true)
726 .expect("corrected EDF");
727 assert_eq!(edf.corrected, Some(9.0));
729 assert_eq!(edf.rho_uncertainty_df(), Some(6.0));
730 assert!((edf.conditional - 3.0).abs() < 1e-12);
731 }
732
733 #[test]
734 fn corrected_edf_reports_unavailable_without_inputs() {
735 let edf = corrected_edf(5.5, None, None, Some(1.0), 1, true).expect("availability result");
736 assert_eq!(edf.conditional, 5.5);
737 assert_eq!(edf.corrected, None);
738 assert_eq!(edf.rho_uncertainty_df(), None);
739 assert_eq!(
740 edf.unavailable_reason,
741 Some(CorrectedEdfUnavailable::MissingWeightedGram)
742 );
743 }
744
745 #[test]
746 fn alo_elpd_sums_pointwise_and_flags_no_tail() {
747 let ll: Array1<f64> = array![-1.0, -2.0, -0.5, -1.5, -0.8, -1.2, -0.9, -1.1, -0.7, -1.3];
750 let loo = alo_elpd(ll.view(), ll.view()).expect("alo elpd");
751 let expected: f64 = ll.iter().sum();
752 assert!((loo.elpd - expected).abs() < 1e-9);
753 assert_eq!(loo.pointwise.len(), ll.len());
754 assert_eq!(loo.n_k_bad, 0);
756 }
757
758 #[test]
759 fn alo_elpd_pointwise_is_local_to_alo_loglikelihoods() {
760 let ll_loo: Array1<f64> = array![
761 -1.0, -1.1, -1.2, -1.3, -1.4, -1.5, -1.6, -1.7, -1.8, -1.9, -2.0, -2.1
762 ];
763 let ll_hat = ll_loo.clone();
764 let mut ll_hat_perturbed = ll_loo.clone();
765 ll_hat_perturbed[7] += 10.0;
766
767 let base = alo_elpd(ll_hat.view(), ll_loo.view()).expect("alo elpd");
768 let perturbed = alo_elpd(ll_hat_perturbed.view(), ll_loo.view()).expect("alo elpd");
769
770 for i in 0..ll_loo.len() {
771 assert_eq!(base.pointwise[i], ll_loo[i]);
772 assert_eq!(perturbed.pointwise[i], ll_loo[i]);
773 if i != 7 {
774 assert_eq!(base.pointwise[i], perturbed.pointwise[i]);
775 }
776 }
777 assert_eq!(perturbed.elpd, base.elpd);
778 }
779
780 fn gpd_sample(u: f64, k: f64, sigma: f64) -> f64 {
781 sigma * ((1.0 - u).powf(-k) - 1.0) / k
782 }
783
784 #[test]
785 fn alo_elpd_influence_diagnostic_fires_on_heavy_tailed_ratios() {
786 let mut ratios = vec![1.0; 200];
787 for i in 1..=120 {
788 let u = (i as f64 - 0.5) / 120.0;
789 ratios.push(1.0 + gpd_sample(u, 1.2, 0.5));
790 }
791 let ll_loo: Array1<f64> = Array1::from_elem(ratios.len(), -1.0);
792 let ll_hat: Array1<f64> = Array1::from_iter(
793 ll_loo
794 .iter()
795 .zip(ratios.iter())
796 .map(|(&ll, &ratio)| ll + ratio.ln()),
797 );
798
799 let loo = alo_elpd(ll_hat.view(), ll_loo.view()).expect("alo elpd");
800
801 assert_eq!(loo.pointwise, ll_loo);
802 assert!((loo.elpd - -(ratios.len() as f64)).abs() < 1e-12);
803 assert!(
804 loo.k_hat_max.is_some_and(|value| value > 0.7),
805 "heavy fitted-vs-ALO ratio tail should fire influence diagnostic; got k_hat={:?}",
806 loo.k_hat_max
807 );
808 assert!(
809 loo.n_k_bad > 0,
810 "heavy fitted-vs-ALO ratio tail should count influential tail observations"
811 );
812 }
813
814 #[test]
815 fn compare_pairs_pointwise_and_orients_a_minus_b() {
816 let mk = |pw: Array1<f64>, aic: f64| ModelComparison {
817 log_lik: 0.0,
818 edf: CorrectedEdf {
819 conditional: 0.0,
820 corrected: Some(0.0),
821 unavailable_reason: None,
822 },
823 aic_conditional: aic,
824 aic_corrected: Some(aic),
825 loo: Some(AloElpd {
826 elpd: pw.iter().sum(),
827 se: Some(0.0),
828 pointwise: pw,
829 k_hat_max: Some(0.1),
830 n_k_bad: 0,
831 }),
832 };
833 let a = mk(array![-1.0, -1.0, -1.0, -1.0], 10.0);
834 let b = mk(array![-2.0, -2.0, -2.0, -2.0], 14.0);
835 let rep = compare(&a, &b).expect("comparison");
836 assert!(rep.rows_aligned);
837 assert_eq!(rep.delta_elpd, Some(4.0));
839 assert_eq!(rep.delta_aic_corrected, Some(-4.0));
840 assert_eq!(rep.delta_elpd_se, Some(0.0));
841 }
842
843 #[test]
844 fn alo_elpd_se_uses_unbiased_sample_variance() {
845 let ll: Array1<f64> = array![0.0, 2.0];
849 let loo = alo_elpd(ll.view(), ll.view()).expect("alo elpd");
850 assert_eq!(loo.se, Some(2.0));
851 }
852
853 #[test]
854 fn compare_se_uses_unbiased_sample_variance_of_paired_differences() {
855 let mk = |pw: Array1<f64>| ModelComparison {
856 log_lik: 0.0,
857 edf: CorrectedEdf {
858 conditional: 0.0,
859 corrected: Some(0.0),
860 unavailable_reason: None,
861 },
862 aic_conditional: 0.0,
863 aic_corrected: Some(0.0),
864 loo: Some(AloElpd {
865 elpd: pw.iter().sum(),
866 se: Some(0.0),
867 pointwise: pw,
868 k_hat_max: Some(0.1),
869 n_k_bad: 0,
870 }),
871 };
872 let a = mk(array![0.0, 2.0]);
874 let b = mk(array![0.0, 0.0]);
875 let rep = compare(&a, &b).expect("comparison");
876 assert!(rep.rows_aligned);
877 assert!(
878 rep.delta_elpd_se == Some(2.0),
879 "se = {:?}",
880 rep.delta_elpd_se
881 );
882 }
883
884 #[test]
885 fn compare_refuses_unpaired_rows() {
886 let mk = |pw: Array1<f64>| ModelComparison {
887 log_lik: 0.0,
888 edf: CorrectedEdf {
889 conditional: 0.0,
890 corrected: Some(0.0),
891 unavailable_reason: None,
892 },
893 aic_conditional: 0.0,
894 aic_corrected: Some(5.0),
895 loo: Some(AloElpd {
896 elpd: pw.iter().sum(),
897 se: Some(0.0),
898 pointwise: pw,
899 k_hat_max: Some(0.1),
900 n_k_bad: 0,
901 }),
902 };
903 let a = mk(array![-1.0, -1.0, -1.0]);
904 let b = mk(array![-1.0, -1.0]);
905 let rep = compare(&a, &b).expect("comparison");
906 assert!(!rep.rows_aligned);
907 assert_eq!(rep.delta_elpd, None);
908 assert_eq!(rep.delta_aic_corrected, Some(0.0));
910 }
911
912 #[test]
923 fn corrected_edf_uses_retained_first_order_pair_when_primary_method_is_cubature() {
924 use gam_solve::model_types::{
925 Dispersion, FitArtifacts, FitInference, FittedBlock, FittedLinkState,
926 UnifiedFitResultParts,
927 };
928 use gam_solve::pirls::PirlsStatus;
929 use gam_problem::{LikelihoodScaleMetadata, LogLikelihoodNormalization};
930
931 let cubature_correction = array![[9.0, 0.0], [0.0, 9.0]];
934 let first_order_correction = array![[0.4, 0.0], [0.0, 0.4]];
935 let weighted_gram = array![[1.0, 0.0], [0.0, 1.0]];
936
937 let parts = UnifiedFitResultParts {
938 blocks: vec![FittedBlock {
939 beta: array![0.25, -0.5],
940 role: gam_problem::BlockRole::Mean,
941 edf: 1.5,
942 lambdas: array![2.0],
943 }],
944 log_lambdas: array![2.0_f64.ln()],
945 lambdas: array![2.0],
946 likelihood_family: Some(LikelihoodSpec::gaussian_identity()),
947 likelihood_scale: LikelihoodScaleMetadata::ProfiledGaussian,
948 log_likelihood_normalization: LogLikelihoodNormalization::Full,
949 log_likelihood: -1.2,
950 deviance: 2.4,
951 reml_score: 0.7,
952 stable_penalty_term: 0.3,
953 penalized_objective: 2.2,
954 used_device: false,
955 outer_iterations: 0,
962 outer_converged: true,
963 outer_gradient_norm: None,
964 standard_deviation: 1.0,
968 covariance_conditional: Some(array![[1.0, 0.1], [0.1, 2.0]]),
969 covariance_corrected: None,
970 inference: Some(FitInference {
971 edf_by_block: vec![1.5],
977 penalty_block_trace: vec![],
978 edf_total: 1.5,
979 smoothing_correction: Some(cubature_correction.clone()),
981 smoothing_correction_method: Some(SmoothingCorrectionMethod::SigmaPointCubature {
982 rank: 1,
983 n_points: 2,
984 rho_hessian_stabilization: gam_problem::StabilizationLedger::approximation_only(
985 1.0e-8,
986 gam_problem::StabilizationRule::FixedConstant,
987 )
988 .expect("valid test cubature ridge"),
989 }),
990 smoothing_correction_first_order: Some(first_order_correction.clone()),
993 smoothing_correction_method_first_order: Some(
994 SmoothingCorrectionMethod::FirstOrderIdentifiedSubspace {
995 active_rank: 1,
996 rho_dimension: 1,
997 },
998 ),
999 penalized_hessian: array![[2.0, 0.1], [0.1, 3.0]].into(),
1000 reparam_qs: Some(array![[1.0, 0.0], [0.0, 1.0]]),
1001 dispersion: Dispersion::estimated(1.0).expect("valid test dispersion"),
1002 beta_covariance: Some(array![[1.0, 0.1], [0.1, 2.0]].into()),
1003 beta_standard_errors: Some(array![1.0, 2.0_f64.sqrt()]),
1004 beta_covariance_corrected: None,
1005 beta_standard_errors_corrected: None,
1006 beta_covariance_frequentist: None,
1007 coefficient_influence: None,
1008 weighted_gram: Some(weighted_gram),
1009 bias_correction_beta: None,
1010 bias_correction_jacobian: None,
1011 }),
1012 fitted_link: FittedLinkState::Standard(None),
1013 geometry: None,
1014 block_states: Vec::new(),
1015 pirls_status: PirlsStatus::Converged,
1016 max_abs_eta: 1.25,
1017 constraint_kkt: None,
1018 artifacts: FitArtifacts::default(),
1019 inner_cycles: 0,
1020 };
1021 let fit = UnifiedFitResult::try_from_parts(parts)
1022 .unwrap_or_else(|e| panic!("construct #946 cubature-vs-first-order fixture: {e:?}"));
1023
1024 assert!(matches!(
1027 fit.smoothing_correction_method(),
1028 Some(SmoothingCorrectionMethod::SigmaPointCubature { .. })
1029 ));
1030 assert!(matches!(
1031 fit.smoothing_correction_method_first_order(),
1032 Some(SmoothingCorrectionMethod::FirstOrderIdentifiedSubspace { .. })
1033 ));
1034
1035 let y = array![0.1, 0.2, 0.3];
1036 let eta_hat = array![0.05, 0.15, 0.35];
1037 let weights = Array1::<f64>::ones(3);
1038 let cmp = model_comparison_from_unified(&fit, y.view(), eta_hat.view(), weights.view(), None)
1039 .expect("construct comparison for the cubature-vs-first-order fixture");
1040
1041 let corrected = cmp
1042 .edf
1043 .corrected
1044 .expect("corrected EDF must be Some even though the PRIMARY method is cubature");
1045 let aic_corrected = cmp
1046 .aic_corrected
1047 .expect("corrected AIC must be Some even though the PRIMARY method is cubature");
1048
1049 let rho_uncertainty_df = cmp
1056 .edf
1057 .rho_uncertainty_df()
1058 .expect("rho-uncertainty df must be Some");
1059 assert!(
1060 (rho_uncertainty_df - 0.8).abs() < 1e-9,
1061 "expected the retained first-order correction's contribution (0.8), got {rho_uncertainty_df}"
1062 );
1063 assert!(
1064 (corrected - 2.3).abs() < 1e-9,
1065 "corrected EDF must equal conditional + the first-order contribution, got {corrected}"
1066 );
1067 assert!(
1068 aic_corrected.is_finite(),
1069 "corrected AIC must be finite, got {aic_corrected}"
1070 );
1071 }
1072}