Skip to main content

gam_problem/
estimation_error.rs

1use gam_linalg::LinalgError;
2use gam_linalg::faer_ndarray::FaerLinalgError;
3
4use crate::{BasisError, CustomFamilyError, MonotoneRootError};
5
6/// A comprehensive error type for the model estimation process.
7#[derive(thiserror::Error)]
8pub enum EstimationError {
9    #[error("Underlying basis function generation failed: {0}")]
10    BasisError(#[from] BasisError),
11
12    #[error("Custom-family fit failed: {0}")]
13    CustomFamily(#[from] CustomFamilyError),
14
15    #[error("A linear system solve failed. The penalized Hessian may be singular. Error: {0}")]
16    LinearSystemSolveFailed(FaerLinalgError),
17
18    #[error("Eigendecomposition failed: {0}")]
19    EigendecompositionFailed(FaerLinalgError),
20
21    #[error(
22        "Penalty spectrum check failed in '{context}': non-finite eigenvalue {value:?} at index {index}"
23    )]
24    PenaltySpectrumNonFinite {
25        context: String,
26        index: usize,
27        value: f64,
28    },
29
30    #[error(
31        "Penalty spectrum check failed in '{context}': indefinite eigenvalue {value:.3e} at index {index} (tolerance {tolerance:.3e}, scale {scale:.3e})"
32    )]
33    PenaltySpectrumIndefinite {
34        context: String,
35        index: usize,
36        value: f64,
37        tolerance: f64,
38        scale: f64,
39    },
40
41    #[error("Parameter constraint violation: {0}")]
42    ParameterConstraintViolation(String),
43
44    #[error(
45        "The P-IRLS inner loop did not converge within {max_iterations} iterations. Last gradient norm was {last_change:.6e}."
46    )]
47    PirlsDidNotConverge {
48        max_iterations: usize,
49        last_change: f64,
50    },
51
52    #[error(
53        "Perfect or quasi-perfect separation detected during model fitting at iteration {iteration}. \
54        The model cannot converge because a predictor perfectly separates the binary outcomes. \
55        (Diagnostic: max|eta| = {max_abs_eta:.2e})."
56    )]
57    PerfectSeparationDetected { iteration: usize, max_abs_eta: f64 },
58
59    #[error(
60        "Pre-fit perfect separation detected in the realized binomial inverse-link design: column {column_index} \
61        has a threshold {threshold:.6e} that separates the binary outcomes \
62        (positive_above_threshold={positive_above_threshold}). The unpenalized MLE is not finite; \
63        enable Firth/Jeffreys bias reduction or remove/reparameterize the separating column."
64    )]
65    PrefitPerfectSeparationDetected {
66        column_index: usize,
67        threshold: f64,
68        positive_above_threshold: bool,
69    },
70
71    #[error(
72        "Pre-fit linear separation detected in the realized binomial inverse-link design: \
73        {num_unpenalized_columns} effectively unpenalized columns admit a separating direction \
74        with minimum signed margin {min_signed_margin:.6e} (columns {column_indices:?}). \
75        The unpenalized MLE is not finite; enable Firth/Jeffreys bias reduction or \
76        remove/reparameterize/penalize the separating columns."
77    )]
78    PrefitLinearSeparationDetected {
79        min_signed_margin: f64,
80        num_unpenalized_columns: usize,
81        column_indices: Vec<usize>,
82    },
83
84    #[error(
85        "Pre-fit rank deficiency detected in the realized unpenalized design: rank {rank} < {num_unpenalized_columns} \
86        unpenalized columns (min eigenvalue {min_eigenvalue:.3e}, tolerance {tolerance:.3e}, columns {column_indices:?}). \
87        Remove/reparameterize the aliased columns or add an explicit penalty/constraint before fitting."
88    )]
89    PrefitRankDeficientDesignDetected {
90        rank: usize,
91        num_unpenalized_columns: usize,
92        min_eigenvalue: f64,
93        tolerance: f64,
94        column_indices: Vec<usize>,
95    },
96
97    #[error(
98        "Pre-fit near-degeneracy detected in the realized unpenalized design: the {num_unpenalized_columns} \
99        unpenalized columns span a numerically rank-degenerate direction (Gram condition number {condition_number:.3e} \
100        exceeds tolerance {tolerance:.3e}; min eigenvalue {min_eigenvalue:.3e}, max eigenvalue {max_eigenvalue:.3e}, \
101        columns {column_indices:?}). The unpenalized normal equations are effectively singular along this direction, \
102        so the fit would grind/diverge. Remove/reparameterize the near-aliased columns or add an explicit \
103        penalty/constraint before fitting."
104    )]
105    PrefitNearDegenerateDesignDetected {
106        num_unpenalized_columns: usize,
107        condition_number: f64,
108        min_eigenvalue: f64,
109        max_eigenvalue: f64,
110        tolerance: f64,
111        column_indices: Vec<usize>,
112    },
113
114    #[error(
115        "Perfect or quasi-perfect separation detected during multinomial fitting at iteration {iteration}. \
116        The active class-{active_class_index} logit against the reference class is saturated at training row {row_index}, \
117        so the unpenalized softmax MLE is not finite in that direction. \
118        (Diagnostic: max|eta| = {max_abs_eta:.2e})."
119    )]
120    MultinomialSeparationDetected {
121        iteration: usize,
122        max_abs_eta: f64,
123        active_class_index: usize,
124        row_index: usize,
125    },
126
127    #[error(
128        "Hessian matrix is not positive definite (minimum eigenvalue: {min_eigenvalue:.4e}). This indicates a numerical instability."
129    )]
130    HessianNotPositiveDefinite { min_eigenvalue: f64 },
131
132    #[error("REML smoothing optimization failed to converge: {0}")]
133    RemlOptimizationFailed(String),
134
135    #[error("{context}: unified evaluator returned no gradient in {mode} mode")]
136    GradientUnavailable {
137        context: &'static str,
138        mode: &'static str,
139    },
140
141    #[error("An internal error occurred during model layout or coefficient mapping: {0}")]
142    LayoutError(String),
143
144    #[error(
145        "Model is over-parameterized: {num_coeffs} coefficients for {num_samples} samples.\n\n\
146        Coefficient Breakdown:\n\
147          - Intercept:                     {intercept_coeffs}\n\
148          - Binary Main Effects:           {binary_main_coeffs}\n\
149          - Primary Smooth Effects:        {primary_smooth_coeffs}\n\
150          - Binary×Primary Interactions:   {binary_primary_interaction_coeffs}\n\
151          - Auxiliary Main Effects:        {aux_main_coeffs}\n\
152          - Auxiliary Interactions:        {aux_interaction_coeffs}"
153    )]
154    ModelOverparameterized {
155        num_coeffs: usize,
156        num_samples: usize,
157        intercept_coeffs: usize,
158        binary_main_coeffs: usize,
159        primary_smooth_coeffs: usize,
160        aux_main_coeffs: usize,
161        binary_primary_interaction_coeffs: usize,
162        aux_interaction_coeffs: usize,
163    },
164
165    #[error(
166        "Model is ill-conditioned with condition number {condition_number:.2e}. This typically occurs when the model is over-parameterized (too many knots relative to data points). Consider reducing the number of knots or increasing regularization."
167    )]
168    ModelIsIllConditioned { condition_number: f64 },
169
170    #[error("Invalid input: {0}")]
171    InvalidInput(String),
172
173    #[error("monotone root solve: {0}")]
174    MonotoneRoot(#[from] MonotoneRootError),
175
176    #[error("Calibrator training failed: {0}")]
177    CalibratorTrainingFailed(String),
178
179    #[error("Invalid specification: {0}")]
180    InvalidSpecification(String),
181
182    #[error("Prediction error")]
183    PredictionError,
184}
185
186// Ensure Debug prints with actual line breaks by delegating to Display
187impl core::fmt::Debug for EstimationError {
188    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
189        write!(f, "{}", self)
190    }
191}
192
193impl EstimationError {
194    /// Classifies inner-solve failures that the outer REML loop should
195    /// treat as a soft retreat (return +inf cost / infeasible outer-eval)
196    /// rather than propagate as a hard error.
197    ///
198    /// Why: when the penalised Hessian becomes effectively singular at the
199    /// current rho, when P-IRLS hits a perfect-separation diagnostic, or when
200    /// it exhausts its iteration budget, the outer optimiser's correct
201    /// response is to back away from this rho — not to terminate the fit.
202    /// All three variants encode "the inner problem at this rho is too hard
203    /// to evaluate, try a different rho".
204    pub fn is_inner_solve_retreat(&self) -> bool {
205        matches!(
206            self,
207            EstimationError::ModelIsIllConditioned { .. }
208                | EstimationError::PerfectSeparationDetected { .. }
209                | EstimationError::MultinomialSeparationDetected { .. }
210                | EstimationError::PirlsDidNotConverge { .. }
211        )
212    }
213}
214
215impl From<LinalgError> for EstimationError {
216    fn from(error: LinalgError) -> Self {
217        match error {
218            LinalgError::InvalidInput(message) => EstimationError::InvalidInput(message),
219            LinalgError::HessianNotPositiveDefinite { min_eigenvalue } => {
220                EstimationError::HessianNotPositiveDefinite { min_eigenvalue }
221            }
222            LinalgError::ModelIsIllConditioned { condition_number } => {
223                EstimationError::ModelIsIllConditioned { condition_number }
224            }
225        }
226    }
227}