gam_inference/model_comparison.rs
1//! Honest, calibrated model comparison computed from machinery already present
2//! at the fit optimum — exact smoothing-corrected conditional AIC and zero-refit
3//! ALO elpd with an influence diagnostic (issue #946).
4//!
5//! Every consumer (the topology race, the SAE fit payload, the `compare`
6//! entry point) reads the same two channels:
7//!
8//! * **Corrected conditional AIC.** The conditional AIC `−2·ℓ + 2·edf` treats
9//! the smoothing parameters as known and is biased toward complexity exactly
10//! where users rely on it (random-effect-vs-null, is-a-wiggle-real). The
11//! Wood–Pya–Säfken (2016, JASA) correction replaces `edf = tr(F)` by
12//! `τ = tr(F) + tr(X'WX · Σ_ρ)`, where `Σ_ρ` is the smoothing-parameter
13//! uncertainty covariance in coefficient space. gam carries `Σ_ρ` *exactly*
14//! (assembled from the IFT `dβ̂/dρ` and the exact outer Hessian at the fit
15//! optimum, retained on the fit as [`UnifiedFitResult::smoothing_correction`]),
16//! so the correction is the first exact instance of this estimator — not the
17//! approximation mgcv must use, and not the omission most software ships.
18//!
19//! * **ALO elpd.** Pointwise log predictive densities evaluated at the
20//! ALO-corrected leave-one-out predictions (no refits — the ALO solves reuse
21//! the fit's factored Hessian). The summed elpd is exactly
22//! `Σᵢ ℓ(yᵢ|η̃₋ᵢ)`. A Pareto tail fit of the cross-observation fitted-vs-ALO
23//! ratio distribution is reported only as an influence diagnostic; it is not
24//! draw-wise PSIS-LOO and does not alter the pointwise contributions.
25//!
26//! Both channels are *corroboration*: they ride alongside the evidence headline
27//! a race already produces, never replacing it.
28
29use crate::alo::AloDiagnostics;
30use gam_problem::types::{GlmLikelihoodSpec, LikelihoodSpec};
31use gam_solve::estimate::UnifiedFitResult;
32use gam_solve::psis::pareto_smooth_weights;
33use ndarray::{Array1, ArrayView1, ArrayView2};
34
35/// ALO predictive-accuracy summary at zero refit cost.
36#[derive(Debug, Clone)]
37pub struct AloElpd {
38 /// Expected log pointwise predictive density, `Σᵢ ℓ(yᵢ|η̃₋ᵢ)`.
39 pub elpd: f64,
40 /// Standard error of `elpd`, `√(n · Var(pointwise))`.
41 pub se: f64,
42 /// Per-observation ALO elpd contributions (length `n`).
43 pub pointwise: Array1<f64>,
44 /// GPD tail-shape `k̂` of the cross-observation fitted-vs-ALO ratio
45 /// distribution. This is an influence diagnostic, not a PSIS-LOO reliability
46 /// diagnostic.
47 pub k_hat_max: f64,
48 /// Number of tail observations flagged when the influence diagnostic exceeds
49 /// the `0.7` heavy-tail cutoff.
50 pub n_k_bad: usize,
51}
52
53/// Effective-degrees-of-freedom pair: the conditional `tr(F)` and the
54/// Wood–Pya–Säfken correction that accounts for smoothing-parameter
55/// uncertainty.
56#[derive(Debug, Clone, Copy)]
57pub struct CorrectedEdf {
58 /// `tr(F)` with `F = H⁻¹X'WX`, conditional on `λ̂`.
59 pub conditional: f64,
60 /// `τ = tr(F) + tr(X'WX · Σ_ρ)`, the exact WPS corrected EDF. Equals
61 /// [`Self::conditional`] when no smoothing correction is available (e.g.
62 /// `K = 0`, or the post-fit IFT solve was skipped).
63 pub corrected: f64,
64}
65
66impl CorrectedEdf {
67 /// The per-fit measurement the issue calls out: how much λ-uncertainty is
68 /// inflating the user's model-choice complexity penalty, `τ − tr(F)`.
69 pub fn rho_uncertainty_df(&self) -> f64 {
70 self.corrected - self.conditional
71 }
72}
73
74/// The full comparison payload reported alongside a fit's evidence headline.
75#[derive(Debug, Clone)]
76pub struct ModelComparison {
77 /// Log-likelihood at the converged mode (the engine's
78 /// constants-omitted value — see note on cross-fit comparability below).
79 pub log_lik: f64,
80 /// Conditional and WPS-corrected effective degrees of freedom.
81 pub edf: CorrectedEdf,
82 /// `−2·ℓ + 2·edf_conditional` (treats `λ̂` as known).
83 pub aic_conditional: f64,
84 /// `−2·ℓ + 2·edf_corrected` (Wood–Pya–Säfken).
85 pub aic_corrected: f64,
86 /// Zero-refit ALO predictive comparison, when ALO diagnostics and the per-row
87 /// family kernel are available.
88 pub loo: Option<AloElpd>,
89}
90
91/// Exact Wood–Pya–Säfken corrected effective degrees of freedom.
92///
93/// `edf_conditional = tr(F)` with `F = H⁻¹X'WX` (the engine's `edf_total`).
94/// The correction term is `tr(X'WX · Σ_ρ)` where `Σ_ρ` is the H⁻¹-scale
95/// smoothing-parameter uncertainty covariance. The engine stores the genuine
96/// symmetric-PSD weighted Gram `X'WX = H − S(λ)` directly on the fit
97/// ([`UnifiedFitResult::weighted_gram`], issue #1027) — pairing it with
98/// `Σ_ρ = smoothing_correction / φ` makes the correction the nonnegative
99/// `tr(A½ B A½)` it is defined to be, instead of the indefinite `H·F`
100/// reconstruction (where the stored `H` need not satisfy `H·F = X'WX`) that
101/// drove the corrected EDF below the conditional EDF.
102///
103/// Returns `edf_conditional` unchanged when any exact input is absent —
104/// the conditional value is the honest fallback, never an approximation of
105/// the correction.
106pub fn corrected_edf(
107 edf_conditional: f64,
108 weighted_gram: Option<ArrayView2<'_, f64>>,
109 smoothing_correction: Option<ArrayView2<'_, f64>>,
110 phi: f64,
111) -> CorrectedEdf {
112 let correction = wps_correction_term(weighted_gram, smoothing_correction, phi);
113 CorrectedEdf {
114 conditional: edf_conditional,
115 corrected: edf_conditional + correction,
116 }
117}
118
119/// `tr(X'WX · Σ_ρ)` with `Σ_ρ = smoothing_correction / φ` and `X'WX` the
120/// stored PSD weighted Gram. Returns `0.0` when any input is missing,
121/// non-square, dimension-mismatched, or non-finite. Nonnegative by
122/// construction (both factors are symmetric PSD).
123fn wps_correction_term(
124 weighted_gram: Option<ArrayView2<'_, f64>>,
125 smoothing_correction: Option<ArrayView2<'_, f64>>,
126 phi: f64,
127) -> f64 {
128 let (Some(xwx), Some(corr)) = (weighted_gram, smoothing_correction) else {
129 return 0.0;
130 };
131 let k = xwx.nrows();
132 if k == 0
133 || xwx.ncols() != k
134 || corr.nrows() != k
135 || corr.ncols() != k
136 || !(phi.is_finite() && phi > 0.0)
137 {
138 return 0.0;
139 }
140 // tr(X'WX · corr/φ) = (1/φ) Σ_{ij} X'WX_{ij} corr_{ji}; both symmetric, so
141 // this is the nonnegative tr(A^½ B A^½).
142 let mut trace = 0.0;
143 for i in 0..k {
144 for j in 0..k {
145 trace += xwx[[i, j]] * corr[[j, i]];
146 }
147 }
148 trace /= phi;
149 if trace.is_finite() { trace } else { 0.0 }
150}
151
152/// ALO elpd from ALO-corrected leave-one-out predictions.
153///
154/// `loglik_fitted` and `loglik_loo` are the per-observation log predictive
155/// densities at the *fitted* (`η̂`) and *ALO leave-one-out* (`η̃₋ᵢ`) linear
156/// predictors respectively. The returned elpd is the honest ALO estimand
157/// `Σᵢ loglik_loo[i]`; each pointwise contribution is exactly `loglik_loo[i]`.
158///
159/// The raw fitted-vs-ALO ratio for observation `i` is
160/// `r_i = exp(ℓ(yᵢ|η̂ᵢ) − ℓ(yᵢ|η̃₋ᵢ))` — large where dropping `i` would have
161/// moved the fit a lot. We fit a GPD tail to this cross-observation ratio vector
162/// only to report an influence diagnostic: `k_hat_max` is the fitted tail shape
163/// and `n_k_bad` is the tail count when `k̂ > 0.7`. This is not draw-wise
164/// PSIS-LOO: there is no posterior-draw dimension, the Pareto fit is across
165/// observations, and the diagnostic never changes elpd.
166///
167/// Returns `None` when the inputs are degenerate (non-finite, mismatched
168/// lengths, or empty). If the influence tail fit is unavailable, `k_hat_max` is
169/// `NaN` and `n_k_bad` is zero.
170pub fn alo_elpd(
171 loglik_fitted: ArrayView1<'_, f64>,
172 loglik_loo: ArrayView1<'_, f64>,
173) -> Option<AloElpd> {
174 let n = loglik_loo.len();
175 if n == 0 || loglik_fitted.len() != n {
176 return None;
177 }
178 if loglik_fitted
179 .iter()
180 .chain(loglik_loo.iter())
181 .any(|v| !v.is_finite())
182 {
183 return None;
184 }
185 // Cross-observation influence ratios r_i = p(y_i|η̂_i) / p(y_i|η̃₋ᵢ).
186 // Stabilize by subtracting the max log-ratio before exponentiating; the
187 // multiplicative constant does not change the fitted GPD shape.
188 let log_ratio: Array1<f64> = &loglik_fitted.to_owned() - &loglik_loo.to_owned();
189 let max_lr = log_ratio.iter().copied().fold(f64::NEG_INFINITY, f64::max);
190 if !max_lr.is_finite() {
191 return None;
192 }
193 let raw: Vec<f64> = log_ratio.iter().map(|&lr| (lr - max_lr).exp()).collect();
194
195 let (k_hat_max, n_k_bad);
196 match pareto_smooth_weights(&raw) {
197 Some(psis) => {
198 k_hat_max = psis.k_hat;
199 n_k_bad = if psis.k_hat > 0.7 { psis.tail_count } else { 0 };
200 }
201 None => {
202 k_hat_max = f64::NAN;
203 n_k_bad = 0;
204 }
205 }
206
207 let pointwise = loglik_loo.to_owned();
208 let elpd: f64 = pointwise.iter().sum();
209 let mean = elpd / n as f64;
210 // SE of the sum of n pointwise contributions: √(n·s²) with the unbiased
211 // sample variance (denominator n−1). Undefined for a single observation.
212 let var = if n > 1 {
213 pointwise
214 .iter()
215 .map(|&p| (p - mean) * (p - mean))
216 .sum::<f64>()
217 / (n - 1) as f64
218 } else {
219 f64::NAN
220 };
221 let se = (n as f64 * var).sqrt();
222 Some(AloElpd {
223 elpd,
224 se,
225 pointwise,
226 k_hat_max,
227 n_k_bad,
228 })
229}
230
231/// Result of comparing two fits on the same response: the paired predictive
232/// difference with its standard error plus the
233/// corrected-AIC gap. Both differences are oriented `a − b`: positive `delta_elpd`
234/// favours `a`, negative `delta_aic_corrected` favours `a`.
235#[derive(Debug, Clone)]
236pub struct ComparisonReport {
237 /// `Σᵢ (elpd_aᵢ − elpd_bᵢ)`; positive favours `a`.
238 pub delta_elpd: f64,
239 /// SE of `delta_elpd` from the pointwise paired differences,
240 /// `√(n · Var(elpd_aᵢ − elpd_bᵢ))`.
241 pub delta_elpd_se: f64,
242 /// `AIC_corrected(a) − AIC_corrected(b)`; negative favours `a`.
243 pub delta_aic_corrected: f64,
244 /// `false` when the two fits have a different number of observations and the
245 /// paired predictive difference could not be formed; `delta_elpd` is then
246 /// `NaN` and only the AIC gap is meaningful.
247 pub rows_aligned: bool,
248}
249
250/// Paired comparison of two fits. The predictive difference is paired
251/// row-by-row, so the two fits must have been computed on
252/// the same response in the same order; we refuse the paired difference when the
253/// observation counts disagree and surface only the AIC gap.
254pub fn compare(a: &ModelComparison, b: &ModelComparison) -> ComparisonReport {
255 let delta_aic_corrected = a.aic_corrected - b.aic_corrected;
256 match (&a.loo, &b.loo) {
257 (Some(la), Some(lb))
258 if la.pointwise.len() == lb.pointwise.len() && !la.pointwise.is_empty() =>
259 {
260 let n = la.pointwise.len();
261 let diff: Array1<f64> = &la.pointwise - &lb.pointwise;
262 let delta_elpd: f64 = diff.iter().sum();
263 let mean = delta_elpd / n as f64;
264 // Unbiased sample variance (n−1) of the paired differences; the SE
265 // of the summed difference is √(n·s²).
266 let var = if n > 1 {
267 diff.iter().map(|&d| (d - mean) * (d - mean)).sum::<f64>() / (n - 1) as f64
268 } else {
269 f64::NAN
270 };
271 ComparisonReport {
272 delta_elpd,
273 delta_elpd_se: (n as f64 * var).sqrt(),
274 delta_aic_corrected,
275 rows_aligned: true,
276 }
277 }
278 _ => ComparisonReport {
279 delta_elpd: f64::NAN,
280 delta_elpd_se: f64::NAN,
281 delta_aic_corrected,
282 rows_aligned: false,
283 },
284 }
285}
286
287/// Assemble the comparison payload for a fitted GLM/GAM from the fit result plus
288/// optional ALO diagnostics.
289///
290/// The corrected-AIC channel is always populated (it needs only fit-retained
291/// fields). The ALO elpd channel is populated when `alo` is supplied and the
292/// fit carries an engine-level family: the leave-one-out linear predictors are
293/// the ALO `eta_tilde`, mapped through the family inverse link to means and
294/// scored by the per-row family log-likelihood kernel.
295///
296/// `eta_hat` is the *fitted* linear predictor (including offset) and `y` the
297/// response, both length `n`.
298pub fn model_comparison_from_unified(
299 fit: &UnifiedFitResult,
300 y: ArrayView1<'_, f64>,
301 eta_hat: ArrayView1<'_, f64>,
302 prior_weights: ArrayView1<'_, f64>,
303 alo: Option<&AloDiagnostics>,
304) -> ModelComparison {
305 let phi = fit.dispersion_phi();
306 let edf_conditional = fit.edf_total().unwrap_or(f64::NAN);
307 let edf = corrected_edf(
308 edf_conditional,
309 fit.weighted_gram().map(|g| g.view()),
310 fit.smoothing_correction().map(|c| c.view()),
311 phi,
312 );
313
314 // The user-facing `log_likelihood` (and the AIC / elpd derived from it) must
315 // be the *fully normalized, scale-aware* absolute log-likelihood — not the
316 // REML building block stored on the fit, which deliberately drops every
317 // family- and saturated-likelihood normalizing constant and the Gaussian
318 // scale (#1581/#1582/#1583). Recompute it here at the fitted means with the
319 // profiled Gaussian scale concretized into σ̂². For custom / GAMLSS fits with
320 // no engine-level family there is no per-row kernel to call, so we fall back
321 // to the stored value (those paths supply their own normalized log-lik).
322 let log_lik = fit
323 .likelihood_family
324 .as_ref()
325 .and_then(|spec| {
326 let scale = reporting_scale(spec, &fit.likelihood_scale, phi);
327 full_loglikelihood_at_eta(y, eta_hat, prior_weights, spec, scale)
328 })
329 .unwrap_or(fit.log_likelihood);
330
331 // An estimated / profiled dispersion is a fitted parameter and adds one
332 // degree of freedom to the conditional AIC — mgcv's `2·(edf + 1)` for a
333 // scale-estimated family (#1583). Fixed-scale families (Poisson, Binomial,
334 // user-fixed φ/θ) add none.
335 let scale_dof = fit
336 .likelihood_family
337 .as_ref()
338 .map(|spec| scale_parameter_count(spec, &fit.likelihood_scale))
339 .unwrap_or(0.0);
340
341 let aic_conditional = -2.0 * log_lik + 2.0 * (edf.conditional + scale_dof);
342 let aic_corrected = -2.0 * log_lik + 2.0 * (edf.corrected + scale_dof);
343
344 let loo = alo.and_then(|alo| {
345 let spec = fit.likelihood_family.clone()?;
346 let scale = reporting_scale(&spec, &fit.likelihood_scale, phi);
347 alo_elpd_from_family(
348 y,
349 eta_hat,
350 alo.eta_tilde.view(),
351 prior_weights,
352 &spec,
353 scale,
354 )
355 });
356
357 ModelComparison {
358 log_lik,
359 edf,
360 aic_conditional,
361 aic_corrected,
362 loo,
363 }
364}
365
366/// ALO elpd for an engine-level family: map the fitted and ALO leave-one-out
367/// linear predictors through the family inverse link, score both with the
368/// per-row log-likelihood kernel, and compute the ALO elpd plus influence
369/// diagnostic.
370pub fn alo_elpd_from_family(
371 y: ArrayView1<'_, f64>,
372 eta_hat: ArrayView1<'_, f64>,
373 eta_loo: ArrayView1<'_, f64>,
374 prior_weights: ArrayView1<'_, f64>,
375 spec: &LikelihoodSpec,
376 scale: gam_problem::types::LikelihoodScaleMetadata,
377) -> Option<AloElpd> {
378 use gam_models::family_runtime::{FamilyStrategy, strategy_for_spec};
379 use gam_solve::pirls::pointwise_loglikelihood;
380
381 let n = y.len();
382 if eta_hat.len() != n || eta_loo.len() != n || prior_weights.len() != n || n == 0 {
383 return None;
384 }
385 let strategy = strategy_for_spec(spec);
386 let mu_hat = strategy.inverse_link_array(eta_hat).ok()?;
387 let mu_loo = strategy.inverse_link_array(eta_loo).ok()?;
388 let glm = GlmLikelihoodSpec {
389 spec: spec.clone(),
390 scale,
391 };
392 // The PSIS-LOO `elpd` reported to the user is an *absolute* log predictive
393 // density, so it must use the fully normalized, scale-aware kernel (the
394 // profiled Gaussian scale is concretized by the caller). The dropped
395 // constants are identical for the fitted and LOO evaluations of a row (they
396 // depend only on yᵢ and the scale, not on μ), so the PSIS importance ratios
397 // r_i = exp(ℓ̂_i − ℓ_loo,i) — and hence k̂ — are unchanged; only the absolute
398 // elpd is corrected (#1581/#1582/#1583).
399 let ll_hat = pointwise_loglikelihood(y, &mu_hat, &glm, prior_weights);
400 let ll_loo = pointwise_loglikelihood(y, &mu_loo, &glm, prior_weights);
401 alo_elpd(ll_hat.view(), ll_loo.view())
402}
403
404/// Total fully-normalized log-likelihood at the fitted linear predictor
405/// `eta_hat`: map through the family inverse link and sum the per-row reporting
406/// kernel. `None` when the inverse link fails, the lengths disagree, or the
407/// result is non-finite (so callers fall back to the stored value).
408fn full_loglikelihood_at_eta(
409 y: ArrayView1<'_, f64>,
410 eta_hat: ArrayView1<'_, f64>,
411 prior_weights: ArrayView1<'_, f64>,
412 spec: &LikelihoodSpec,
413 scale: gam_problem::types::LikelihoodScaleMetadata,
414) -> Option<f64> {
415 use gam_models::family_runtime::{FamilyStrategy, strategy_for_spec};
416 use gam_solve::pirls::calculate_loglikelihood;
417
418 let n = y.len();
419 if eta_hat.len() != n || prior_weights.len() != n || n == 0 {
420 return None;
421 }
422 let mu_hat = strategy_for_spec(spec).inverse_link_array(eta_hat).ok()?;
423 let glm = GlmLikelihoodSpec {
424 spec: spec.clone(),
425 scale,
426 };
427 let ll = calculate_loglikelihood(y, &mu_hat, &glm, prior_weights);
428 ll.is_finite().then_some(ll)
429}
430
431/// Concretize the response-scale metadata for the *reporting* log-likelihood.
432///
433/// The profiled Gaussian carries no fixed scale (`ProfiledGaussian`), so its
434/// predictive density would silently collapse to the unit-variance form. Here we
435/// resolve the estimated residual variance `σ̂² = phi` into a concrete
436/// `FixedDispersion`, so the reporting kernel scores the density on the right
437/// measure and obeys the change-of-variables law (#1583). An explicitly fixed φ
438/// is honored as-is; every other family already carries the parameters its
439/// density needs (Beta φ, NB θ, Gamma shape, Tweedie φ), so its scale is
440/// returned unchanged.
441fn reporting_scale(
442 spec: &LikelihoodSpec,
443 scale: &gam_problem::types::LikelihoodScaleMetadata,
444 phi: f64,
445) -> gam_problem::types::LikelihoodScaleMetadata {
446 use gam_problem::types::{LikelihoodScaleMetadata, ResponseFamily};
447 match spec.response {
448 ResponseFamily::Gaussian => match scale.fixed_phi() {
449 Some(p) if p.is_finite() && p > 0.0 => {
450 LikelihoodScaleMetadata::FixedDispersion { phi: p }
451 }
452 _ if phi.is_finite() && phi > 0.0 => LikelihoodScaleMetadata::FixedDispersion { phi },
453 _ => scale.clone(),
454 },
455 _ => scale.clone(),
456 }
457}
458
459/// Number of estimated dispersion / scale parameters a family contributes to the
460/// conditional-AIC degrees of freedom (`2·(edf + scale_dof)`, #1583).
461///
462/// Gaussian profiles σ̂² (one extra dof) unless φ was user-fixed; Gamma / Beta /
463/// Tweedie / Negative-Binomial add one only when their dispersion is *estimated*
464/// from data; Poisson and Binomial carry φ ≡ 1 and add none.
465fn scale_parameter_count(
466 spec: &LikelihoodSpec,
467 scale: &gam_problem::types::LikelihoodScaleMetadata,
468) -> f64 {
469 use gam_problem::types::{LikelihoodScaleMetadata, ResponseFamily};
470 let estimated = match spec.response {
471 ResponseFamily::Gaussian => {
472 !matches!(scale, LikelihoodScaleMetadata::FixedDispersion { .. })
473 }
474 ResponseFamily::Gamma => {
475 matches!(scale, LikelihoodScaleMetadata::EstimatedGammaShape { .. })
476 }
477 ResponseFamily::Beta { .. } => {
478 matches!(scale, LikelihoodScaleMetadata::EstimatedBetaPhi { .. })
479 }
480 ResponseFamily::Tweedie { .. } => {
481 matches!(scale, LikelihoodScaleMetadata::EstimatedTweediePhi { .. })
482 }
483 ResponseFamily::NegativeBinomial { .. } => {
484 matches!(scale, LikelihoodScaleMetadata::EstimatedNegBinTheta { .. })
485 }
486 ResponseFamily::Poisson | ResponseFamily::Binomial | ResponseFamily::RoystonParmar => false,
487 };
488 if estimated { 1.0 } else { 0.0 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use ndarray::{Array2, array};
495
496 #[test]
497 fn wps_correction_is_trace_of_h_f_sigma_over_phi() {
498 // X'WX = I, φ = 2 → correction is tr(X'WX·corr)/φ = tr(corr)/φ.
499 let xwx = Array2::<f64>::eye(3);
500 let corr = array![[2.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 6.0]];
501 let edf = corrected_edf(3.0, Some(xwx.view()), Some(corr.view()), 2.0);
502 // tr(corr)/φ = (2+4+6)/2 = 6, so corrected = 3 + 6 = 9, ρ-df = 6.
503 assert!((edf.corrected - 9.0).abs() < 1e-12);
504 assert!((edf.rho_uncertainty_df() - 6.0).abs() < 1e-12);
505 assert!((edf.conditional - 3.0).abs() < 1e-12);
506 }
507
508 #[test]
509 fn corrected_edf_falls_back_to_conditional_without_inputs() {
510 let edf = corrected_edf(5.5, None, None, 1.0);
511 assert_eq!(edf.conditional, 5.5);
512 assert_eq!(edf.corrected, 5.5);
513 assert_eq!(edf.rho_uncertainty_df(), 0.0);
514 }
515
516 #[test]
517 fn alo_elpd_sums_pointwise_and_flags_no_tail() {
518 // Identical fitted and LOO log-densities → all importance ratios 1,
519 // elpd = Σ ℓ₋ᵢ.
520 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];
521 let loo = alo_elpd(ll.view(), ll.view()).expect("alo elpd");
522 let expected: f64 = ll.iter().sum();
523 assert!((loo.elpd - expected).abs() < 1e-9);
524 assert_eq!(loo.pointwise.len(), ll.len());
525 // No spread in importance ratios → k̂ finite, no bad points expected.
526 assert_eq!(loo.n_k_bad, 0);
527 }
528
529 #[test]
530 fn alo_elpd_pointwise_is_local_to_alo_loglikelihoods() {
531 let ll_loo: Array1<f64> = array![
532 -1.0, -1.1, -1.2, -1.3, -1.4, -1.5, -1.6, -1.7, -1.8, -1.9, -2.0, -2.1
533 ];
534 let ll_hat = ll_loo.clone();
535 let mut ll_hat_perturbed = ll_loo.clone();
536 ll_hat_perturbed[7] += 10.0;
537
538 let base = alo_elpd(ll_hat.view(), ll_loo.view()).expect("alo elpd");
539 let perturbed = alo_elpd(ll_hat_perturbed.view(), ll_loo.view()).expect("alo elpd");
540
541 for i in 0..ll_loo.len() {
542 assert_eq!(base.pointwise[i], ll_loo[i]);
543 assert_eq!(perturbed.pointwise[i], ll_loo[i]);
544 if i != 7 {
545 assert_eq!(base.pointwise[i], perturbed.pointwise[i]);
546 }
547 }
548 assert_eq!(perturbed.elpd, base.elpd);
549 }
550
551 fn gpd_sample(u: f64, k: f64, sigma: f64) -> f64 {
552 sigma * ((1.0 - u).powf(-k) - 1.0) / k
553 }
554
555 #[test]
556 fn alo_elpd_influence_diagnostic_fires_on_heavy_tailed_ratios() {
557 let mut ratios = vec![1.0; 200];
558 for i in 1..=120 {
559 let u = (i as f64 - 0.5) / 120.0;
560 ratios.push(1.0 + gpd_sample(u, 1.2, 0.5));
561 }
562 let ll_loo: Array1<f64> = Array1::from_elem(ratios.len(), -1.0);
563 let ll_hat: Array1<f64> = Array1::from_iter(
564 ll_loo
565 .iter()
566 .zip(ratios.iter())
567 .map(|(&ll, &ratio)| ll + ratio.ln()),
568 );
569
570 let loo = alo_elpd(ll_hat.view(), ll_loo.view()).expect("alo elpd");
571
572 assert_eq!(loo.pointwise, ll_loo);
573 assert!((loo.elpd - -(ratios.len() as f64)).abs() < 1e-12);
574 assert!(
575 loo.k_hat_max > 0.7,
576 "heavy fitted-vs-ALO ratio tail should fire influence diagnostic; got k_hat={}",
577 loo.k_hat_max
578 );
579 assert!(
580 loo.n_k_bad > 0,
581 "heavy fitted-vs-ALO ratio tail should count influential tail observations"
582 );
583 }
584
585 #[test]
586 fn compare_pairs_pointwise_and_orients_a_minus_b() {
587 let mk = |pw: Array1<f64>, aic: f64| ModelComparison {
588 log_lik: 0.0,
589 edf: CorrectedEdf {
590 conditional: 0.0,
591 corrected: 0.0,
592 },
593 aic_conditional: aic,
594 aic_corrected: aic,
595 loo: Some(AloElpd {
596 elpd: pw.iter().sum(),
597 se: 0.0,
598 pointwise: pw,
599 k_hat_max: 0.1,
600 n_k_bad: 0,
601 }),
602 };
603 let a = mk(array![-1.0, -1.0, -1.0, -1.0], 10.0);
604 let b = mk(array![-2.0, -2.0, -2.0, -2.0], 14.0);
605 let rep = compare(&a, &b);
606 assert!(rep.rows_aligned);
607 // a − b: elpd diff = (-4) - (-8) = +4 favours a; aic diff = 10 - 14 = -4 favours a.
608 assert!((rep.delta_elpd - 4.0).abs() < 1e-12);
609 assert!((rep.delta_aic_corrected + 4.0).abs() < 1e-12);
610 assert!(rep.delta_elpd_se.abs() < 1e-12);
611 }
612
613 #[test]
614 fn alo_elpd_se_uses_unbiased_sample_variance() {
615 // Pointwise contributions (0, 2): mean 1, sample variance s² = 2
616 // (denominator n−1 = 1), so SE(Σ) = √(n·s²) = √4 = 2. The population
617 // variance (denominator n) would give √2 instead.
618 let ll: Array1<f64> = array![0.0, 2.0];
619 let loo = alo_elpd(ll.view(), ll.view()).expect("alo elpd");
620 assert!((loo.se - 2.0).abs() < 1e-12, "se = {}", loo.se);
621 }
622
623 #[test]
624 fn compare_se_uses_unbiased_sample_variance_of_paired_differences() {
625 let mk = |pw: Array1<f64>| ModelComparison {
626 log_lik: 0.0,
627 edf: CorrectedEdf {
628 conditional: 0.0,
629 corrected: 0.0,
630 },
631 aic_conditional: 0.0,
632 aic_corrected: 0.0,
633 loo: Some(AloElpd {
634 elpd: pw.iter().sum(),
635 se: 0.0,
636 pointwise: pw,
637 k_hat_max: 0.1,
638 n_k_bad: 0,
639 }),
640 };
641 // Paired differences (0, 2): s² = 2, SE(Σ diff) = √(2·2) = 2.
642 let a = mk(array![0.0, 2.0]);
643 let b = mk(array![0.0, 0.0]);
644 let rep = compare(&a, &b);
645 assert!(rep.rows_aligned);
646 assert!(
647 (rep.delta_elpd_se - 2.0).abs() < 1e-12,
648 "se = {}",
649 rep.delta_elpd_se
650 );
651 }
652
653 #[test]
654 fn compare_refuses_unpaired_rows() {
655 let mk = |pw: Array1<f64>| ModelComparison {
656 log_lik: 0.0,
657 edf: CorrectedEdf {
658 conditional: 0.0,
659 corrected: 0.0,
660 },
661 aic_conditional: 0.0,
662 aic_corrected: 5.0,
663 loo: Some(AloElpd {
664 elpd: pw.iter().sum(),
665 se: 0.0,
666 pointwise: pw,
667 k_hat_max: 0.1,
668 n_k_bad: 0,
669 }),
670 };
671 let a = mk(array![-1.0, -1.0, -1.0]);
672 let b = mk(array![-1.0, -1.0]);
673 let rep = compare(&a, &b);
674 assert!(!rep.rows_aligned);
675 assert!(rep.delta_elpd.is_nan());
676 // AIC gap still reported.
677 assert!((rep.delta_aic_corrected - 0.0).abs() < 1e-12);
678 }
679}