Skip to main content

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}