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    let operations = normalized_terms.len() as f64;
216    let roundoff = operations * f64::EPSILON * absolute_sum;
217    if normalized < 0.0 {
218        if normalized >= -roundoff {
219            normalized = 0.0;
220        } else {
221            return Err(EstimationError::InvalidInput(format!(
222                "WPS PSD trace is negative beyond roundoff: normalized={normalized}, bound={roundoff}"
223            )));
224        }
225    }
226    if normalized == 0.0 {
227        return Ok(0.0);
228    }
229    let log_value = normalized.ln() + max_x.ln() + max_c.ln() - covariance_scale.ln();
230    let value = log_value.exp();
231    if value.is_finite() {
232        Ok(value)
233    } else {
234        Err(EstimationError::InvalidInput(
235            "WPS correction is outside f64 range".into(),
236        ))
237    }
238}
239
240/// ALO elpd from ALO-corrected leave-one-out predictions.
241///
242/// `loglik_fitted` and `loglik_loo` are the per-observation log predictive
243/// densities at the *fitted* (`η̂`) and *ALO leave-one-out* (`η̃₋ᵢ`) linear
244/// predictors respectively. The returned elpd is the honest ALO estimand
245/// `Σᵢ loglik_loo[i]`; each pointwise contribution is exactly `loglik_loo[i]`.
246///
247/// The raw fitted-vs-ALO ratio for observation `i` is
248/// `r_i = exp(ℓ(yᵢ|η̂ᵢ) − ℓ(yᵢ|η̃₋ᵢ))` — large where dropping `i` would have
249/// moved the fit a lot. We fit a GPD tail to this cross-observation ratio vector
250/// only to report an influence diagnostic: `k_hat_max` is the fitted tail shape
251/// and `n_k_bad` is the tail count when `k̂ > 0.7`. This is not draw-wise
252/// PSIS-LOO: there is no posterior-draw dimension, the Pareto fit is across
253/// observations, and the diagnostic never changes elpd.
254///
255/// Invalid or unrepresentable inputs are rejected explicitly. If the optional
256/// influence-tail fit is unavailable, `k_hat_max` is `None` and `n_k_bad` is
257/// zero; that diagnostic absence does not alter the certified elpd.
258pub fn alo_elpd(
259    loglik_fitted: ArrayView1<'_, f64>,
260    loglik_loo: ArrayView1<'_, f64>,
261) -> Result<AloElpd, EstimationError> {
262    let reduction_values: Vec<f64> = loglik_loo.iter().copied().collect();
263    let elpd = gam_solve::pirls::stable_finite_signed_sum(&reduction_values, "ALO elpd reduction")?;
264    alo_elpd_with_total(loglik_fitted, loglik_loo, elpd)
265}
266
267fn alo_elpd_with_total(
268    loglik_fitted: ArrayView1<'_, f64>,
269    loglik_loo: ArrayView1<'_, f64>,
270    elpd: f64,
271) -> Result<AloElpd, EstimationError> {
272    let n = loglik_loo.len();
273    if n == 0 {
274        return Err(EstimationError::InvalidInput(
275            "ALO requires at least one observation".into(),
276        ));
277    }
278    if loglik_fitted.len() != n {
279        return Err(EstimationError::InvalidInput(format!(
280            "ALO likelihood length mismatch: fitted={}, loo={n}",
281            loglik_fitted.len()
282        )));
283    }
284    if !elpd.is_finite() {
285        return Err(EstimationError::InvalidInput(format!(
286            "ALO elpd total is non-finite: {elpd}"
287        )));
288    }
289    let mut log_ratio = Array1::zeros(n);
290    for row in 0..n {
291        let fitted = loglik_fitted[row];
292        let loo = loglik_loo[row];
293        if !fitted.is_finite() || !loo.is_finite() {
294            return Err(EstimationError::InvalidInput(format!(
295                "ALO non-finite log-likelihood at row {row}: fitted={fitted}, loo={loo}"
296            )));
297        }
298        let ratio = fitted - loo;
299        if !ratio.is_finite() {
300            return Err(EstimationError::InvalidInput(format!(
301                "ALO log influence ratio is outside f64 range at row {row}: fitted={fitted}, loo={loo}"
302            )));
303        }
304        log_ratio[row] = ratio;
305    }
306    // Cross-observation influence ratios r_i = p(y_i|η̂_i) / p(y_i|η̃₋ᵢ).
307    // Stabilize by subtracting the max log-ratio before exponentiating; the
308    // multiplicative constant does not change the fitted GPD shape.
309    let max_lr = log_ratio.iter().copied().fold(f64::NEG_INFINITY, f64::max);
310    let raw: Vec<f64> = log_ratio.iter().map(|&lr| (lr - max_lr).exp()).collect();
311
312    let (k_hat_max, n_k_bad);
313    match pareto_smooth_weights(&raw) {
314        Some(psis) => {
315            k_hat_max = Some(psis.k_hat);
316            n_k_bad = if psis.k_hat > 0.7 { psis.tail_count } else { 0 };
317        }
318        None => {
319            k_hat_max = None;
320            n_k_bad = 0;
321        }
322    }
323
324    let pointwise = loglik_loo.to_owned();
325    let mean = elpd / n as f64;
326    // SE of the sum of n pointwise contributions: √(n·s²) with the unbiased
327    // sample variance (denominator n−1). Undefined for a single observation.
328    let se = if n > 1 {
329        let max_deviation = pointwise
330            .iter()
331            .map(|&value| (value - mean).abs())
332            .fold(0.0_f64, f64::max);
333        if max_deviation == 0.0 {
334            Some(0.0)
335        } else {
336            let scaled_sum_squares: f64 = pointwise
337                .iter()
338                .map(|&value| {
339                    let scaled = (value - mean) / max_deviation;
340                    scaled * scaled
341                })
342                .sum();
343            let multiplier = (n as f64 * scaled_sum_squares / (n - 1) as f64).sqrt();
344            let value = max_deviation * multiplier;
345            if !value.is_finite() {
346                return Err(EstimationError::InvalidInput(
347                    "ALO standard error is outside f64 range".into(),
348                ));
349            }
350            Some(value)
351        }
352    } else {
353        None
354    };
355    Ok(AloElpd {
356        elpd,
357        se,
358        pointwise,
359        k_hat_max,
360        n_k_bad,
361    })
362}
363
364/// Result of comparing two fits on the same response: the paired predictive
365/// difference with its standard error plus the
366/// corrected-AIC gap. Both differences are oriented `a − b`: positive `delta_elpd`
367/// favours `a`, negative `delta_aic_corrected` favours `a`.
368#[derive(Debug, Clone)]
369pub struct ComparisonReport {
370    /// `Σᵢ (elpd_aᵢ − elpd_bᵢ)`; positive favours `a`.
371    pub delta_elpd: Option<f64>,
372    /// SE of `delta_elpd` from the pointwise paired differences,
373    /// `√(n · Var(elpd_aᵢ − elpd_bᵢ))`.
374    pub delta_elpd_se: Option<f64>,
375    /// `AIC_corrected(a) − AIC_corrected(b)`; negative favours `a`.
376    pub delta_aic_corrected: Option<f64>,
377    /// `false` when the two fits have a different number of observations and the
378    /// paired predictive difference could not be formed; paired metrics are
379    /// then `None`.
380    pub rows_aligned: bool,
381}
382
383/// Paired comparison of two fits. The predictive difference is paired
384/// row-by-row, so the two fits must have been computed on
385/// the same response in the same order; we refuse the paired difference when the
386/// observation counts disagree and surface only the AIC gap.
387pub fn compare(
388    a: &ModelComparison,
389    b: &ModelComparison,
390) -> Result<ComparisonReport, EstimationError> {
391    let delta_aic_corrected = match (a.aic_corrected, b.aic_corrected) {
392        (Some(left), Some(right)) => {
393            let difference = left - right;
394            if !difference.is_finite() {
395                return Err(EstimationError::InvalidInput(
396                    "corrected-AIC difference is outside f64 range".into(),
397                ));
398            }
399            Some(difference)
400        }
401        _ => None,
402    };
403    match (&a.loo, &b.loo) {
404        (Some(la), Some(lb))
405            if la.pointwise.len() == lb.pointwise.len() && !la.pointwise.is_empty() =>
406        {
407            let n = la.pointwise.len();
408            let mut diff = Array1::zeros(n);
409            for row in 0..n {
410                let value = la.pointwise[row] - lb.pointwise[row];
411                if !value.is_finite() {
412                    return Err(EstimationError::InvalidInput(format!(
413                        "paired elpd difference is outside f64 range at row {row}"
414                    )));
415                }
416                diff[row] = value;
417            }
418            let values: Vec<f64> = diff.iter().copied().collect();
419            let delta_elpd =
420                gam_solve::pirls::stable_finite_signed_sum(&values, "paired elpd reduction")?;
421            let mean = delta_elpd / n as f64;
422            // Unbiased sample variance (n−1) of the paired differences; the SE
423            // of the summed difference is √(n·s²).
424            let se = if n > 1 {
425                let max_deviation = diff
426                    .iter()
427                    .map(|&value| (value - mean).abs())
428                    .fold(0.0_f64, f64::max);
429                if max_deviation == 0.0 {
430                    Some(0.0)
431                } else {
432                    let scaled_sum_squares: f64 = diff
433                        .iter()
434                        .map(|&value| {
435                            let scaled = (value - mean) / max_deviation;
436                            scaled * scaled
437                        })
438                        .sum();
439                    let multiplier = (n as f64 * scaled_sum_squares / (n - 1) as f64).sqrt();
440                    let value = max_deviation * multiplier;
441                    if !value.is_finite() {
442                        return Err(EstimationError::InvalidInput(
443                            "paired elpd standard error is outside f64 range".into(),
444                        ));
445                    }
446                    Some(value)
447                }
448            } else {
449                None
450            };
451            Ok(ComparisonReport {
452                delta_elpd: Some(delta_elpd),
453                delta_elpd_se: se,
454                delta_aic_corrected,
455                rows_aligned: true,
456            })
457        }
458        _ => Ok(ComparisonReport {
459            delta_elpd: None,
460            delta_elpd_se: None,
461            delta_aic_corrected,
462            rows_aligned: false,
463        }),
464    }
465}
466
467/// Assemble the comparison payload for a fitted GLM/GAM from the fit result plus
468/// optional ALO leave-one-out predictor coordinates.
469///
470/// Corrected AIC is populated only with retained, method-certified correction
471/// provenance. The ALO elpd channel is populated when `alo_eta_tilde` is
472/// supplied and the fit carries an engine-level family; both predictors are
473/// scored directly in eta coordinates. Taking the sole coordinate consumed by
474/// this calculation keeps model comparison independent of any particular ALO
475/// result schema (scalar or multi-coordinate).
476///
477/// `eta_hat` is the *fitted* linear predictor (including offset) and `y` the
478/// response, both length `n`.
479pub fn model_comparison_from_unified(
480    fit: &UnifiedFitResult,
481    y: ArrayView1<'_, f64>,
482    eta_hat: ArrayView1<'_, f64>,
483    prior_weights: ArrayView1<'_, f64>,
484    alo_eta_tilde: Option<ArrayView1<'_, f64>>,
485) -> Result<ModelComparison, EstimationError> {
486    let phi = fit.dispersion_phi()?;
487    let edf_conditional = fit.edf_total().ok_or_else(|| {
488        EstimationError::InvalidInput("model comparison requires a retained conditional EDF".into())
489    })?;
490    let covariance_scale = fit
491        .likelihood_family
492        .as_ref()
493        .map(|spec| {
494            GlmLikelihoodSpec {
495                spec: spec.clone(),
496                scale: fit.likelihood_scale,
497            }
498            .coefficient_covariance_scale(phi)
499            .map_err(|error| {
500                EstimationError::InvalidInput(format!(
501                    "model-comparison coefficient covariance scale: {error}"
502                ))
503            })
504        })
505        .transpose()?;
506    // The WPS correction is reported as exact only under the typed provenance
507    // the optimizer retained with the correction itself: first-order IFT on the
508    // identified outer-Hessian subspace. SigmaPointCubature is a named
509    // approximation and must stay out of the exact channel.
510    //
511    // Read the RETAINED first-order pair, not the fit's primary
512    // `smoothing_correction()`/`smoothing_correction_method()`: the optimizer's
513    // auto-selector escalates the primary pair to a cubature upgrade exactly
514    // when smoothing-parameter uncertainty is large enough to matter (rho
515    // posterior variance over threshold, near-boundary, or high outer
516    // gradient) — precisely the regime this correction exists to report on.
517    // Gating on the primary pair made this channel `None` whenever the
518    // correction would have been large enough to be interesting and `Some`
519    // only when it was small enough that first-order alone was already
520    // deemed adequate (#946). `compute_smoothing_correction_auto` always
521    // computes the exact first-order correction before deciding whether to
522    // escalate, and the optimizer now retains it alongside the cubature
523    // upgrade rather than discarding it, so this channel is populated
524    // whenever the first-order geometry was computable at all, independent
525    // of whether cubature also ran for some other consumer's benefit.
526    let method_certified_exact = matches!(
527        fit.smoothing_correction_method_first_order(),
528        Some(SmoothingCorrectionMethod::FirstOrderIdentifiedSubspace { .. })
529    );
530    let edf = corrected_edf(
531        edf_conditional,
532        fit.weighted_gram().map(|g| g.view()),
533        fit.smoothing_correction_first_order().map(|c| c.view()),
534        covariance_scale,
535        fit.log_lambdas.len(),
536        method_certified_exact,
537    )?;
538
539    // The user-facing `log_likelihood` (and the AIC / elpd derived from it) must
540    // be the *fully normalized, scale-aware* absolute log-likelihood — not the
541    // REML building block stored on the fit, which deliberately drops every
542    // family- and saturated-likelihood normalizing constant and the Gaussian
543    // scale (#1581/#1582/#1583). Recompute it here at the fitted means with the
544    // profiled Gaussian scale concretized into σ̂². For custom / GAMLSS fits with
545    // no engine-level family there is no per-row kernel to call, so we fall back
546    // to the stored value (those paths supply their own normalized log-lik).
547    let log_lik = if let Some(spec) = fit.likelihood_family.as_ref() {
548        let scale = reporting_scale(spec, &fit.likelihood_scale, phi);
549        full_loglikelihood_at_eta(y, eta_hat, prior_weights, spec, scale)?
550    } else {
551        // Custom/GAMLSS engines own their normalized likelihood and do not
552        // advertise an engine-level GLM family. Their stored value is therefore
553        // authoritative, not a fallback from a failed GLM evaluation.
554        fit.log_likelihood
555    };
556
557    // An estimated / profiled dispersion is a fitted parameter and adds one
558    // degree of freedom to the conditional AIC — mgcv's `2·(edf + 1)` for a
559    // scale-estimated family (#1583). Fixed-scale families (Poisson, Binomial,
560    // user-fixed φ/θ) add none.
561    let scale_dof = fit
562        .likelihood_family
563        .as_ref()
564        .map(|spec| scale_parameter_count(spec, &fit.likelihood_scale))
565        .unwrap_or(0.0);
566
567    let aic_conditional = -2.0 * log_lik + 2.0 * (edf.conditional + scale_dof);
568    let aic_corrected = edf
569        .corrected
570        .map(|corrected| -2.0 * log_lik + 2.0 * (corrected + scale_dof));
571
572    let loo = match (alo_eta_tilde, fit.likelihood_family.as_ref()) {
573        (Some(eta_tilde), Some(spec)) => {
574            let scale = reporting_scale(spec, &fit.likelihood_scale, phi);
575            Some(alo_elpd_from_family(
576                y,
577                eta_hat,
578                eta_tilde,
579                prior_weights,
580                spec,
581                scale,
582            )?)
583        }
584        _ => None,
585    };
586
587    Ok(ModelComparison {
588        log_lik,
589        edf,
590        aic_conditional,
591        aic_corrected,
592        loo,
593    })
594}
595
596/// ALO elpd for an engine-level family, evaluated directly at the fitted and
597/// leave-one-out linear predictors. No eta-to-mean-to-eta round trip is allowed:
598/// doing so rounds representable tail predictors onto boundary means and
599/// desynchronizes comparison values from the likelihood score surface.
600pub fn alo_elpd_from_family(
601    y: ArrayView1<'_, f64>,
602    eta_hat: ArrayView1<'_, f64>,
603    eta_loo: ArrayView1<'_, f64>,
604    prior_weights: ArrayView1<'_, f64>,
605    spec: &LikelihoodSpec,
606    scale: gam_problem::types::LikelihoodScaleMetadata,
607) -> Result<AloElpd, EstimationError> {
608    use gam_solve::pirls::evaluate_full_log_likelihood_from_eta;
609
610    let glm = GlmLikelihoodSpec {
611        spec: spec.clone(),
612        scale,
613    };
614    // The PSIS-LOO `elpd` reported to the user is an *absolute* log predictive
615    // density, so it must use the fully normalized, scale-aware kernel (the
616    // profiled Gaussian scale is concretized by the caller). The dropped
617    // constants are identical for the fitted and LOO evaluations of a row (they
618    // depend only on yᵢ and the scale, not on μ), so the PSIS importance ratios
619    // r_i = exp(ℓ̂_i − ℓ_loo,i) — and hence k̂ — are unchanged; only the absolute
620    // elpd is corrected (#1581/#1582/#1583).
621    let ll_hat = evaluate_full_log_likelihood_from_eta(y, eta_hat, &glm, prior_weights)?;
622    let ll_loo = evaluate_full_log_likelihood_from_eta(y, eta_loo, &glm, prior_weights)?;
623    alo_elpd_with_total(ll_hat.pointwise(), ll_loo.pointwise(), ll_loo.total())
624}
625
626/// Total fully-normalized log-likelihood at the fitted linear predictor
627/// `eta_hat`, without materializing a fitted-mean surrogate.
628fn full_loglikelihood_at_eta(
629    y: ArrayView1<'_, f64>,
630    eta_hat: ArrayView1<'_, f64>,
631    prior_weights: ArrayView1<'_, f64>,
632    spec: &LikelihoodSpec,
633    scale: gam_problem::types::LikelihoodScaleMetadata,
634) -> Result<f64, EstimationError> {
635    use gam_solve::pirls::evaluate_full_log_likelihood_from_eta;
636
637    let glm = GlmLikelihoodSpec {
638        spec: spec.clone(),
639        scale,
640    };
641    evaluate_full_log_likelihood_from_eta(y, eta_hat, &glm, prior_weights)
642        .map(|evaluation| evaluation.total())
643}
644
645/// Concretize the response-scale metadata for the *reporting* log-likelihood.
646///
647/// The profiled Gaussian carries no fixed scale (`ProfiledGaussian`), so its
648/// predictive density would silently collapse to the unit-variance form. Here we
649/// resolve the estimated residual variance `σ̂² = phi` into a concrete
650/// `FixedDispersion`, so the reporting kernel scores the density on the right
651/// measure and obeys the change-of-variables law (#1583). An explicitly fixed φ
652/// is honored as-is; every other family already carries the parameters its
653/// density needs (Beta φ, NB θ, Gamma shape, Tweedie φ), so its scale is
654/// returned unchanged.
655fn reporting_scale(
656    spec: &LikelihoodSpec,
657    scale: &gam_problem::types::LikelihoodScaleMetadata,
658    phi: f64,
659) -> gam_problem::types::LikelihoodScaleMetadata {
660    use gam_problem::types::{LikelihoodScaleMetadata, ResponseFamily};
661    match spec.response {
662        ResponseFamily::Gaussian => match *scale {
663            fixed @ LikelihoodScaleMetadata::FixedDispersion { .. } => fixed,
664            LikelihoodScaleMetadata::ProfiledGaussian if phi.is_finite() && phi > 0.0 => {
665                LikelihoodScaleMetadata::FixedDispersion { phi }
666            }
667            other => other,
668        },
669        _ => scale.clone(),
670    }
671}
672
673/// Number of estimated dispersion / scale parameters a family contributes to the
674/// conditional-AIC degrees of freedom (`2·(edf + scale_dof)`, #1583).
675///
676/// Gaussian profiles σ̂² (one extra dof) unless φ was user-fixed; Gamma / Beta /
677/// Tweedie / Negative-Binomial add one only when their dispersion is *estimated*
678/// from data; Poisson and Binomial carry φ ≡ 1 and add none.
679fn scale_parameter_count(
680    spec: &LikelihoodSpec,
681    scale: &gam_problem::types::LikelihoodScaleMetadata,
682) -> f64 {
683    use gam_problem::types::{LikelihoodScaleMetadata, ResponseFamily};
684    let estimated = match spec.response {
685        ResponseFamily::Gaussian => {
686            !matches!(scale, LikelihoodScaleMetadata::FixedDispersion { .. })
687        }
688        ResponseFamily::Gamma => {
689            matches!(scale, LikelihoodScaleMetadata::EstimatedGammaShape { .. })
690        }
691        ResponseFamily::Beta { .. } => {
692            matches!(scale, LikelihoodScaleMetadata::EstimatedBetaPhi { .. })
693        }
694        ResponseFamily::Tweedie { .. } => {
695            matches!(scale, LikelihoodScaleMetadata::EstimatedTweediePhi { .. })
696        }
697        ResponseFamily::NegativeBinomial { .. } => {
698            matches!(scale, LikelihoodScaleMetadata::EstimatedNegBinTheta { .. })
699        }
700        ResponseFamily::Poisson | ResponseFamily::Binomial | ResponseFamily::RoystonParmar => false,
701    };
702    if estimated { 1.0 } else { 0.0 }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::*;
708    use ndarray::{Array2, array};
709
710    #[test]
711    fn wps_correction_is_trace_of_h_f_sigma_over_phi() {
712        // X'WX = I, φ = 2 → correction is tr(X'WX·corr)/φ = tr(corr)/φ.
713        let xwx = Array2::<f64>::eye(3);
714        let corr = array![[2.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 6.0]];
715        let edf = corrected_edf(3.0, Some(xwx.view()), Some(corr.view()), Some(2.0), 1, true)
716            .expect("corrected EDF");
717        // tr(corr)/φ = (2+4+6)/2 = 6, so corrected = 3 + 6 = 9, ρ-df = 6.
718        assert_eq!(edf.corrected, Some(9.0));
719        assert_eq!(edf.rho_uncertainty_df(), Some(6.0));
720        assert!((edf.conditional - 3.0).abs() < 1e-12);
721    }
722
723    #[test]
724    fn corrected_edf_reports_unavailable_without_inputs() {
725        let edf = corrected_edf(5.5, None, None, Some(1.0), 1, true).expect("availability result");
726        assert_eq!(edf.conditional, 5.5);
727        assert_eq!(edf.corrected, None);
728        assert_eq!(edf.rho_uncertainty_df(), None);
729        assert_eq!(
730            edf.unavailable_reason,
731            Some(CorrectedEdfUnavailable::MissingWeightedGram)
732        );
733    }
734
735    #[test]
736    fn alo_elpd_sums_pointwise_and_flags_no_tail() {
737        // Identical fitted and LOO log-densities → all importance ratios 1,
738        // elpd = Σ ℓ₋ᵢ.
739        let ll: Array1<f64> = array![-1.0, -2.0, -0.5, -1.5, -0.8, -1.2, -0.9, -1.1, -0.7, -1.3];
740        let loo = alo_elpd(ll.view(), ll.view()).expect("alo elpd");
741        let expected: f64 = ll.iter().sum();
742        assert!((loo.elpd - expected).abs() < 1e-9);
743        assert_eq!(loo.pointwise.len(), ll.len());
744        // No spread in importance ratios → k̂ finite, no bad points expected.
745        assert_eq!(loo.n_k_bad, 0);
746    }
747
748    #[test]
749    fn alo_elpd_pointwise_is_local_to_alo_loglikelihoods() {
750        let ll_loo: Array1<f64> = array![
751            -1.0, -1.1, -1.2, -1.3, -1.4, -1.5, -1.6, -1.7, -1.8, -1.9, -2.0, -2.1
752        ];
753        let ll_hat = ll_loo.clone();
754        let mut ll_hat_perturbed = ll_loo.clone();
755        ll_hat_perturbed[7] += 10.0;
756
757        let base = alo_elpd(ll_hat.view(), ll_loo.view()).expect("alo elpd");
758        let perturbed = alo_elpd(ll_hat_perturbed.view(), ll_loo.view()).expect("alo elpd");
759
760        for i in 0..ll_loo.len() {
761            assert_eq!(base.pointwise[i], ll_loo[i]);
762            assert_eq!(perturbed.pointwise[i], ll_loo[i]);
763            if i != 7 {
764                assert_eq!(base.pointwise[i], perturbed.pointwise[i]);
765            }
766        }
767        assert_eq!(perturbed.elpd, base.elpd);
768    }
769
770    fn gpd_sample(u: f64, k: f64, sigma: f64) -> f64 {
771        sigma * ((1.0 - u).powf(-k) - 1.0) / k
772    }
773
774    #[test]
775    fn alo_elpd_influence_diagnostic_fires_on_heavy_tailed_ratios() {
776        let mut ratios = vec![1.0; 200];
777        for i in 1..=120 {
778            let u = (i as f64 - 0.5) / 120.0;
779            ratios.push(1.0 + gpd_sample(u, 1.2, 0.5));
780        }
781        let ll_loo: Array1<f64> = Array1::from_elem(ratios.len(), -1.0);
782        let ll_hat: Array1<f64> = Array1::from_iter(
783            ll_loo
784                .iter()
785                .zip(ratios.iter())
786                .map(|(&ll, &ratio)| ll + ratio.ln()),
787        );
788
789        let loo = alo_elpd(ll_hat.view(), ll_loo.view()).expect("alo elpd");
790
791        assert_eq!(loo.pointwise, ll_loo);
792        assert!((loo.elpd - -(ratios.len() as f64)).abs() < 1e-12);
793        assert!(
794            loo.k_hat_max.is_some_and(|value| value > 0.7),
795            "heavy fitted-vs-ALO ratio tail should fire influence diagnostic; got k_hat={:?}",
796            loo.k_hat_max
797        );
798        assert!(
799            loo.n_k_bad > 0,
800            "heavy fitted-vs-ALO ratio tail should count influential tail observations"
801        );
802    }
803
804    #[test]
805    fn compare_pairs_pointwise_and_orients_a_minus_b() {
806        let mk = |pw: Array1<f64>, aic: f64| ModelComparison {
807            log_lik: 0.0,
808            edf: CorrectedEdf {
809                conditional: 0.0,
810                corrected: Some(0.0),
811                unavailable_reason: None,
812            },
813            aic_conditional: aic,
814            aic_corrected: Some(aic),
815            loo: Some(AloElpd {
816                elpd: pw.iter().sum(),
817                se: Some(0.0),
818                pointwise: pw,
819                k_hat_max: Some(0.1),
820                n_k_bad: 0,
821            }),
822        };
823        let a = mk(array![-1.0, -1.0, -1.0, -1.0], 10.0);
824        let b = mk(array![-2.0, -2.0, -2.0, -2.0], 14.0);
825        let rep = compare(&a, &b).expect("comparison");
826        assert!(rep.rows_aligned);
827        // a − b: elpd diff = (-4) - (-8) = +4 favours a; aic diff = 10 - 14 = -4 favours a.
828        assert_eq!(rep.delta_elpd, Some(4.0));
829        assert_eq!(rep.delta_aic_corrected, Some(-4.0));
830        assert_eq!(rep.delta_elpd_se, Some(0.0));
831    }
832
833    #[test]
834    fn alo_elpd_se_uses_unbiased_sample_variance() {
835        // Pointwise contributions (0, 2): mean 1, sample variance s² = 2
836        // (denominator n−1 = 1), so SE(Σ) = √(n·s²) = √4 = 2. The population
837        // variance (denominator n) would give √2 instead.
838        let ll: Array1<f64> = array![0.0, 2.0];
839        let loo = alo_elpd(ll.view(), ll.view()).expect("alo elpd");
840        assert_eq!(loo.se, Some(2.0));
841    }
842
843    #[test]
844    fn compare_se_uses_unbiased_sample_variance_of_paired_differences() {
845        let mk = |pw: Array1<f64>| ModelComparison {
846            log_lik: 0.0,
847            edf: CorrectedEdf {
848                conditional: 0.0,
849                corrected: Some(0.0),
850                unavailable_reason: None,
851            },
852            aic_conditional: 0.0,
853            aic_corrected: Some(0.0),
854            loo: Some(AloElpd {
855                elpd: pw.iter().sum(),
856                se: Some(0.0),
857                pointwise: pw,
858                k_hat_max: Some(0.1),
859                n_k_bad: 0,
860            }),
861        };
862        // Paired differences (0, 2): s² = 2, SE(Σ diff) = √(2·2) = 2.
863        let a = mk(array![0.0, 2.0]);
864        let b = mk(array![0.0, 0.0]);
865        let rep = compare(&a, &b).expect("comparison");
866        assert!(rep.rows_aligned);
867        assert!(
868            rep.delta_elpd_se == Some(2.0),
869            "se = {:?}",
870            rep.delta_elpd_se
871        );
872    }
873
874    #[test]
875    fn compare_refuses_unpaired_rows() {
876        let mk = |pw: Array1<f64>| ModelComparison {
877            log_lik: 0.0,
878            edf: CorrectedEdf {
879                conditional: 0.0,
880                corrected: Some(0.0),
881                unavailable_reason: None,
882            },
883            aic_conditional: 0.0,
884            aic_corrected: Some(5.0),
885            loo: Some(AloElpd {
886                elpd: pw.iter().sum(),
887                se: Some(0.0),
888                pointwise: pw,
889                k_hat_max: Some(0.1),
890                n_k_bad: 0,
891            }),
892        };
893        let a = mk(array![-1.0, -1.0, -1.0]);
894        let b = mk(array![-1.0, -1.0]);
895        let rep = compare(&a, &b).expect("comparison");
896        assert!(!rep.rows_aligned);
897        assert_eq!(rep.delta_elpd, None);
898        // AIC gap still reported.
899        assert_eq!(rep.delta_aic_corrected, Some(0.0));
900    }
901
902    /// #946 regression: a fit whose PRIMARY smoothing correction escalated to
903    /// sigma-point cubature (a named approximation) must still populate the
904    /// EXACT corrected-EDF/AIC channel, reading the separately-retained
905    /// first-order pair instead of going dark. Before this fix,
906    /// `model_comparison_from_unified` gated `corrected`/`aic_corrected` on
907    /// `fit.smoothing_correction_method()` (the PRIMARY method), so any fit
908    /// whose auto-selector chose cubature — precisely the regime with
909    /// non-trivial smoothing-parameter uncertainty the correction exists to
910    /// report on — silently reported `MissingMethodProvenance` instead of the
911    /// exact first-order estimate that was computed and available all along.
912    #[test]
913    fn corrected_edf_uses_retained_first_order_pair_when_primary_method_is_cubature() {
914        use gam_solve::model_types::{
915            Dispersion, FitArtifacts, FitInference, FittedBlock, FittedLinkState,
916            UnifiedFitResultParts,
917        };
918        use gam_solve::pirls::PirlsStatus;
919        use gam_problem::{LikelihoodScaleMetadata, LogLikelihoodNormalization};
920
921        // Distinct matrices for the primary (cubature) vs retained (first-order)
922        // corrections so the test can prove which one the channel actually used.
923        let cubature_correction = array![[9.0, 0.0], [0.0, 9.0]];
924        let first_order_correction = array![[0.4, 0.0], [0.0, 0.4]];
925        let weighted_gram = array![[1.0, 0.0], [0.0, 1.0]];
926
927        let parts = UnifiedFitResultParts {
928            blocks: vec![FittedBlock {
929                beta: array![0.25, -0.5],
930                role: gam_problem::BlockRole::Mean,
931                edf: 1.5,
932                lambdas: array![2.0],
933            }],
934            log_lambdas: array![2.0_f64.ln()],
935            lambdas: array![2.0],
936            likelihood_family: Some(LikelihoodSpec::gaussian_identity()),
937            likelihood_scale: LikelihoodScaleMetadata::ProfiledGaussian,
938            log_likelihood_normalization: LogLikelihoodNormalization::Full,
939            log_likelihood: -1.2,
940            deviance: 2.4,
941            reml_score: 0.7,
942            stable_penalty_term: 0.3,
943            penalized_objective: 2.2,
944            used_device: false,
945            // `outer_iterations: 0` sidesteps the analytic-certificate
946            // requirement in `UnifiedFitResult::try_from_parts` (a fit is
947            // certified either by a passing criterion certificate, or
948            // trivially when the outer loop never ran) — irrelevant to what
949            // this test exercises (the corrected-EDF wiring), so the simplest
950            // valid fixture is used.
951            outer_iterations: 0,
952            outer_converged: true,
953            outer_gradient_norm: None,
954            // 1.0 so the ProfiledGaussian coefficient-covariance scale
955            // (= standard_deviation^2 = dispersion_phi()) is exactly 1.0,
956            // keeping the hand-checked ρ-uncertainty arithmetic below simple.
957            standard_deviation: 1.0,
958            covariance_conditional: Some(array![[1.0, 0.1], [0.1, 2.0]]),
959            covariance_corrected: None,
960            inference: Some(FitInference {
961                edf_by_block: vec![0.6, 0.9],
962                penalty_block_trace: vec![],
963                edf_total: 1.5,
964                // PRIMARY: escalated to a cubature upgrade.
965                smoothing_correction: Some(cubature_correction.clone()),
966                smoothing_correction_method: Some(SmoothingCorrectionMethod::SigmaPointCubature {
967                    rank: 1,
968                    n_points: 2,
969                    rho_hessian_stabilization: gam_problem::StabilizationLedger::approximation_only(
970                        1.0e-8,
971                        gam_problem::StabilizationRule::FixedConstant,
972                    )
973                    .expect("valid test cubature ridge"),
974                }),
975                // RETAINED: the exact first-order correction computed before
976                // the escalation decision, never discarded (#946).
977                smoothing_correction_first_order: Some(first_order_correction.clone()),
978                smoothing_correction_method_first_order: Some(
979                    SmoothingCorrectionMethod::FirstOrderIdentifiedSubspace {
980                        active_rank: 1,
981                        rho_dimension: 1,
982                    },
983                ),
984                penalized_hessian: array![[2.0, 0.1], [0.1, 3.0]].into(),
985                reparam_qs: Some(array![[1.0, 0.0], [0.0, 1.0]]),
986                dispersion: Dispersion::estimated(1.0).expect("valid test dispersion"),
987                beta_covariance: Some(array![[1.0, 0.1], [0.1, 2.0]].into()),
988                beta_standard_errors: Some(array![1.0, 2.0_f64.sqrt()]),
989                beta_covariance_corrected: None,
990                beta_standard_errors_corrected: None,
991                beta_covariance_frequentist: None,
992                coefficient_influence: None,
993                weighted_gram: Some(weighted_gram),
994                bias_correction_beta: None,
995                bias_correction_jacobian: None,
996            }),
997            fitted_link: FittedLinkState::Standard(None),
998            geometry: None,
999            block_states: Vec::new(),
1000            pirls_status: PirlsStatus::Converged,
1001            max_abs_eta: 1.25,
1002            constraint_kkt: None,
1003            artifacts: FitArtifacts::default(),
1004            inner_cycles: 0,
1005        };
1006        let fit = UnifiedFitResult::try_from_parts(parts)
1007            .unwrap_or_else(|e| panic!("construct #946 cubature-vs-first-order fixture: {e:?}"));
1008
1009        // Sanity: the fixture's PRIMARY method really is cubature, and the
1010        // RETAINED first-order method really is distinct from it.
1011        assert!(matches!(
1012            fit.smoothing_correction_method(),
1013            Some(SmoothingCorrectionMethod::SigmaPointCubature { .. })
1014        ));
1015        assert!(matches!(
1016            fit.smoothing_correction_method_first_order(),
1017            Some(SmoothingCorrectionMethod::FirstOrderIdentifiedSubspace { .. })
1018        ));
1019
1020        let y = array![0.1, 0.2, 0.3];
1021        let eta_hat = array![0.05, 0.15, 0.35];
1022        let weights = Array1::<f64>::ones(3);
1023        let cmp = model_comparison_from_unified(&fit, y.view(), eta_hat.view(), weights.view(), None)
1024            .expect("construct comparison for the cubature-vs-first-order fixture");
1025
1026        let corrected = cmp
1027            .edf
1028            .corrected
1029            .expect("corrected EDF must be Some even though the PRIMARY method is cubature");
1030        let aic_corrected = cmp
1031            .aic_corrected
1032            .expect("corrected AIC must be Some even though the PRIMARY method is cubature");
1033
1034        // The channel must have used the RETAINED first-order correction
1035        // (0.4 on the diagonal), not the primary cubature one (9.0): the
1036        // ρ-uncertainty contribution is tr(weighted_gram · first_order_correction)
1037        // / covariance_scale = tr(I · 0.4I) / 1.0 = 0.8, so corrected =
1038        // conditional (edf_total=1.5) + 0.8 = 2.3 — NOT conditional + 18.0
1039        // (which the cubature matrix would have produced).
1040        let rho_uncertainty_df = cmp
1041            .edf
1042            .rho_uncertainty_df()
1043            .expect("rho-uncertainty df must be Some");
1044        assert!(
1045            (rho_uncertainty_df - 0.8).abs() < 1e-9,
1046            "expected the retained first-order correction's contribution (0.8), got {rho_uncertainty_df}"
1047        );
1048        assert!(
1049            (corrected - 2.3).abs() < 1e-9,
1050            "corrected EDF must equal conditional + the first-order contribution, got {corrected}"
1051        );
1052        assert!(
1053            aic_corrected.is_finite(),
1054            "corrected AIC must be finite, got {aic_corrected}"
1055        );
1056    }
1057}