Skip to main content

gam_solve/estimate/
summary.rs

1use super::*;
2
3#[derive(Clone, Debug)]
4pub struct ParametricTermSummary {
5    pub name: String,
6    pub estimate: f64,
7    pub std_error: Option<f64>,
8    pub zvalue: Option<f64>,
9    pub pvalue: Option<f64>,
10}
11
12#[derive(Clone, Debug)]
13pub struct SmoothTermSummary {
14    pub name: String,
15    pub edf: f64,
16    pub ref_df: f64,
17    pub chi_sq: Option<f64>,
18    pub pvalue: Option<f64>,
19    pub continuous_order: Option<ContinuousSmoothnessOrder>,
20    /// Issue #340: human-readable note describing an automatic B-spline
21    /// basis-shrink performed at fit time when `n` was too small for the
22    /// user's requested `(degree, num_internal_knots)`. `None` means no
23    /// shrink occurred (or the term is not a B-spline 1D smooth).
24    pub basis_note: Option<String>,
25}
26
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub enum ContinuousSmoothnessOrderStatus {
29    Ok,
30    NonMaternRegime,
31    FirstOrderLimit,
32    IntrinsicLimit,
33    UndefinedZeroLambda,
34}
35
36#[derive(Clone, Debug)]
37pub struct ContinuousSmoothnessOrder {
38    pub lambda0: f64,
39    pub lambda1: f64,
40    pub lambda2: f64,
41    pub r_ratio: Option<f64>,
42    pub nu: Option<f64>,
43    pub kappa2: Option<f64>,
44    pub status: ContinuousSmoothnessOrderStatus,
45}
46
47#[derive(Clone, Debug)]
48pub struct ModelSummary {
49    pub family: String,
50    pub deviance_explained: Option<f64>,
51    pub reml_score: Option<f64>,
52    pub parametric_terms: Vec<ParametricTermSummary>,
53    pub smooth_terms: Vec<SmoothTermSummary>,
54    /// Exact covariance definition behind the coefficient standard errors
55    /// (#2296). Result-owned: recorded from the pair the builder actually
56    /// consumed, never from a display policy. `None` when the fit carries no
57    /// coefficient standard errors at all.
58    pub coefficient_se_source: Option<crate::model_types::CoefficientCovarianceDefinition>,
59}
60
61/// Convert optimizer-scale lambdas into physical lambdas for raw operator penalties.
62///
63/// Derivation:
64///   We optimize with normalized penalties
65///     sum_k lambda_tilde_k * S_tilde_k
66///   where
67///     S_tilde_k = (1 / c_k) * S_k.
68///
69///   Define physical lambdas by requiring operator equality:
70///     sum_k lambda_k * S_k  ==  sum_k lambda_tilde_k * S_tilde_k
71///                           ==  sum_k lambda_tilde_k * (1/c_k) * S_k
72///                           ==  sum_k (lambda_tilde_k / c_k) * S_k.
73///
74///   Therefore, coefficient matching gives:
75///     lambda_k = lambda_tilde_k / c_k.
76///
77/// This helper performs exactly that mapping and validates positivity/finite values.
78fn unscale_to_physical_lambdas(
79    lambda_tilde: [f64; 3],
80    normalization_scale: [f64; 3],
81) -> Option<[f64; 3]> {
82    let mut out = [f64::NAN; 3];
83    for k in 0..3 {
84        let c = normalization_scale[k];
85        if !(c.is_finite() && c > 0.0) {
86            return None;
87        }
88        out[k] = lambda_tilde[k] / c;
89    }
90    Some(out)
91}
92
93// Continuous smoothness/order diagnostic from three operator penalties.
94//
95// Full derivation and implementation contract
96// We assume one smooth term has exactly three operator penalties in term-local order:
97//   S0 = mass, S1 = tension (|grad f|^2), S2 = stiffness ((Delta f)^2).
98//
99// 1) Unscaling (physical lambda from optimizer lambda)
100// If penalties were normalized before optimization:
101//   S_tilde_k = S_k / c_k
102// and the optimizer fits lambda_tilde_k, then
103//   lambda_tilde_k * (beta-mu)' S_tilde_k (beta-mu)
104// = lambda_tilde_k * (beta-mu)' (S_k / c_k) (beta-mu)
105// = (lambda_tilde_k / c_k) * (beta-mu)' S_k (beta-mu).
106//
107// Therefore physical lambdas are:
108//   lambda_k = lambda_tilde_k / c_k,  k in {0,1,2}.
109//
110// 2) SPDE/binomial coefficient mapping
111// If the fitted (lambda0,lambda1,lambda2) are interpreted as proportional to
112//   a_m(kappa,nu) = C(nu,m) * kappa^(2*(nu-m)),  m=0,1,2,
113// then
114//   a0 = kappa^(2*nu)
115//   a1 = nu * kappa^(2*nu-2)
116//   a2 = nu*(nu-1)/2 * kappa^(2*nu-4)
117//
118// Ratios:
119//   lambda0/lambda2 = a0/a2 = 2*kappa^4 / (nu*(nu-1))
120//   lambda1/lambda2 = a1/a2 = 2*kappa^2 / (nu-1)
121//
122// Define:
123//   R = lambda1^2 / (lambda0*lambda2).
124// Then
125//   R = a1^2/(a0*a2) = 2*nu/(nu-1).
126// Solve for nu:
127//   nu = R/(R-2), requiring R>2 for finite nu>1.
128//
129// And from lambda1/lambda2:
130//   kappa^2 = ((nu-1)/2) * (lambda1/lambda2)
131//           = lambda1 / ((R-2)*lambda2).
132//
133// 3) Boundary/discriminant interpretation
134// Spectral polynomial in x=|omega|^2:
135//   Q(x) = lambda0 + lambda1*x + lambda2*x^2.
136//
137// Perfect-square Matérn(2) form is:
138//   Q(x) proportional to (kappa^2 + x)^2
139// which implies:
140//   lambda1^2 = 4*lambda0*lambda2  <=>  R = 4.
141//
142// Discriminant:
143//   D = lambda1^2 - 4*lambda0*lambda2 = lambda0*lambda2*(R-4).
144// Hence:
145//   R < 4  => D < 0 => no real factorization into two real range terms
146//            => flagged as NonMaternRegime.
147//   R = 4  => exact boundary (perfect square) => treated as Matérn-compatible.
148//
149// 4) Degenerate limits and guards
150// - If lambda0 or lambda2 is non-finite or <= eps, the 3-term inversion is unstable;
151//   report UndefinedZeroLambda and do not divide by those terms.
152// - Intrinsic limit (lambda0 -> 0+, with finite lambda1/lambda2):
153//     R = lambda1^2/(lambda0*lambda2) -> +inf
154//     nu = R/(R-2) -> 1+
155//     kappa^2 = lambda1/((R-2)lambda2) -> 0+.
156//   We expose this explicitly as IntrinsicLimit with nu≈1 and kappa^2≈0.
157// - If R <= 2 (+eps), nu = R/(R-2) is undefined or numerically unstable; keep
158//   nu/kappa2 unset.
159//
160// Status policy in this implementation:
161// - Ok:                R >= 4 and valid finite nu/kappa2.
162// - NonMaternRegime:   R < 4; if additionally R > 2, we still report effective
163//                      nu/kappa2 as diagnostics, but mark non-Matérn status.
164// - IntrinsicLimit:    lambda0 is negligible; report nu≈1, kappa^2≈0.
165// - UndefinedZeroLambda: invalid scaling/lambda inputs or unstable inversion.
166pub fn compute_continuous_smoothness_order(
167    lambda_tilde: [f64; 3],
168    normalization_scale: [f64; 3],
169    eps: f64,
170) -> ContinuousSmoothnessOrder {
171    let Some(lambda) = unscale_to_physical_lambdas(lambda_tilde, normalization_scale) else {
172        return ContinuousSmoothnessOrder {
173            lambda0: f64::NAN,
174            lambda1: f64::NAN,
175            lambda2: f64::NAN,
176            r_ratio: None,
177            nu: None,
178            kappa2: None,
179            status: ContinuousSmoothnessOrderStatus::UndefinedZeroLambda,
180        };
181    };
182    let [lambda0, lambda1, lambda2] = lambda;
183    if !lambda0.is_finite() || !lambda1.is_finite() || !lambda2.is_finite() {
184        return ContinuousSmoothnessOrder {
185            lambda0,
186            lambda1,
187            lambda2,
188            r_ratio: None,
189            nu: None,
190            kappa2: None,
191            status: ContinuousSmoothnessOrderStatus::UndefinedZeroLambda,
192        };
193    }
194    // Scale-aware degeneracy floor.
195    // Using only an absolute epsilon can misclassify limits when lambdas are
196    // globally tiny or globally huge, so we threshold relative to the largest
197    // physical lambda magnitude in this term.
198    let lambda_scale = lambda0.abs().max(lambda1.abs()).max(lambda2.abs()).max(1.0);
199    let lambda_floor = eps * lambda_scale;
200
201    // Intrinsic limit: mass term vanishes (kappa^2 -> 0).
202    if lambda0 <= lambda_floor {
203        if lambda1 > lambda_floor && lambda2 > lambda_floor {
204            return ContinuousSmoothnessOrder {
205                lambda0,
206                lambda1,
207                lambda2,
208                r_ratio: None,
209                nu: Some(1.0),
210                kappa2: Some(0.0),
211                status: ContinuousSmoothnessOrderStatus::IntrinsicLimit,
212            };
213        }
214        return ContinuousSmoothnessOrder {
215            lambda0,
216            lambda1,
217            lambda2,
218            r_ratio: None,
219            nu: None,
220            kappa2: None,
221            status: ContinuousSmoothnessOrderStatus::UndefinedZeroLambda,
222        };
223    }
224    // First-order fallback when stiffness collapses:
225    //   lambda2 ~ 0 => use lambda0/lambda1 = kappa^2 with nu ≈ 1.
226    if lambda2 <= lambda_floor {
227        if lambda1 > lambda_floor && lambda1.is_finite() {
228            return ContinuousSmoothnessOrder {
229                lambda0,
230                lambda1,
231                lambda2,
232                r_ratio: None,
233                nu: Some(1.0),
234                kappa2: Some(lambda0 / lambda1),
235                status: ContinuousSmoothnessOrderStatus::FirstOrderLimit,
236            };
237        }
238        return ContinuousSmoothnessOrder {
239            lambda0,
240            lambda1,
241            lambda2,
242            r_ratio: None,
243            nu: None,
244            kappa2: None,
245            status: ContinuousSmoothnessOrderStatus::UndefinedZeroLambda,
246        };
247    }
248
249    let r_ratio = (lambda1 * lambda1) / (lambda0 * lambda2);
250    if !r_ratio.is_finite() {
251        return ContinuousSmoothnessOrder {
252            lambda0,
253            lambda1,
254            lambda2,
255            r_ratio: None,
256            nu: None,
257            kappa2: None,
258            status: ContinuousSmoothnessOrderStatus::UndefinedZeroLambda,
259        };
260    }
261
262    // From a_m = binom(nu,m) * kappa^{2(nu-m)} with m=0,1,2:
263    //   R = lambda1^2 / (lambda0*lambda2) = 2*nu/(nu-1)
264    //   nu = R/(R-2), and kappa^2 = lambda1 / ((R-2)*lambda2).
265    //
266    // Discriminant of spectral quadratic P(t)=lambda0+lambda1*t+lambda2*t^2:
267    //   Delta_P = lambda1^2 - 4*lambda0*lambda2 = lambda0*lambda2*(R-4).
268    // Non-Matérn regime is flagged by Delta_P < 0 (equiv. R < 4),
269    // but nu/kappa2 are still reported when R > 2 as effective diagnostics.
270    let discriminant = lambda1 * lambda1 - 4.0 * lambda0 * lambda2;
271    let disc_tol = eps * lambda_scale * lambda_scale;
272    let status = if discriminant < -disc_tol {
273        ContinuousSmoothnessOrderStatus::NonMaternRegime
274    } else {
275        // Includes exact boundary R=4 (perfect-square case) and numerically
276        // indistinguishable near-boundary points.
277        ContinuousSmoothnessOrderStatus::Ok
278    };
279    if r_ratio <= 2.0 + eps {
280        return ContinuousSmoothnessOrder {
281            lambda0,
282            lambda1,
283            lambda2,
284            r_ratio: Some(r_ratio),
285            nu: None,
286            kappa2: None,
287            status,
288        };
289    }
290    let nu = r_ratio / (r_ratio - 2.0);
291    // Closed-form extraction required by the continuous-order benchmark:
292    //
293    //   R = lambda1^2 / (lambda0*lambda2) = 2*nu/(nu-1)
294    //   => nu = R/(R-2).
295    //
296    //   lambda1/lambda2 = 2*kappa^2/(nu-1)
297    //   => kappa^2 = ((nu-1)/2)*(lambda1/lambda2)
298    //             = lambda1 / ((R-2)*lambda2).
299    //
300    // We use this exact closed form as the reported kappa^2.
301    let kappa2 = lambda1 / ((r_ratio - 2.0) * lambda2);
302    if !nu.is_finite() || !kappa2.is_finite() {
303        return ContinuousSmoothnessOrder {
304            lambda0,
305            lambda1,
306            lambda2,
307            r_ratio: Some(r_ratio),
308            nu: None,
309            kappa2: None,
310            status: ContinuousSmoothnessOrderStatus::UndefinedZeroLambda,
311        };
312    }
313
314    ContinuousSmoothnessOrder {
315        lambda0,
316        lambda1,
317        lambda2,
318        r_ratio: Some(r_ratio),
319        nu: Some(nu),
320        kappa2: Some(kappa2),
321        status,
322    }
323}
324
325fn significance_stars(p: Option<f64>) -> &'static str {
326    match p {
327        Some(v) if v.is_finite() && v < 0.001 => "***",
328        Some(v) if v.is_finite() && v < 0.01 => "**",
329        Some(v) if v.is_finite() && v < 0.05 => "*",
330        Some(v) if v.is_finite() && v < 0.1 => ".",
331        _ => "",
332    }
333}
334
335fn format_pvalue(p: Option<f64>) -> String {
336    let Some(v) = p else {
337        return "NA".to_string();
338    };
339    if !v.is_finite() {
340        return "NA".to_string();
341    }
342    if v < 2e-16 {
343        "< 2e-16".to_string()
344    } else if v < 1e-4 {
345        format!("{v:.2e}")
346    } else {
347        format!("{v:.4}")
348    }
349}
350
351impl fmt::Display for ModelSummary {
352    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
353        let paramnamew = self
354            .parametric_terms
355            .iter()
356            .map(|t| t.name.len())
357            .max()
358            .unwrap_or(10)
359            .max("Term".len());
360        let smoothnamew = self
361            .smooth_terms
362            .iter()
363            .map(|t| t.name.len())
364            .max()
365            .unwrap_or(10)
366            .max("Term".len());
367
368        writeln!(f, "Family: {}", self.family)?;
369        let dev_txt = self
370            .deviance_explained
371            .map(|d| format!("{:.1}%", (100.0 * d).clamp(-9999.0, 9999.0)))
372            .unwrap_or_else(|| "NA".to_string());
373        let reml_txt = self
374            .reml_score
375            .map(|v| format!("{v:.4}"))
376            .unwrap_or_else(|| "NA".to_string());
377        writeln!(f, "Deviance Explained: {dev_txt} | REML Score: {reml_txt}")?;
378        if let Some(source) = self.coefficient_se_source {
379            writeln!(f, "Coefficient SE Covariance: {source}")?;
380        }
381        writeln!(f)?;
382
383        writeln!(f, "Parametric Terms:")?;
384        writeln!(f, "{:-<1$}", "", paramnamew + 59)?;
385        writeln!(
386            f,
387            "{:<namew$} {:>10} {:>12} {:>10} {:>19}",
388            "Term",
389            "Estimate",
390            "Standard Error",
391            "Z Statistic",
392            "Two-Sided P-Value",
393            namew = paramnamew
394        )?;
395        writeln!(f, "{:-<1$}", "", paramnamew + 59)?;
396        for term in &self.parametric_terms {
397            let estimate = format!("{:.4}", term.estimate);
398            let se = term
399                .std_error
400                .filter(|v| v.is_finite())
401                .map(|v| format!("{v:.4}"))
402                .unwrap_or_else(|| "NA".to_string());
403            let z = term
404                .zvalue
405                .filter(|v| v.is_finite())
406                .map(|v| format!("{v:.2}"))
407                .unwrap_or_else(|| "NA".to_string());
408            let p = format_pvalue(term.pvalue);
409            let stars = significance_stars(term.pvalue);
410            writeln!(
411                f,
412                "{:<namew$} {:>10} {:>12} {:>10} {:>19} {}",
413                term.name,
414                estimate,
415                se,
416                z,
417                p,
418                stars,
419                namew = paramnamew
420            )?;
421        }
422        writeln!(f)?;
423
424        writeln!(f, "Smooth Terms:")?;
425        writeln!(f, "{:-<1$}", "", smoothnamew + 86)?;
426        writeln!(
427            f,
428            "{:<namew$} {:>26} {:>30} {:>12} {:>10}",
429            "Term",
430            "Effective Degrees of Freedom",
431            "Reference Degrees of Freedom",
432            "Chi-Square",
433            "P-Value",
434            namew = smoothnamew
435        )?;
436        writeln!(f, "{:-<1$}", "", smoothnamew + 86)?;
437        for term in &self.smooth_terms {
438            let chisq = term
439                .chi_sq
440                .filter(|v| v.is_finite())
441                .map(|v| format!("{v:.3}"))
442                .unwrap_or_else(|| "NA".to_string());
443            let p = format_pvalue(term.pvalue);
444            let stars = significance_stars(term.pvalue);
445            writeln!(
446                f,
447                "{:<namew$} {:>26.2} {:>30.2} {:>12} {:>10} {}",
448                term.name,
449                term.edf,
450                term.ref_df,
451                chisq,
452                p,
453                stars,
454                namew = smoothnamew
455            )?;
456        }
457        writeln!(f)?;
458        let order_terms = self
459            .smooth_terms
460            .iter()
461            .filter_map(|t| t.continuous_order.as_ref().map(|o| (&t.name, o)))
462            .collect::<Vec<_>>();
463        if !order_terms.is_empty() {
464            writeln!(f, "Continuous Smoothness Order:")?;
465            writeln!(
466                f,
467                "{:<namew$} {:>10} {:>10} {:>10} {:>10} {:>10} {:>10} {:>20}",
468                "Term",
469                "lambda0",
470                "lambda1",
471                "lambda2",
472                "R",
473                "nu",
474                "kappa^2",
475                "status",
476                namew = smoothnamew
477            )?;
478            for (name, o) in order_terms {
479                let r_txt = o
480                    .r_ratio
481                    .filter(|v| v.is_finite())
482                    .map(|v| format!("{v:.4}"))
483                    .unwrap_or_else(|| "NA".to_string());
484                let nu_txt =
485                    o.nu.filter(|v| v.is_finite())
486                        .map(|v| format!("{v:.4}"))
487                        .unwrap_or_else(|| "NA".to_string());
488                let kappa_txt = o
489                    .kappa2
490                    .filter(|v| v.is_finite())
491                    .map(|v| format!("{v:.4}"))
492                    .unwrap_or_else(|| "NA".to_string());
493                let status_txt = match o.status {
494                    ContinuousSmoothnessOrderStatus::Ok => "Ok",
495                    ContinuousSmoothnessOrderStatus::NonMaternRegime => "NonMaternRegime",
496                    ContinuousSmoothnessOrderStatus::FirstOrderLimit => "FirstOrderLimit",
497                    ContinuousSmoothnessOrderStatus::IntrinsicLimit => "IntrinsicLimit",
498                    ContinuousSmoothnessOrderStatus::UndefinedZeroLambda => "UndefinedZeroLambda",
499                };
500                writeln!(
501                    f,
502                    "{:<namew$} {:>10.3e} {:>10.3e} {:>10.3e} {:>10} {:>10} {:>10} {:>20}",
503                    name,
504                    o.lambda0,
505                    o.lambda1,
506                    o.lambda2,
507                    r_txt,
508                    nu_txt,
509                    kappa_txt,
510                    status_txt,
511                    namew = smoothnamew
512                )?;
513            }
514            writeln!(f)?;
515        }
516        write!(
517            f,
518            "Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1"
519        )?;
520        Ok(())
521    }
522}