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 operations = normalized_terms.len() as f64;
216 let roundoff = operations * f64::EPSILON * absolute_sum;
217 if normalized < 0.0 {
218 if normalized >= -roundoff {
219 normalized = 0.0;
220 } else {
221 return Err(EstimationError::InvalidInput(format!(
222 "WPS PSD trace is negative beyond roundoff: normalized={normalized}, bound={roundoff}"
223 )));
224 }
225 }
226 if normalized == 0.0 {
227 return Ok(0.0);
228 }
229 let log_value = normalized.ln() + max_x.ln() + max_c.ln() - covariance_scale.ln();
230 let value = log_value.exp();
231 if value.is_finite() {
232 Ok(value)
233 } else {
234 Err(EstimationError::InvalidInput(
235 "WPS correction is outside f64 range".into(),
236 ))
237 }
238}
239
240pub fn alo_elpd(
259 loglik_fitted: ArrayView1<'_, f64>,
260 loglik_loo: ArrayView1<'_, f64>,
261) -> Result<AloElpd, EstimationError> {
262 let reduction_values: Vec<f64> = loglik_loo.iter().copied().collect();
263 let elpd = gam_solve::pirls::stable_finite_signed_sum(&reduction_values, "ALO elpd reduction")?;
264 alo_elpd_with_total(loglik_fitted, loglik_loo, elpd)
265}
266
267fn alo_elpd_with_total(
268 loglik_fitted: ArrayView1<'_, f64>,
269 loglik_loo: ArrayView1<'_, f64>,
270 elpd: f64,
271) -> Result<AloElpd, EstimationError> {
272 let n = loglik_loo.len();
273 if n == 0 {
274 return Err(EstimationError::InvalidInput(
275 "ALO requires at least one observation".into(),
276 ));
277 }
278 if loglik_fitted.len() != n {
279 return Err(EstimationError::InvalidInput(format!(
280 "ALO likelihood length mismatch: fitted={}, loo={n}",
281 loglik_fitted.len()
282 )));
283 }
284 if !elpd.is_finite() {
285 return Err(EstimationError::InvalidInput(format!(
286 "ALO elpd total is non-finite: {elpd}"
287 )));
288 }
289 let mut log_ratio = Array1::zeros(n);
290 for row in 0..n {
291 let fitted = loglik_fitted[row];
292 let loo = loglik_loo[row];
293 if !fitted.is_finite() || !loo.is_finite() {
294 return Err(EstimationError::InvalidInput(format!(
295 "ALO non-finite log-likelihood at row {row}: fitted={fitted}, loo={loo}"
296 )));
297 }
298 let ratio = fitted - loo;
299 if !ratio.is_finite() {
300 return Err(EstimationError::InvalidInput(format!(
301 "ALO log influence ratio is outside f64 range at row {row}: fitted={fitted}, loo={loo}"
302 )));
303 }
304 log_ratio[row] = ratio;
305 }
306 let max_lr = log_ratio.iter().copied().fold(f64::NEG_INFINITY, f64::max);
310 let raw: Vec<f64> = log_ratio.iter().map(|&lr| (lr - max_lr).exp()).collect();
311
312 let (k_hat_max, n_k_bad);
313 match pareto_smooth_weights(&raw) {
314 Some(psis) => {
315 k_hat_max = Some(psis.k_hat);
316 n_k_bad = if psis.k_hat > 0.7 { psis.tail_count } else { 0 };
317 }
318 None => {
319 k_hat_max = None;
320 n_k_bad = 0;
321 }
322 }
323
324 let pointwise = loglik_loo.to_owned();
325 let mean = elpd / n as f64;
326 let se = if n > 1 {
329 let max_deviation = pointwise
330 .iter()
331 .map(|&value| (value - mean).abs())
332 .fold(0.0_f64, f64::max);
333 if max_deviation == 0.0 {
334 Some(0.0)
335 } else {
336 let scaled_sum_squares: f64 = pointwise
337 .iter()
338 .map(|&value| {
339 let scaled = (value - mean) / max_deviation;
340 scaled * scaled
341 })
342 .sum();
343 let multiplier = (n as f64 * scaled_sum_squares / (n - 1) as f64).sqrt();
344 let value = max_deviation * multiplier;
345 if !value.is_finite() {
346 return Err(EstimationError::InvalidInput(
347 "ALO standard error is outside f64 range".into(),
348 ));
349 }
350 Some(value)
351 }
352 } else {
353 None
354 };
355 Ok(AloElpd {
356 elpd,
357 se,
358 pointwise,
359 k_hat_max,
360 n_k_bad,
361 })
362}
363
364#[derive(Debug, Clone)]
369pub struct ComparisonReport {
370 pub delta_elpd: Option<f64>,
372 pub delta_elpd_se: Option<f64>,
375 pub delta_aic_corrected: Option<f64>,
377 pub rows_aligned: bool,
381}
382
383pub fn compare(
388 a: &ModelComparison,
389 b: &ModelComparison,
390) -> Result<ComparisonReport, EstimationError> {
391 let delta_aic_corrected = match (a.aic_corrected, b.aic_corrected) {
392 (Some(left), Some(right)) => {
393 let difference = left - right;
394 if !difference.is_finite() {
395 return Err(EstimationError::InvalidInput(
396 "corrected-AIC difference is outside f64 range".into(),
397 ));
398 }
399 Some(difference)
400 }
401 _ => None,
402 };
403 match (&a.loo, &b.loo) {
404 (Some(la), Some(lb))
405 if la.pointwise.len() == lb.pointwise.len() && !la.pointwise.is_empty() =>
406 {
407 let n = la.pointwise.len();
408 let mut diff = Array1::zeros(n);
409 for row in 0..n {
410 let value = la.pointwise[row] - lb.pointwise[row];
411 if !value.is_finite() {
412 return Err(EstimationError::InvalidInput(format!(
413 "paired elpd difference is outside f64 range at row {row}"
414 )));
415 }
416 diff[row] = value;
417 }
418 let values: Vec<f64> = diff.iter().copied().collect();
419 let delta_elpd =
420 gam_solve::pirls::stable_finite_signed_sum(&values, "paired elpd reduction")?;
421 let mean = delta_elpd / n as f64;
422 let se = if n > 1 {
425 let max_deviation = diff
426 .iter()
427 .map(|&value| (value - mean).abs())
428 .fold(0.0_f64, f64::max);
429 if max_deviation == 0.0 {
430 Some(0.0)
431 } else {
432 let scaled_sum_squares: f64 = diff
433 .iter()
434 .map(|&value| {
435 let scaled = (value - mean) / max_deviation;
436 scaled * scaled
437 })
438 .sum();
439 let multiplier = (n as f64 * scaled_sum_squares / (n - 1) as f64).sqrt();
440 let value = max_deviation * multiplier;
441 if !value.is_finite() {
442 return Err(EstimationError::InvalidInput(
443 "paired elpd standard error is outside f64 range".into(),
444 ));
445 }
446 Some(value)
447 }
448 } else {
449 None
450 };
451 Ok(ComparisonReport {
452 delta_elpd: Some(delta_elpd),
453 delta_elpd_se: se,
454 delta_aic_corrected,
455 rows_aligned: true,
456 })
457 }
458 _ => Ok(ComparisonReport {
459 delta_elpd: None,
460 delta_elpd_se: None,
461 delta_aic_corrected,
462 rows_aligned: false,
463 }),
464 }
465}
466
467pub fn model_comparison_from_unified(
480 fit: &UnifiedFitResult,
481 y: ArrayView1<'_, f64>,
482 eta_hat: ArrayView1<'_, f64>,
483 prior_weights: ArrayView1<'_, f64>,
484 alo_eta_tilde: Option<ArrayView1<'_, f64>>,
485) -> Result<ModelComparison, EstimationError> {
486 let phi = fit.dispersion_phi()?;
487 let edf_conditional = fit.edf_total().ok_or_else(|| {
488 EstimationError::InvalidInput("model comparison requires a retained conditional EDF".into())
489 })?;
490 let covariance_scale = fit
491 .likelihood_family
492 .as_ref()
493 .map(|spec| {
494 GlmLikelihoodSpec {
495 spec: spec.clone(),
496 scale: fit.likelihood_scale,
497 }
498 .coefficient_covariance_scale(phi)
499 .map_err(|error| {
500 EstimationError::InvalidInput(format!(
501 "model-comparison coefficient covariance scale: {error}"
502 ))
503 })
504 })
505 .transpose()?;
506 let method_certified_exact = matches!(
527 fit.smoothing_correction_method_first_order(),
528 Some(SmoothingCorrectionMethod::FirstOrderIdentifiedSubspace { .. })
529 );
530 let edf = corrected_edf(
531 edf_conditional,
532 fit.weighted_gram().map(|g| g.view()),
533 fit.smoothing_correction_first_order().map(|c| c.view()),
534 covariance_scale,
535 fit.log_lambdas.len(),
536 method_certified_exact,
537 )?;
538
539 let log_lik = if let Some(spec) = fit.likelihood_family.as_ref() {
548 let scale = reporting_scale(spec, &fit.likelihood_scale, phi);
549 full_loglikelihood_at_eta(y, eta_hat, prior_weights, spec, scale)?
550 } else {
551 fit.log_likelihood
555 };
556
557 let scale_dof = fit
562 .likelihood_family
563 .as_ref()
564 .map(|spec| scale_parameter_count(spec, &fit.likelihood_scale))
565 .unwrap_or(0.0);
566
567 let aic_conditional = -2.0 * log_lik + 2.0 * (edf.conditional + scale_dof);
568 let aic_corrected = edf
569 .corrected
570 .map(|corrected| -2.0 * log_lik + 2.0 * (corrected + scale_dof));
571
572 let loo = match (alo_eta_tilde, fit.likelihood_family.as_ref()) {
573 (Some(eta_tilde), Some(spec)) => {
574 let scale = reporting_scale(spec, &fit.likelihood_scale, phi);
575 Some(alo_elpd_from_family(
576 y,
577 eta_hat,
578 eta_tilde,
579 prior_weights,
580 spec,
581 scale,
582 )?)
583 }
584 _ => None,
585 };
586
587 Ok(ModelComparison {
588 log_lik,
589 edf,
590 aic_conditional,
591 aic_corrected,
592 loo,
593 })
594}
595
596pub fn alo_elpd_from_family(
601 y: ArrayView1<'_, f64>,
602 eta_hat: ArrayView1<'_, f64>,
603 eta_loo: ArrayView1<'_, f64>,
604 prior_weights: ArrayView1<'_, f64>,
605 spec: &LikelihoodSpec,
606 scale: gam_problem::types::LikelihoodScaleMetadata,
607) -> Result<AloElpd, EstimationError> {
608 use gam_solve::pirls::evaluate_full_log_likelihood_from_eta;
609
610 let glm = GlmLikelihoodSpec {
611 spec: spec.clone(),
612 scale,
613 };
614 let ll_hat = evaluate_full_log_likelihood_from_eta(y, eta_hat, &glm, prior_weights)?;
622 let ll_loo = evaluate_full_log_likelihood_from_eta(y, eta_loo, &glm, prior_weights)?;
623 alo_elpd_with_total(ll_hat.pointwise(), ll_loo.pointwise(), ll_loo.total())
624}
625
626fn full_loglikelihood_at_eta(
629 y: ArrayView1<'_, f64>,
630 eta_hat: ArrayView1<'_, f64>,
631 prior_weights: ArrayView1<'_, f64>,
632 spec: &LikelihoodSpec,
633 scale: gam_problem::types::LikelihoodScaleMetadata,
634) -> Result<f64, EstimationError> {
635 use gam_solve::pirls::evaluate_full_log_likelihood_from_eta;
636
637 let glm = GlmLikelihoodSpec {
638 spec: spec.clone(),
639 scale,
640 };
641 evaluate_full_log_likelihood_from_eta(y, eta_hat, &glm, prior_weights)
642 .map(|evaluation| evaluation.total())
643}
644
645fn reporting_scale(
656 spec: &LikelihoodSpec,
657 scale: &gam_problem::types::LikelihoodScaleMetadata,
658 phi: f64,
659) -> gam_problem::types::LikelihoodScaleMetadata {
660 use gam_problem::types::{LikelihoodScaleMetadata, ResponseFamily};
661 match spec.response {
662 ResponseFamily::Gaussian => match *scale {
663 fixed @ LikelihoodScaleMetadata::FixedDispersion { .. } => fixed,
664 LikelihoodScaleMetadata::ProfiledGaussian if phi.is_finite() && phi > 0.0 => {
665 LikelihoodScaleMetadata::FixedDispersion { phi }
666 }
667 other => other,
668 },
669 _ => scale.clone(),
670 }
671}
672
673fn scale_parameter_count(
680 spec: &LikelihoodSpec,
681 scale: &gam_problem::types::LikelihoodScaleMetadata,
682) -> f64 {
683 use gam_problem::types::{LikelihoodScaleMetadata, ResponseFamily};
684 let estimated = match spec.response {
685 ResponseFamily::Gaussian => {
686 !matches!(scale, LikelihoodScaleMetadata::FixedDispersion { .. })
687 }
688 ResponseFamily::Gamma => {
689 matches!(scale, LikelihoodScaleMetadata::EstimatedGammaShape { .. })
690 }
691 ResponseFamily::Beta { .. } => {
692 matches!(scale, LikelihoodScaleMetadata::EstimatedBetaPhi { .. })
693 }
694 ResponseFamily::Tweedie { .. } => {
695 matches!(scale, LikelihoodScaleMetadata::EstimatedTweediePhi { .. })
696 }
697 ResponseFamily::NegativeBinomial { .. } => {
698 matches!(scale, LikelihoodScaleMetadata::EstimatedNegBinTheta { .. })
699 }
700 ResponseFamily::Poisson | ResponseFamily::Binomial | ResponseFamily::RoystonParmar => false,
701 };
702 if estimated { 1.0 } else { 0.0 }
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708 use ndarray::{Array2, array};
709
710 #[test]
711 fn wps_correction_is_trace_of_h_f_sigma_over_phi() {
712 let xwx = Array2::<f64>::eye(3);
714 let corr = array![[2.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 6.0]];
715 let edf = corrected_edf(3.0, Some(xwx.view()), Some(corr.view()), Some(2.0), 1, true)
716 .expect("corrected EDF");
717 assert_eq!(edf.corrected, Some(9.0));
719 assert_eq!(edf.rho_uncertainty_df(), Some(6.0));
720 assert!((edf.conditional - 3.0).abs() < 1e-12);
721 }
722
723 #[test]
724 fn corrected_edf_reports_unavailable_without_inputs() {
725 let edf = corrected_edf(5.5, None, None, Some(1.0), 1, true).expect("availability result");
726 assert_eq!(edf.conditional, 5.5);
727 assert_eq!(edf.corrected, None);
728 assert_eq!(edf.rho_uncertainty_df(), None);
729 assert_eq!(
730 edf.unavailable_reason,
731 Some(CorrectedEdfUnavailable::MissingWeightedGram)
732 );
733 }
734
735 #[test]
736 fn alo_elpd_sums_pointwise_and_flags_no_tail() {
737 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];
740 let loo = alo_elpd(ll.view(), ll.view()).expect("alo elpd");
741 let expected: f64 = ll.iter().sum();
742 assert!((loo.elpd - expected).abs() < 1e-9);
743 assert_eq!(loo.pointwise.len(), ll.len());
744 assert_eq!(loo.n_k_bad, 0);
746 }
747
748 #[test]
749 fn alo_elpd_pointwise_is_local_to_alo_loglikelihoods() {
750 let ll_loo: Array1<f64> = array![
751 -1.0, -1.1, -1.2, -1.3, -1.4, -1.5, -1.6, -1.7, -1.8, -1.9, -2.0, -2.1
752 ];
753 let ll_hat = ll_loo.clone();
754 let mut ll_hat_perturbed = ll_loo.clone();
755 ll_hat_perturbed[7] += 10.0;
756
757 let base = alo_elpd(ll_hat.view(), ll_loo.view()).expect("alo elpd");
758 let perturbed = alo_elpd(ll_hat_perturbed.view(), ll_loo.view()).expect("alo elpd");
759
760 for i in 0..ll_loo.len() {
761 assert_eq!(base.pointwise[i], ll_loo[i]);
762 assert_eq!(perturbed.pointwise[i], ll_loo[i]);
763 if i != 7 {
764 assert_eq!(base.pointwise[i], perturbed.pointwise[i]);
765 }
766 }
767 assert_eq!(perturbed.elpd, base.elpd);
768 }
769
770 fn gpd_sample(u: f64, k: f64, sigma: f64) -> f64 {
771 sigma * ((1.0 - u).powf(-k) - 1.0) / k
772 }
773
774 #[test]
775 fn alo_elpd_influence_diagnostic_fires_on_heavy_tailed_ratios() {
776 let mut ratios = vec![1.0; 200];
777 for i in 1..=120 {
778 let u = (i as f64 - 0.5) / 120.0;
779 ratios.push(1.0 + gpd_sample(u, 1.2, 0.5));
780 }
781 let ll_loo: Array1<f64> = Array1::from_elem(ratios.len(), -1.0);
782 let ll_hat: Array1<f64> = Array1::from_iter(
783 ll_loo
784 .iter()
785 .zip(ratios.iter())
786 .map(|(&ll, &ratio)| ll + ratio.ln()),
787 );
788
789 let loo = alo_elpd(ll_hat.view(), ll_loo.view()).expect("alo elpd");
790
791 assert_eq!(loo.pointwise, ll_loo);
792 assert!((loo.elpd - -(ratios.len() as f64)).abs() < 1e-12);
793 assert!(
794 loo.k_hat_max.is_some_and(|value| value > 0.7),
795 "heavy fitted-vs-ALO ratio tail should fire influence diagnostic; got k_hat={:?}",
796 loo.k_hat_max
797 );
798 assert!(
799 loo.n_k_bad > 0,
800 "heavy fitted-vs-ALO ratio tail should count influential tail observations"
801 );
802 }
803
804 #[test]
805 fn compare_pairs_pointwise_and_orients_a_minus_b() {
806 let mk = |pw: Array1<f64>, aic: f64| ModelComparison {
807 log_lik: 0.0,
808 edf: CorrectedEdf {
809 conditional: 0.0,
810 corrected: Some(0.0),
811 unavailable_reason: None,
812 },
813 aic_conditional: aic,
814 aic_corrected: Some(aic),
815 loo: Some(AloElpd {
816 elpd: pw.iter().sum(),
817 se: Some(0.0),
818 pointwise: pw,
819 k_hat_max: Some(0.1),
820 n_k_bad: 0,
821 }),
822 };
823 let a = mk(array![-1.0, -1.0, -1.0, -1.0], 10.0);
824 let b = mk(array![-2.0, -2.0, -2.0, -2.0], 14.0);
825 let rep = compare(&a, &b).expect("comparison");
826 assert!(rep.rows_aligned);
827 assert_eq!(rep.delta_elpd, Some(4.0));
829 assert_eq!(rep.delta_aic_corrected, Some(-4.0));
830 assert_eq!(rep.delta_elpd_se, Some(0.0));
831 }
832
833 #[test]
834 fn alo_elpd_se_uses_unbiased_sample_variance() {
835 let ll: Array1<f64> = array![0.0, 2.0];
839 let loo = alo_elpd(ll.view(), ll.view()).expect("alo elpd");
840 assert_eq!(loo.se, Some(2.0));
841 }
842
843 #[test]
844 fn compare_se_uses_unbiased_sample_variance_of_paired_differences() {
845 let mk = |pw: Array1<f64>| ModelComparison {
846 log_lik: 0.0,
847 edf: CorrectedEdf {
848 conditional: 0.0,
849 corrected: Some(0.0),
850 unavailable_reason: None,
851 },
852 aic_conditional: 0.0,
853 aic_corrected: Some(0.0),
854 loo: Some(AloElpd {
855 elpd: pw.iter().sum(),
856 se: Some(0.0),
857 pointwise: pw,
858 k_hat_max: Some(0.1),
859 n_k_bad: 0,
860 }),
861 };
862 let a = mk(array![0.0, 2.0]);
864 let b = mk(array![0.0, 0.0]);
865 let rep = compare(&a, &b).expect("comparison");
866 assert!(rep.rows_aligned);
867 assert!(
868 rep.delta_elpd_se == Some(2.0),
869 "se = {:?}",
870 rep.delta_elpd_se
871 );
872 }
873
874 #[test]
875 fn compare_refuses_unpaired_rows() {
876 let mk = |pw: Array1<f64>| ModelComparison {
877 log_lik: 0.0,
878 edf: CorrectedEdf {
879 conditional: 0.0,
880 corrected: Some(0.0),
881 unavailable_reason: None,
882 },
883 aic_conditional: 0.0,
884 aic_corrected: Some(5.0),
885 loo: Some(AloElpd {
886 elpd: pw.iter().sum(),
887 se: Some(0.0),
888 pointwise: pw,
889 k_hat_max: Some(0.1),
890 n_k_bad: 0,
891 }),
892 };
893 let a = mk(array![-1.0, -1.0, -1.0]);
894 let b = mk(array![-1.0, -1.0]);
895 let rep = compare(&a, &b).expect("comparison");
896 assert!(!rep.rows_aligned);
897 assert_eq!(rep.delta_elpd, None);
898 assert_eq!(rep.delta_aic_corrected, Some(0.0));
900 }
901
902 #[test]
913 fn corrected_edf_uses_retained_first_order_pair_when_primary_method_is_cubature() {
914 use gam_solve::model_types::{
915 Dispersion, FitArtifacts, FitInference, FittedBlock, FittedLinkState,
916 UnifiedFitResultParts,
917 };
918 use gam_solve::pirls::PirlsStatus;
919 use gam_problem::{LikelihoodScaleMetadata, LogLikelihoodNormalization};
920
921 let cubature_correction = array![[9.0, 0.0], [0.0, 9.0]];
924 let first_order_correction = array![[0.4, 0.0], [0.0, 0.4]];
925 let weighted_gram = array![[1.0, 0.0], [0.0, 1.0]];
926
927 let parts = UnifiedFitResultParts {
928 blocks: vec![FittedBlock {
929 beta: array![0.25, -0.5],
930 role: gam_problem::BlockRole::Mean,
931 edf: 1.5,
932 lambdas: array![2.0],
933 }],
934 log_lambdas: array![2.0_f64.ln()],
935 lambdas: array![2.0],
936 likelihood_family: Some(LikelihoodSpec::gaussian_identity()),
937 likelihood_scale: LikelihoodScaleMetadata::ProfiledGaussian,
938 log_likelihood_normalization: LogLikelihoodNormalization::Full,
939 log_likelihood: -1.2,
940 deviance: 2.4,
941 reml_score: 0.7,
942 stable_penalty_term: 0.3,
943 penalized_objective: 2.2,
944 used_device: false,
945 outer_iterations: 0,
952 outer_converged: true,
953 outer_gradient_norm: None,
954 standard_deviation: 1.0,
958 covariance_conditional: Some(array![[1.0, 0.1], [0.1, 2.0]]),
959 covariance_corrected: None,
960 inference: Some(FitInference {
961 edf_by_block: vec![0.6, 0.9],
962 penalty_block_trace: vec![],
963 edf_total: 1.5,
964 smoothing_correction: Some(cubature_correction.clone()),
966 smoothing_correction_method: Some(SmoothingCorrectionMethod::SigmaPointCubature {
967 rank: 1,
968 n_points: 2,
969 rho_hessian_stabilization: gam_problem::StabilizationLedger::approximation_only(
970 1.0e-8,
971 gam_problem::StabilizationRule::FixedConstant,
972 )
973 .expect("valid test cubature ridge"),
974 }),
975 smoothing_correction_first_order: Some(first_order_correction.clone()),
978 smoothing_correction_method_first_order: Some(
979 SmoothingCorrectionMethod::FirstOrderIdentifiedSubspace {
980 active_rank: 1,
981 rho_dimension: 1,
982 },
983 ),
984 penalized_hessian: array![[2.0, 0.1], [0.1, 3.0]].into(),
985 reparam_qs: Some(array![[1.0, 0.0], [0.0, 1.0]]),
986 dispersion: Dispersion::estimated(1.0).expect("valid test dispersion"),
987 beta_covariance: Some(array![[1.0, 0.1], [0.1, 2.0]].into()),
988 beta_standard_errors: Some(array![1.0, 2.0_f64.sqrt()]),
989 beta_covariance_corrected: None,
990 beta_standard_errors_corrected: None,
991 beta_covariance_frequentist: None,
992 coefficient_influence: None,
993 weighted_gram: Some(weighted_gram),
994 bias_correction_beta: None,
995 bias_correction_jacobian: None,
996 }),
997 fitted_link: FittedLinkState::Standard(None),
998 geometry: None,
999 block_states: Vec::new(),
1000 pirls_status: PirlsStatus::Converged,
1001 max_abs_eta: 1.25,
1002 constraint_kkt: None,
1003 artifacts: FitArtifacts::default(),
1004 inner_cycles: 0,
1005 };
1006 let fit = UnifiedFitResult::try_from_parts(parts)
1007 .unwrap_or_else(|e| panic!("construct #946 cubature-vs-first-order fixture: {e:?}"));
1008
1009 assert!(matches!(
1012 fit.smoothing_correction_method(),
1013 Some(SmoothingCorrectionMethod::SigmaPointCubature { .. })
1014 ));
1015 assert!(matches!(
1016 fit.smoothing_correction_method_first_order(),
1017 Some(SmoothingCorrectionMethod::FirstOrderIdentifiedSubspace { .. })
1018 ));
1019
1020 let y = array![0.1, 0.2, 0.3];
1021 let eta_hat = array![0.05, 0.15, 0.35];
1022 let weights = Array1::<f64>::ones(3);
1023 let cmp = model_comparison_from_unified(&fit, y.view(), eta_hat.view(), weights.view(), None)
1024 .expect("construct comparison for the cubature-vs-first-order fixture");
1025
1026 let corrected = cmp
1027 .edf
1028 .corrected
1029 .expect("corrected EDF must be Some even though the PRIMARY method is cubature");
1030 let aic_corrected = cmp
1031 .aic_corrected
1032 .expect("corrected AIC must be Some even though the PRIMARY method is cubature");
1033
1034 let rho_uncertainty_df = cmp
1041 .edf
1042 .rho_uncertainty_df()
1043 .expect("rho-uncertainty df must be Some");
1044 assert!(
1045 (rho_uncertainty_df - 0.8).abs() < 1e-9,
1046 "expected the retained first-order correction's contribution (0.8), got {rho_uncertainty_df}"
1047 );
1048 assert!(
1049 (corrected - 2.3).abs() < 1e-9,
1050 "corrected EDF must equal conditional + the first-order contribution, got {corrected}"
1051 );
1052 assert!(
1053 aic_corrected.is_finite(),
1054 "corrected AIC must be finite, got {aic_corrected}"
1055 );
1056 }
1057}