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}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    // ── is_inner_solve_retreat ────────────────────────────────────────────────
234
235    #[test]
236    fn model_ill_conditioned_is_retreat() {
237        assert!(EstimationError::ModelIsIllConditioned { condition_number: 1e15 }
238            .is_inner_solve_retreat());
239    }
240
241    #[test]
242    fn perfect_separation_is_retreat() {
243        assert!(EstimationError::PerfectSeparationDetected {
244            iteration: 3,
245            max_abs_eta: 50.0
246        }
247        .is_inner_solve_retreat());
248    }
249
250    #[test]
251    fn multinomial_separation_is_retreat() {
252        assert!(EstimationError::MultinomialSeparationDetected {
253            iteration: 1,
254            max_abs_eta: 100.0,
255            active_class_index: 2,
256            row_index: 7
257        }
258        .is_inner_solve_retreat());
259    }
260
261    #[test]
262    fn pirls_did_not_converge_is_retreat() {
263        assert!(EstimationError::PirlsDidNotConverge {
264            max_iterations: 100,
265            last_change: 1e-3
266        }
267        .is_inner_solve_retreat());
268    }
269
270    #[test]
271    fn invalid_input_is_not_retreat() {
272        assert!(
273            !EstimationError::InvalidInput("bad".to_string()).is_inner_solve_retreat()
274        );
275    }
276
277    #[test]
278    fn reml_optimization_failed_is_not_retreat() {
279        assert!(
280            !EstimationError::RemlOptimizationFailed("outer fail".to_string())
281                .is_inner_solve_retreat()
282        );
283    }
284
285    // ── error message content ─────────────────────────────────────────────────
286
287    #[test]
288    fn invalid_input_message_appears_in_display() {
289        let err = EstimationError::InvalidInput("test_message".to_string());
290        assert!(err.to_string().contains("test_message"));
291    }
292
293    #[test]
294    fn pirls_did_not_converge_mentions_max_iterations() {
295        let err = EstimationError::PirlsDidNotConverge {
296            max_iterations: 42,
297            last_change: 0.001,
298        };
299        assert!(err.to_string().contains("42"));
300    }
301
302    // ── From<LinalgError> ─────────────────────────────────────────────────────
303
304    #[test]
305    fn from_linalg_invalid_input_maps_to_invalid_input() {
306        let linalg_err = LinalgError::InvalidInput("linalg msg".to_string());
307        let err = EstimationError::from(linalg_err);
308        assert!(matches!(err, EstimationError::InvalidInput(_)));
309        assert!(err.to_string().contains("linalg msg"));
310    }
311
312    #[test]
313    fn from_linalg_hessian_not_spd_maps_correctly() {
314        let linalg_err = LinalgError::HessianNotPositiveDefinite { min_eigenvalue: -1.0 };
315        let err = EstimationError::from(linalg_err);
316        assert!(matches!(
317            err,
318            EstimationError::HessianNotPositiveDefinite { .. }
319        ));
320    }
321}