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