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 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/// 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: Option<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: Option<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 · Σ_ρ)`, when its exact inputs were retained.
61    pub corrected: Option<f64>,
62    /// Typed provenance for an unavailable correction. `None` means either the
63    /// correction is available or `K=0` proved it is exactly zero.
64    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    /// The per-fit measurement the issue calls out: how much λ-uncertainty is
77    /// inflating the user's model-choice complexity penalty, `τ − tr(F)`.
78    pub fn rho_uncertainty_df(&self) -> Option<f64> {
79        self.corrected.map(|value| value - self.conditional)
80    }
81}
82
83/// The full comparison payload reported alongside a fit's evidence headline.
84#[derive(Debug, Clone)]
85pub struct ModelComparison {
86    /// Fully normalized log-likelihood at the converged mode.
87    pub log_lik: f64,
88    /// Conditional and WPS-corrected effective degrees of freedom.
89    pub edf: CorrectedEdf,
90    /// `−2·ℓ + 2·edf_conditional` (treats `λ̂` as known).
91    pub aic_conditional: f64,
92    /// `−2·ℓ + 2·edf_corrected` (Wood–Pya–Säfken).
93    pub aic_corrected: Option<f64>,
94    /// Zero-refit ALO predictive comparison, when ALO diagnostics and the per-row
95    /// family kernel are available.
96    pub loo: Option<AloElpd>,
97}
98
99/// Exact Wood–Pya–Säfken corrected effective degrees of freedom.
100///
101/// `edf_conditional = tr(F)` with `F = H⁻¹X'WX` (the engine's `edf_total`).
102/// The correction term is `tr(X'WX · C) / s`, where `C` is the retained
103/// coefficient-covariance correction and `s` is the coefficient-covariance
104/// ownership scale (`V_beta = s H⁻¹`). The engine stores the genuine
105/// symmetric-PSD weighted Gram `X'WX = H − S(λ)` directly on the fit
106/// ([`UnifiedFitResult::weighted_gram`], issue #1027) — pairing it with
107/// `C` makes the correction the nonnegative `tr(A½ B A½)` it is defined to
108/// be, instead of the indefinite `H·F`
109/// reconstruction (where the stored `H` need not satisfy `H·F = X'WX`) that
110/// drove the corrected EDF below the conditional EDF.
111///
112/// Missing artifacts or method provenance produce `corrected=None` with a
113/// typed reason; malformed present inputs are errors.
114pub 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
176/// `tr(X'WX · C) / s` with `X'WX` and `C` PSD and `s` the explicit
177/// coefficient-covariance scale.
178fn 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    // `stable_finite_signed_sum` is Neumaier-compensated, so its forward error
216    // is `2u·Σ|x|` regardless of how many terms it was given; scaling the band
217    // by the term count applies the naive-summation model to the very algorithm
218    // chosen to defeat it, and was `k²/5` too wide (4000× at k = 100). Each
219    // term above costs three roundings to build — two divisions and a multiply.
220    //
221    // Widening does not make a valid input safer: `X'WX` and `C` are PSD, so
222    // `tr(X'WX·C) ≥ 0` exactly, and the computed value therefore cannot fall
223    // below `-roundoff` for any admissible input. All the extra width did was
224    // suppress the report below when one of the two is genuinely indefinite,
225    // silently returning a zero correction in place of an error.
226    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
250/// ALO elpd from ALO-corrected leave-one-out predictions.
251///
252/// `loglik_fitted` and `loglik_loo` are the per-observation log predictive
253/// densities at the *fitted* (`η̂`) and *ALO leave-one-out* (`η̃₋ᵢ`) linear
254/// predictors respectively. The returned elpd is the honest ALO estimand
255/// `Σᵢ loglik_loo[i]`; each pointwise contribution is exactly `loglik_loo[i]`.
256///
257/// The raw fitted-vs-ALO ratio for observation `i` is
258/// `r_i = exp(ℓ(yᵢ|η̂ᵢ) − ℓ(yᵢ|η̃₋ᵢ))` — large where dropping `i` would have
259/// moved the fit a lot. We fit a GPD tail to this cross-observation ratio vector
260/// only to report an influence diagnostic: `k_hat_max` is the fitted tail shape
261/// and `n_k_bad` is the tail count when `k̂ > 0.7`. This is not draw-wise
262/// PSIS-LOO: there is no posterior-draw dimension, the Pareto fit is across
263/// observations, and the diagnostic never changes elpd.
264///
265/// Invalid or unrepresentable inputs are rejected explicitly. If the optional
266/// influence-tail fit is unavailable, `k_hat_max` is `None` and `n_k_bad` is
267/// zero; that diagnostic absence does not alter the certified elpd.
268pub 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    // Cross-observation influence ratios r_i = p(y_i|η̂_i) / p(y_i|η̃₋ᵢ).
317    // Stabilize by subtracting the max log-ratio before exponentiating; the
318    // multiplicative constant does not change the fitted GPD shape.
319    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    // SE of the sum of n pointwise contributions: √(n·s²) with the unbiased
337    // sample variance (denominator n−1). Undefined for a single observation.
338    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/// Result of comparing two fits on the same response: the paired predictive
375/// difference with its standard error plus the
376/// corrected-AIC gap. Both differences are oriented `a − b`: positive `delta_elpd`
377/// favours `a`, negative `delta_aic_corrected` favours `a`.
378#[derive(Debug, Clone)]
379pub struct ComparisonReport {
380    /// `Σᵢ (elpd_aᵢ − elpd_bᵢ)`; positive favours `a`.
381    pub delta_elpd: Option<f64>,
382    /// SE of `delta_elpd` from the pointwise paired differences,
383    /// `√(n · Var(elpd_aᵢ − elpd_bᵢ))`.
384    pub delta_elpd_se: Option<f64>,
385    /// `AIC_corrected(a) − AIC_corrected(b)`; negative favours `a`.
386    pub delta_aic_corrected: Option<f64>,
387    /// `false` when the two fits have a different number of observations and the
388    /// paired predictive difference could not be formed; paired metrics are
389    /// then `None`.
390    pub rows_aligned: bool,
391}
392
393/// Paired comparison of two fits. The predictive difference is paired
394/// row-by-row, so the two fits must have been computed on
395/// the same response in the same order; we refuse the paired difference when the
396/// observation counts disagree and surface only the AIC gap.
397pub 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            // Unbiased sample variance (n−1) of the paired differences; the SE
433            // of the summed difference is √(n·s²).
434            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
477/// Assemble the comparison payload for a fitted GLM/GAM from the fit result plus
478/// optional ALO leave-one-out predictor coordinates.
479///
480/// Corrected AIC is populated only with retained, method-certified correction
481/// provenance. The ALO elpd channel is populated when `alo_eta_tilde` is
482/// supplied and the fit carries an engine-level family; both predictors are
483/// scored directly in eta coordinates. Taking the sole coordinate consumed by
484/// this calculation keeps model comparison independent of any particular ALO
485/// result schema (scalar or multi-coordinate).
486///
487/// `eta_hat` is the *fitted* linear predictor (including offset) and `y` the
488/// response, both length `n`.
489pub 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    // The WPS correction is reported as exact only under the typed provenance
517    // the optimizer retained with the correction itself: first-order IFT on the
518    // identified outer-Hessian subspace. SigmaPointCubature is a named
519    // approximation and must stay out of the exact channel.
520    //
521    // Read the RETAINED first-order pair, not the fit's primary
522    // `smoothing_correction()`/`smoothing_correction_method()`: the optimizer's
523    // auto-selector escalates the primary pair to a cubature upgrade exactly
524    // when smoothing-parameter uncertainty is large enough to matter (rho
525    // posterior variance over threshold, near-boundary, or high outer
526    // gradient) — precisely the regime this correction exists to report on.
527    // Gating on the primary pair made this channel `None` whenever the
528    // correction would have been large enough to be interesting and `Some`
529    // only when it was small enough that first-order alone was already
530    // deemed adequate (#946). `compute_smoothing_correction_auto` always
531    // computes the exact first-order correction before deciding whether to
532    // escalate, and the optimizer now retains it alongside the cubature
533    // upgrade rather than discarding it, so this channel is populated
534    // whenever the first-order geometry was computable at all, independent
535    // of whether cubature also ran for some other consumer's benefit.
536    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    // The user-facing `log_likelihood` (and the AIC / elpd derived from it) must
550    // be the *fully normalized, scale-aware* absolute log-likelihood — not the
551    // REML building block stored on the fit, which deliberately drops every
552    // family- and saturated-likelihood normalizing constant and the Gaussian
553    // scale (#1581/#1582/#1583). Recompute it here at the fitted means with the
554    // profiled Gaussian scale concretized into σ̂². For custom / GAMLSS fits with
555    // no engine-level family there is no per-row kernel to call, so we fall back
556    // to the stored value (those paths supply their own normalized log-lik).
557    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        // Custom/GAMLSS engines own their normalized likelihood and do not
562        // advertise an engine-level GLM family. Their stored value is therefore
563        // authoritative, not a fallback from a failed GLM evaluation.
564        fit.log_likelihood
565    };
566
567    // An estimated / profiled dispersion is a fitted parameter and adds one
568    // degree of freedom to the conditional AIC — mgcv's `2·(edf + 1)` for a
569    // scale-estimated family (#1583). Fixed-scale families (Poisson, Binomial,
570    // user-fixed φ/θ) add none.
571    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
606/// ALO elpd for an engine-level family, evaluated directly at the fitted and
607/// leave-one-out linear predictors. No eta-to-mean-to-eta round trip is allowed:
608/// doing so rounds representable tail predictors onto boundary means and
609/// desynchronizes comparison values from the likelihood score surface.
610pub 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    // The PSIS-LOO `elpd` reported to the user is an *absolute* log predictive
625    // density, so it must use the fully normalized, scale-aware kernel (the
626    // profiled Gaussian scale is concretized by the caller). The dropped
627    // constants are identical for the fitted and LOO evaluations of a row (they
628    // depend only on yᵢ and the scale, not on μ), so the PSIS importance ratios
629    // r_i = exp(ℓ̂_i − ℓ_loo,i) — and hence k̂ — are unchanged; only the absolute
630    // elpd is corrected (#1581/#1582/#1583).
631    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
636/// Total fully-normalized log-likelihood at the fitted linear predictor
637/// `eta_hat`, without materializing a fitted-mean surrogate.
638fn 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
655/// Concretize the response-scale metadata for the *reporting* log-likelihood.
656///
657/// The profiled Gaussian carries no fixed scale (`ProfiledGaussian`), so its
658/// predictive density would silently collapse to the unit-variance form. Here we
659/// resolve the estimated residual variance `σ̂² = phi` into a concrete
660/// `FixedDispersion`, so the reporting kernel scores the density on the right
661/// measure and obeys the change-of-variables law (#1583). An explicitly fixed φ
662/// is honored as-is; every other family already carries the parameters its
663/// density needs (Beta φ, NB θ, Gamma shape, Tweedie φ), so its scale is
664/// returned unchanged.
665fn 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
683/// Number of estimated dispersion / scale parameters a family contributes to the
684/// conditional-AIC degrees of freedom (`2·(edf + scale_dof)`, #1583).
685///
686/// Gaussian profiles σ̂² (one extra dof) unless φ was user-fixed; Gamma / Beta /
687/// Tweedie / Negative-Binomial add one only when their dispersion is *estimated*
688/// from data; Poisson and Binomial carry φ ≡ 1 and add none.
689fn 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        // X'WX = I, φ = 2 → correction is tr(X'WX·corr)/φ = tr(corr)/φ.
723        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        // tr(corr)/φ = (2+4+6)/2 = 6, so corrected = 3 + 6 = 9, ρ-df = 6.
728        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        // Identical fitted and LOO log-densities → all importance ratios 1,
748        // elpd = Σ ℓ₋ᵢ.
749        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        // No spread in importance ratios → k̂ finite, no bad points expected.
755        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        // a − b: elpd diff = (-4) - (-8) = +4 favours a; aic diff = 10 - 14 = -4 favours a.
838        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        // Pointwise contributions (0, 2): mean 1, sample variance s² = 2
846        // (denominator n−1 = 1), so SE(Σ) = √(n·s²) = √4 = 2. The population
847        // variance (denominator n) would give √2 instead.
848        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        // Paired differences (0, 2): s² = 2, SE(Σ diff) = √(2·2) = 2.
873        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        // AIC gap still reported.
909        assert_eq!(rep.delta_aic_corrected, Some(0.0));
910    }
911
912    /// #946 regression: a fit whose PRIMARY smoothing correction escalated to
913    /// sigma-point cubature (a named approximation) must still populate the
914    /// EXACT corrected-EDF/AIC channel, reading the separately-retained
915    /// first-order pair instead of going dark. Before this fix,
916    /// `model_comparison_from_unified` gated `corrected`/`aic_corrected` on
917    /// `fit.smoothing_correction_method()` (the PRIMARY method), so any fit
918    /// whose auto-selector chose cubature — precisely the regime with
919    /// non-trivial smoothing-parameter uncertainty the correction exists to
920    /// report on — silently reported `MissingMethodProvenance` instead of the
921    /// exact first-order estimate that was computed and available all along.
922    #[test]
923    fn corrected_edf_uses_retained_first_order_pair_when_primary_method_is_cubature() {
924        use gam_problem::{LikelihoodScaleMetadata, LogLikelihoodNormalization};
925        use gam_solve::model_types::{
926            Dispersion, FitArtifacts, FitInference, FittedBlock, FittedLinkState,
927            UnifiedFitResultParts,
928        };
929        use gam_solve::pirls::PirlsStatus;
930
931        // Distinct matrices for the primary (cubature) vs retained (first-order)
932        // corrections so the test can prove which one the channel actually used.
933        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            training_sample_size: 3,
945            log_lambdas: array![2.0_f64.ln()],
946            lambdas: array![2.0],
947            likelihood_family: Some(LikelihoodSpec::gaussian_identity()),
948            likelihood_scale: LikelihoodScaleMetadata::ProfiledGaussian,
949            log_likelihood_normalization: LogLikelihoodNormalization::Full,
950            log_likelihood: -1.2,
951            deviance: 2.4,
952            reml_score: Some(0.7),
953            stable_penalty_term: 0.3,
954            penalized_objective: Some(2.2),
955            used_device: false,
956            // `outer_iterations: 0` sidesteps the analytic-certificate
957            // requirement in `UnifiedFitResult::try_from_parts` (a fit is
958            // certified either by a passing criterion certificate, or
959            // trivially when the outer loop never ran) — irrelevant to what
960            // this test exercises (the corrected-EDF wiring), so the simplest
961            // valid fixture is used.
962            outer_iterations: 0,
963            outer_converged: true,
964            outer_gradient_norm: None,
965            // 1.0 so the ProfiledGaussian coefficient-covariance scale
966            // (= standard_deviation^2 = dispersion_phi()) is exactly 1.0,
967            // keeping the hand-checked ρ-uncertainty arithmetic below simple.
968            standard_deviation: 1.0,
969            covariance_conditional: Some(array![[1.0, 0.1], [0.1, 2.0]]),
970            covariance_corrected: None,
971            inference: Some(FitInference {
972                // One fitted smoothing parameter means one retained EDF
973                // contribution. The previous two-entry vector disagreed with
974                // both `lambdas` and the sole fitted block, so the typed fit
975                // constructor correctly rejected the fixture before the
976                // corrected-EDF path under test could run.
977                edf_by_block: vec![1.5],
978                penalty_block_trace: vec![],
979                edf_total: 1.5,
980                // PRIMARY: escalated to a cubature upgrade.
981                smoothing_correction: Some(cubature_correction.clone()),
982                smoothing_correction_method: Some(SmoothingCorrectionMethod::SigmaPointCubature {
983                    rank: 1,
984                    n_points: 2,
985                    // The nodes this correction was built from sat at the
986                    // criterion level a one-sigma node is asserted to occupy
987                    // (#2728). A fixture value, since nothing here integrates.
988                    max_node_criterion_rise: 0.5,
989                }),
990                // RETAINED: the exact first-order correction computed before
991                // the escalation decision, never discarded (#946).
992                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        // Sanity: the fixture's PRIMARY method really is cubature, and the
1025        // RETAINED first-order method really is distinct from it.
1026        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 =
1039            model_comparison_from_unified(&fit, y.view(), eta_hat.view(), weights.view(), None)
1040                .expect("construct comparison for the cubature-vs-first-order fixture");
1041
1042        let corrected = cmp
1043            .edf
1044            .corrected
1045            .expect("corrected EDF must be Some even though the PRIMARY method is cubature");
1046        let aic_corrected = cmp
1047            .aic_corrected
1048            .expect("corrected AIC must be Some even though the PRIMARY method is cubature");
1049
1050        // The channel must have used the RETAINED first-order correction
1051        // (0.4 on the diagonal), not the primary cubature one (9.0): the
1052        // ρ-uncertainty contribution is tr(weighted_gram · first_order_correction)
1053        // / covariance_scale = tr(I · 0.4I) / 1.0 = 0.8, so corrected =
1054        // conditional (edf_total=1.5) + 0.8 = 2.3 — NOT conditional + 18.0
1055        // (which the cubature matrix would have produced).
1056        let rho_uncertainty_df = cmp
1057            .edf
1058            .rho_uncertainty_df()
1059            .expect("rho-uncertainty df must be Some");
1060        assert!(
1061            (rho_uncertainty_df - 0.8).abs() < 1e-9,
1062            "expected the retained first-order correction's contribution (0.8), got {rho_uncertainty_df}"
1063        );
1064        assert!(
1065            (corrected - 2.3).abs() < 1e-9,
1066            "corrected EDF must equal conditional + the first-order contribution, got {corrected}"
1067        );
1068        assert!(
1069            aic_corrected.is_finite(),
1070            "corrected AIC must be finite, got {aic_corrected}"
1071        );
1072    }
1073}