1use gam_linalg::LinalgError;
2use gam_linalg::faer_ndarray::FaerLinalgError;
3
4use crate::{BasisError, CustomFamilyError, MonotoneRootError};
5
6#[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
186impl 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 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 #[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 #[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 #[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}