1use ndarray::Array1;
18use std::collections::BTreeMap;
19use std::fmt;
20use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering};
21
22pub static H_MIN_EIG_LOG_BUCKET: AtomicI32 = AtomicI32::new(i32::MIN);
30pub static H_MIN_EIG_LOG_COUNT: AtomicUsize = AtomicUsize::new(0);
35pub const MIN_EIG_DIAG_EVERY: usize = 200;
39pub const MIN_EIG_DIAG_THRESHOLD: f64 = 1e-4;
42
43pub fn format_top_abs(values: &Array1<f64>, label: &str, max_items: usize) -> String {
47 if values.is_empty() {
48 return format!("{label}=<empty>");
49 }
50 let mut ranked: Vec<(usize, f64)> = values.iter().copied().enumerate().collect();
51 ranked.sort_by(|(_, left), (_, right)| {
52 right
53 .abs()
54 .partial_cmp(&left.abs())
55 .unwrap_or(std::cmp::Ordering::Equal)
56 });
57 let parts: Vec<String> = ranked
58 .into_iter()
59 .take(max_items)
60 .map(|(idx, value)| format!("{idx}:{value:.3e}"))
61 .collect();
62 format!("{label}=[{}]", parts.join(", "))
63}
64
65pub fn should_emit_h_min_eig_diag(min_eig: f64) -> bool {
68 if !min_eig.is_finite() || min_eig <= 0.0 {
69 return true;
70 }
71 if min_eig >= MIN_EIG_DIAG_THRESHOLD {
72 return false;
73 }
74 let bucket = if min_eig.is_finite() && min_eig > 0.0 {
75 min_eig.log10().floor() as i32
76 } else {
77 i32::MIN
78 };
79 let last = H_MIN_EIG_LOG_BUCKET.load(Ordering::Relaxed);
80 let count = H_MIN_EIG_LOG_COUNT.fetch_add(1, Ordering::Relaxed);
81 if bucket != last || count.is_multiple_of(MIN_EIG_DIAG_EVERY) {
82 H_MIN_EIG_LOG_BUCKET.store(bucket, Ordering::Relaxed);
83 true
84 } else {
85 false
86 }
87}
88
89#[derive(Clone, Debug)]
95pub struct DiagnosticConfig {
96 pub kkt_tolerance: f64,
98 pub rel_error_threshold: f64,
100 pub emitwarnings: bool,
102}
103
104impl Default for DiagnosticConfig {
105 fn default() -> Self {
106 Self {
107 kkt_tolerance: 1e-4,
108 rel_error_threshold: 0.1,
109 emitwarnings: true,
110 }
111 }
112}
113
114#[derive(Clone, Debug)]
116pub struct EnvelopeAudit {
117 pub kkt_residual_norm: f64,
119 pub innerridge: f64,
121 pub outerridge: f64,
123 pub isviolated: bool,
125 pub message: String,
127}
128
129impl fmt::Display for EnvelopeAudit {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 write!(f, "{}", self.message)
132 }
133}
134
135#[derive(Clone, Debug)]
137pub struct SpectralBleedResult {
138 pub penalty_k: usize,
139 pub truncated_energy: f64,
141 pub applied_correction: f64,
143 pub has_bleed: bool,
145 pub message: String,
147}
148
149impl fmt::Display for SpectralBleedResult {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 write!(f, "{}", self.message)
152 }
153}
154
155#[derive(Clone, Debug)]
157pub struct DualRidgeResult {
158 pub pirlsridge: f64,
160 pub costridge: f64,
162 pub gradientridge: f64,
164 pub ridge_impact: f64,
166 pub phantom_penalty: f64,
168 pub has_mismatch: bool,
170 pub message: String,
172}
173
174impl fmt::Display for DualRidgeResult {
175 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176 write!(f, "{}", self.message)
177 }
178}
179
180#[derive(Clone, Debug, PartialEq)]
182pub struct PredictionDiagnostics {
183 pub n_obs: usize,
184 pub mae: f64,
185 pub rmse: f64,
186 pub bias: f64,
187 pub r_squared: Option<f64>,
188 pub residuals: Vec<f64>,
189}
190
191pub const DEFAULT_PROBABILITY_CLIP: f64 = 1.0e-12;
196
197pub const DEFAULT_GAUSSIAN_SCALE_FLOOR: f64 = 1.0e-12;
199
200pub const DEFAULT_CALIBRATION_BINS: usize = 20;
203
204#[derive(Clone, Debug, PartialEq)]
206pub struct ClassificationPredictionMetrics {
207 pub auc: f64,
208 pub precision_recall_auc: f64,
209 pub brier: f64,
210 pub log_loss: f64,
211 pub nagelkerke_r_squared: Option<f64>,
212 pub expected_calibration_error: f64,
213}
214
215fn validate_metric_inputs(
216 metric: &str,
217 observed: &[f64],
218 predicted_mean: &[f64],
219) -> Result<(), String> {
220 if observed.is_empty() {
221 return Err(format!("{metric} requires at least one observation"));
222 }
223 if observed.len() != predicted_mean.len() {
224 return Err(format!(
225 "{metric} length mismatch: observed={} predicted={}",
226 observed.len(),
227 predicted_mean.len()
228 ));
229 }
230 if let Some((index, value)) = observed
231 .iter()
232 .copied()
233 .enumerate()
234 .find(|(_, value)| !value.is_finite())
235 {
236 return Err(format!(
237 "{metric}: observed[{index}] must be finite; got {value}"
238 ));
239 }
240 if let Some((index, value)) = predicted_mean
241 .iter()
242 .copied()
243 .enumerate()
244 .find(|(_, value)| !value.is_finite())
245 {
246 return Err(format!(
247 "{metric}: predicted_mean[{index}] must be finite; got {value}"
248 ));
249 }
250 Ok(())
251}
252
253fn validate_probability_clip(metric: &str, probability_clip: f64) -> Result<(), String> {
254 if !(probability_clip.is_finite() && probability_clip > 0.0 && probability_clip < 0.5) {
255 return Err(format!(
256 "{metric}: probability_clip must be finite and in (0, 0.5); got {probability_clip}"
257 ));
258 }
259 Ok(())
260}
261
262fn validate_probability_inputs(
263 metric: &str,
264 observed: &[f64],
265 predicted_mean: &[f64],
266) -> Result<(), String> {
267 validate_metric_inputs(metric, observed, predicted_mean)?;
268 if let Some((index, value)) = observed
269 .iter()
270 .copied()
271 .enumerate()
272 .find(|(_, value)| *value < 0.0 || *value > 1.0)
273 {
274 return Err(format!(
275 "{metric}: observed[{index}] must be in [0, 1]; got {value}"
276 ));
277 }
278 if let Some((index, value)) = predicted_mean
279 .iter()
280 .copied()
281 .enumerate()
282 .find(|(_, value)| *value < 0.0 || *value > 1.0)
283 {
284 return Err(format!(
285 "{metric}: predicted_mean[{index}] must be in [0, 1]; got {value}"
286 ));
287 }
288 Ok(())
289}
290
291pub fn auc_from_predictions(observed: &[f64], predicted_mean: &[f64]) -> Result<f64, String> {
294 weighted_auc_from_predictions(observed, predicted_mean, None)
295}
296
297pub fn weighted_auc_from_predictions(
300 observed: &[f64],
301 predicted_mean: &[f64],
302 weights: Option<&[f64]>,
303) -> Result<f64, String> {
304 validate_metric_inputs("auc", observed, predicted_mean)?;
305 if let Some(weights) = weights {
306 if weights.len() != observed.len() {
307 return Err(format!(
308 "auc length mismatch: observed={} weights={}",
309 observed.len(),
310 weights.len()
311 ));
312 }
313 if let Some((index, value)) = weights
314 .iter()
315 .copied()
316 .enumerate()
317 .find(|(_, value)| !value.is_finite() || *value < 0.0)
318 {
319 return Err(format!(
320 "auc: weights[{index}] must be finite and non-negative; got {value}"
321 ));
322 }
323 }
324
325 let weight_at = |index: usize| weights.map_or(1.0, |values| values[index]);
326 let mut pairs: Vec<(f64, bool, f64)> = observed
327 .iter()
328 .zip(predicted_mean)
329 .enumerate()
330 .map(|(index, (&y, &prediction))| (prediction, y > 0.5, weight_at(index)))
331 .collect();
332 let positive_scale = pairs
336 .iter()
337 .filter(|(_, positive, _)| *positive)
338 .map(|(_, _, weight)| weight)
339 .copied()
340 .fold(0.0_f64, f64::max);
341 let negative_scale = pairs
342 .iter()
343 .filter(|(_, positive, _)| !*positive)
344 .map(|(_, _, weight)| weight)
345 .copied()
346 .fold(0.0_f64, f64::max);
347 if positive_scale <= 0.0 || negative_scale <= 0.0 {
348 return Ok(0.5);
349 }
350 let positive_weight: f64 = pairs
351 .iter()
352 .filter(|(_, positive, _)| *positive)
353 .map(|(_, _, weight)| weight / positive_scale)
354 .sum();
355 let negative_weight: f64 = pairs
356 .iter()
357 .filter(|(_, positive, _)| !*positive)
358 .map(|(_, _, weight)| weight / negative_scale)
359 .sum();
360 pairs.sort_by(|(left, _, _), (right, _, _)| left.total_cmp(right));
361
362 let mut concordant = 0.0_f64;
363 let mut negative_weight_below = 0.0_f64;
364 let mut start = 0usize;
365 while start < pairs.len() {
366 let mut end = start + 1;
367 while end < pairs.len() && pairs[end].0 == pairs[start].0 {
368 end += 1;
369 }
370 let positive_in_group: f64 = pairs[start..end]
371 .iter()
372 .filter(|(_, positive, _)| *positive)
373 .map(|(_, _, weight)| (weight / positive_scale) / positive_weight)
374 .sum();
375 let negative_in_group: f64 = pairs[start..end]
376 .iter()
377 .filter(|(_, positive, _)| !*positive)
378 .map(|(_, _, weight)| (weight / negative_scale) / negative_weight)
379 .sum();
380 concordant += positive_in_group * negative_weight_below;
381 concordant += 0.5 * positive_in_group * negative_in_group;
382 negative_weight_below += negative_in_group;
383 start = end;
384 }
385 let auc = concordant;
386 if auc.is_finite() {
387 Ok(auc)
388 } else {
389 Err("auc: weighted pair total is not representable in f64".to_string())
390 }
391}
392
393pub fn brier_from_predictions(observed: &[f64], predicted_mean: &[f64]) -> Result<f64, String> {
395 validate_probability_inputs("brier", observed, predicted_mean)?;
396 let diagnostics = diagnostics_from_predictions(observed, predicted_mean)?;
397 Ok(diagnostics.rmse * diagnostics.rmse)
398}
399
400pub fn binary_log_loss_from_predictions(
402 observed: &[f64],
403 predicted_mean: &[f64],
404 probability_clip: f64,
405) -> Result<f64, String> {
406 validate_probability_inputs("log_loss", observed, predicted_mean)?;
407 validate_probability_clip("log_loss", probability_clip)?;
408 let loss = observed
409 .iter()
410 .zip(predicted_mean)
411 .map(|(&y, &prediction)| {
412 let probability = prediction.clamp(probability_clip, 1.0 - probability_clip);
413 -(y * probability.ln() + (1.0 - y) * (1.0 - probability).ln())
414 })
415 .sum::<f64>()
416 / observed.len() as f64;
417 if loss.is_finite() {
418 Ok(loss)
419 } else {
420 Err("log_loss: result is not representable in f64".to_string())
421 }
422}
423
424pub fn nagelkerke_r_squared_from_log_likelihoods(
427 model_log_likelihood: f64,
428 null_log_likelihood: f64,
429 n_observations: usize,
430) -> Option<f64> {
431 if n_observations == 0 || !model_log_likelihood.is_finite() || !null_log_likelihood.is_finite()
432 {
433 return None;
434 }
435 let scale = 2.0 / n_observations as f64;
436 let cox_snell = -(scale * (null_log_likelihood - model_log_likelihood)).exp_m1();
437 let maximum_cox_snell = -(scale * null_log_likelihood).exp_m1();
438 if cox_snell.is_finite() && maximum_cox_snell.is_finite() && maximum_cox_snell > 0.0 {
439 Some(cox_snell / maximum_cox_snell)
440 } else {
441 None
442 }
443}
444
445pub fn nagelkerke_r_squared_from_predictions(
447 observed: &[f64],
448 predicted_mean: &[f64],
449 null_mean: f64,
450 probability_clip: f64,
451) -> Result<Option<f64>, String> {
452 if observed.is_empty() {
453 return Ok(None);
454 }
455 validate_probability_inputs("nagelkerke_r_squared", observed, predicted_mean)?;
456 validate_probability_clip("nagelkerke_r_squared", probability_clip)?;
457 if !null_mean.is_finite() || null_mean <= 0.0 || null_mean >= 1.0 {
458 return Ok(None);
459 }
460
461 let log_null = null_mean.ln();
462 let log_not_null = (1.0 - null_mean).ln();
463 let null_log_likelihood = observed
464 .iter()
465 .map(|&y| y * log_null + (1.0 - y) * log_not_null)
466 .sum::<f64>();
467 let model_log_likelihood = observed
468 .iter()
469 .zip(predicted_mean)
470 .map(|(&y, &prediction)| {
471 let probability = prediction.clamp(probability_clip, 1.0 - probability_clip);
472 y * probability.ln() + (1.0 - y) * (1.0 - probability).ln()
473 })
474 .sum::<f64>();
475 Ok(nagelkerke_r_squared_from_log_likelihoods(
476 model_log_likelihood,
477 null_log_likelihood,
478 observed.len(),
479 ))
480}
481
482pub fn precision_recall_auc_from_predictions(
486 observed: &[f64],
487 predicted_mean: &[f64],
488) -> Result<f64, String> {
489 validate_metric_inputs("precision_recall_auc", observed, predicted_mean)?;
490 let mut pairs: Vec<(f64, bool)> = observed
491 .iter()
492 .zip(predicted_mean)
493 .map(|(&y, &prediction)| (prediction, y > 0.5))
494 .collect();
495 let positives = pairs.iter().filter(|(_, positive)| *positive).count();
496 if positives == 0 {
497 return Ok(0.0);
498 }
499 pairs.sort_by(|(left, _), (right, _)| right.total_cmp(left));
500 let mut true_positives = 0usize;
501 let mut false_positives = 0usize;
502 let mut previous_precision = 1.0_f64;
503 let mut previous_recall = 0.0_f64;
504 let mut area = 0.0_f64;
505 let mut start = 0usize;
506 while start < pairs.len() {
507 let score = pairs[start].0;
508 let mut end = start;
509 while end < pairs.len() && pairs[end].0 == score {
510 if pairs[end].1 {
511 true_positives += 1;
512 } else {
513 false_positives += 1;
514 }
515 end += 1;
516 }
517 let precision = true_positives as f64 / (true_positives + false_positives) as f64;
518 let recall = true_positives as f64 / positives as f64;
519 area += 0.5 * (precision + previous_precision) * (recall - previous_recall);
520 previous_precision = precision;
521 previous_recall = recall;
522 start = end;
523 }
524 Ok(area)
525}
526
527pub fn expected_calibration_error_from_predictions(
529 observed: &[f64],
530 predicted_mean: &[f64],
531 n_bins: usize,
532) -> Result<f64, String> {
533 validate_probability_inputs("expected_calibration_error", observed, predicted_mean)?;
534 if n_bins == 0 {
535 return Err("expected_calibration_error requires at least one bin".to_string());
536 }
537 let mut bins: BTreeMap<usize, (usize, f64, f64)> = BTreeMap::new();
541 for (&y, &prediction) in observed.iter().zip(predicted_mean) {
542 let index = ((prediction.clamp(0.0, 1.0) * n_bins as f64).floor() as usize).min(n_bins - 1);
543 let bin = bins.entry(index).or_insert((0, 0.0, 0.0));
544 bin.0 += 1;
545 bin.1 += y;
546 bin.2 += prediction;
547 }
548 let n = observed.len() as f64;
549 Ok(bins
550 .into_values()
551 .map(|(count, observed_sum, predicted_sum)| {
552 let count = count as f64;
553 (count / n) * ((observed_sum / count) - (predicted_sum / count)).abs()
554 })
555 .sum())
556}
557
558pub fn gaussian_log_loss_from_predictions(
561 observed: &[f64],
562 predicted_mean: &[f64],
563 sigma: &[f64],
564 sigma_floor: f64,
565) -> Result<f64, String> {
566 validate_metric_inputs("gaussian_log_loss", observed, predicted_mean)?;
567 if sigma.len() != 1 && sigma.len() != observed.len() {
568 return Err(format!(
569 "gaussian_log_loss: sigma length must be 1 or {}; got {}",
570 observed.len(),
571 sigma.len()
572 ));
573 }
574 if !(sigma_floor.is_finite() && sigma_floor > 0.0) {
575 return Err(format!(
576 "gaussian_log_loss: sigma_floor must be finite and positive; got {sigma_floor}"
577 ));
578 }
579 let shared_sigma = sigma.len() == 1;
580 let mut total = 0.0_f64;
581 for (index, (&y, &mean)) in observed.iter().zip(predicted_mean).enumerate() {
582 let raw_sigma = if shared_sigma { sigma[0] } else { sigma[index] };
583 if !raw_sigma.is_finite() || raw_sigma <= 0.0 {
584 return Err(format!(
585 "gaussian_log_loss: sigma[{}] must be finite and positive; got {raw_sigma}",
586 if shared_sigma { 0 } else { index }
587 ));
588 }
589 let sigma = raw_sigma.max(sigma_floor);
590 let standardized_residual = (y - mean) / sigma;
591 total += 0.5 * std::f64::consts::TAU.ln()
592 + sigma.ln()
593 + 0.5 * standardized_residual * standardized_residual;
594 }
595 let loss = total / observed.len() as f64;
596 if loss.is_finite() {
597 Ok(loss)
598 } else {
599 Err("gaussian_log_loss: result is not representable in f64".to_string())
600 }
601}
602
603pub fn classification_metrics_from_predictions(
605 observed: &[f64],
606 predicted_mean: &[f64],
607 null_mean: f64,
608) -> Result<ClassificationPredictionMetrics, String> {
609 validate_probability_inputs("classification_metrics", observed, predicted_mean)?;
610 Ok(ClassificationPredictionMetrics {
611 auc: auc_from_predictions(observed, predicted_mean)?,
612 precision_recall_auc: precision_recall_auc_from_predictions(observed, predicted_mean)?,
613 brier: brier_from_predictions(observed, predicted_mean)?,
614 log_loss: binary_log_loss_from_predictions(
615 observed,
616 predicted_mean,
617 DEFAULT_PROBABILITY_CLIP,
618 )?,
619 nagelkerke_r_squared: nagelkerke_r_squared_from_predictions(
620 observed,
621 predicted_mean,
622 null_mean,
623 DEFAULT_PROBABILITY_CLIP,
624 )?,
625 expected_calibration_error: expected_calibration_error_from_predictions(
626 observed,
627 predicted_mean,
628 DEFAULT_CALIBRATION_BINS,
629 )?,
630 })
631}
632
633pub fn diagnostics_from_predictions(
635 observed: &[f64],
636 predicted_mean: &[f64],
637) -> Result<PredictionDiagnostics, String> {
638 if observed.is_empty() {
639 return Err("diagnostics_from_predictions requires at least one observation".to_string());
640 }
641 if observed.len() != predicted_mean.len() {
642 return Err(format!(
643 "diagnostics_from_predictions length mismatch: observed has {} values but predicted mean has {}",
644 observed.len(),
645 predicted_mean.len()
646 ));
647 }
648 if observed.iter().any(|value| !value.is_finite()) {
649 return Err("observed values must contain only finite numbers".to_string());
650 }
651 if predicted_mean.iter().any(|value| !value.is_finite()) {
652 return Err("predicted mean values must contain only finite numbers".to_string());
653 }
654
655 let n_obs = observed.len();
656 let n_obs_f = n_obs as f64;
657 let mut residuals = Vec::with_capacity(n_obs);
658 let mut abs_sum = 0.0_f64;
659 let mut residual_sum = 0.0_f64;
660 let mut residual_sum_squares = 0.0_f64;
661 let mut observed_sum = 0.0_f64;
662 for (obs, pred) in observed.iter().zip(predicted_mean.iter()) {
663 let residual = obs - pred;
664 residuals.push(residual);
665 abs_sum += residual.abs();
666 residual_sum += residual;
667 residual_sum_squares += residual * residual;
668 observed_sum += obs;
669 }
670
671 let observed_mean = observed_sum / n_obs_f;
672 let total_sum_squares = observed
673 .iter()
674 .map(|value| {
675 let centered = value - observed_mean;
676 centered * centered
677 })
678 .sum::<f64>();
679 let r_squared = if total_sum_squares > 0.0 {
680 Some(1.0 - residual_sum_squares / total_sum_squares)
681 } else {
682 None
683 };
684
685 Ok(PredictionDiagnostics {
686 n_obs,
687 mae: abs_sum / n_obs_f,
688 rmse: (residual_sum_squares / n_obs_f).sqrt(),
689 bias: residual_sum / n_obs_f,
690 r_squared,
691 residuals,
692 })
693}
694
695#[derive(Clone, Debug, Default)]
697pub struct GradientDiagnosticReport {
698 pub envelopeaudit: Option<EnvelopeAudit>,
700 pub spectral_bleed: Vec<SpectralBleedResult>,
702 pub dualridge: Option<DualRidgeResult>,
704}
705
706impl GradientDiagnosticReport {
707 pub fn new() -> Self {
709 Self::default()
710 }
711
712 pub fn summary(&self) -> String {
714 let mut lines = Vec::new();
715
716 if let Some(ref audit) = self.envelopeaudit
717 && audit.isviolated
718 {
719 lines.push(format!("[DIAG] {}", audit));
720 }
721
722 for bleed in &self.spectral_bleed {
723 if bleed.has_bleed {
724 lines.push(format!("[DIAG] {}", bleed));
725 }
726 }
727
728 if let Some(ref ridge) = self.dualridge
729 && ridge.has_mismatch
730 {
731 lines.push(format!("[DIAG] {}", ridge));
732 }
733
734 if lines.is_empty() {
735 "No gradient diagnostic issues detected.".to_string()
736 } else {
737 lines.join("\n")
738 }
739 }
740}
741
742pub fn compute_envelopeaudit(
760 kkt_residual_norm: f64,
761 referencegradient: &Array1<f64>,
762 ridge_used: f64,
763 ridge_assumed: f64,
764 beta: &Array1<f64>,
765 abs_tolerance: f64,
766 rel_tolerance: f64,
767) -> EnvelopeAudit {
768 let kkt_norm = kkt_residual_norm;
769 let penalty_norm = referencegradient.dot(referencegradient).sqrt();
770 let beta_norm = beta.dot(beta).sqrt();
771 let scale = penalty_norm.max((ridge_assumed.abs() * beta_norm).max(1e-12));
772 let rel_kkt = if scale > 0.0 { kkt_norm / scale } else { 0.0 };
773 let ridge_mismatch = (ridge_used - ridge_assumed).abs() > 1e-12;
774 let kktviolation = kkt_norm > abs_tolerance && rel_kkt > rel_tolerance;
775 let isviolated = kktviolation || ridge_mismatch;
776
777 let message = if ridge_mismatch && kktviolation {
778 format!(
779 "Envelope Violation: Inner solver ridge = {:.2e}, Outer gradient assumes ridge = {:.2e}. \
780 KKT residual norm = {:.2e} (abs tol = {:.2e}, rel tol = {:.2e}). Unaccounted gradient energy: {:.2e}",
781 ridge_used, ridge_assumed, kkt_norm, abs_tolerance, rel_tolerance, kkt_norm
782 )
783 } else if ridge_mismatch {
784 format!(
785 "Ridge Mismatch: PIRLS optimized for H + {:.2e}*I, but Gradient calculated for H + {:.2e}*I",
786 ridge_used, ridge_assumed
787 )
788 } else if kktviolation {
789 format!(
790 "Envelope Violation: KKT residual ||∇_β L|| = {:.2e} (rel {:.2e}) exceeds tolerances (abs {:.2e}, rel {:.2e}). \
791 Inner solver may not have converged to true stationary point.",
792 kkt_norm, rel_kkt, abs_tolerance, rel_tolerance
793 )
794 } else {
795 format!(
796 "Envelope OK: KKT residual = {:.2e} (rel {:.2e}), ridge match = {:.2e}",
797 kkt_norm, rel_kkt, ridge_used
798 )
799 };
800
801 EnvelopeAudit {
802 kkt_residual_norm: kkt_norm,
803 innerridge: ridge_used,
804 outerridge: ridge_assumed,
805 isviolated,
806 message,
807 }
808}
809
810pub fn compute_dualridge_check(
827 pirlsridge: f64,
828 costridge: f64,
829 gradientridge: f64,
830 beta: &Array1<f64>,
831) -> DualRidgeResult {
832 let beta_norm_sq = beta.dot(beta);
833 let beta_norm = beta_norm_sq.sqrt();
834
835 let ridge_impact = pirlsridge * beta_norm;
836 let phantom_penalty = 0.5 * pirlsridge * beta_norm_sq;
837
838 let pirlscost_mismatch = (pirlsridge - costridge).abs() > 1e-12;
839 let pirlsgrad_mismatch = (pirlsridge - gradientridge).abs() > 1e-12;
840 let costgrad_mismatch = (costridge - gradientridge).abs() > 1e-12;
841 let has_mismatch = pirlscost_mismatch || pirlsgrad_mismatch || costgrad_mismatch;
842
843 let message = if has_mismatch {
844 let mut mismatches = Vec::new();
845 if pirlscost_mismatch {
846 mismatches.push(format!(
847 "PIRLS({:.2e}) vs Cost({:.2e})",
848 pirlsridge, costridge
849 ));
850 }
851 if pirlsgrad_mismatch {
852 mismatches.push(format!(
853 "PIRLS({:.2e}) vs Gradient({:.2e})",
854 pirlsridge, gradientridge
855 ));
856 }
857 if costgrad_mismatch {
858 mismatches.push(format!(
859 "Cost({:.2e}) vs Gradient({:.2e})",
860 costridge, gradientridge
861 ));
862 }
863 format!(
864 "Ridge Mismatch detected: {}. Effective ridge impact on ||β|| = {:.2e}. \
865 Phantom penalty = {:.2e}. The surface being differentiated differs from \
866 the surface being optimized.",
867 mismatches.join(", "),
868 ridge_impact,
869 phantom_penalty
870 )
871 } else if pirlsridge > 0.0 {
872 format!(
873 "Ridge Consistency OK: All stages use ridge = {:.2e}. ||β|| = {:.2e}, phantom penalty = {:.2e}",
874 pirlsridge, beta_norm, phantom_penalty
875 )
876 } else {
877 "Ridge Consistency OK: No stabilization ridge required.".to_string()
878 };
879
880 DualRidgeResult {
881 pirlsridge,
882 costridge,
883 gradientridge,
884 ridge_impact,
885 phantom_penalty,
886 has_mismatch,
887 message,
888 }
889}
890
891#[derive(Clone, Copy, Debug, PartialEq, Eq)]
904pub enum KktRefusalDiagnosis {
905 RankDeficientHPen,
906 PhantomMultiplierWithWellConditionedH,
907 ActiveSetIncomplete,
908 AliasingDetectedAtFit,
914}
915
916impl KktRefusalDiagnosis {
917 pub fn as_str(&self) -> &'static str {
918 match self {
919 KktRefusalDiagnosis::RankDeficientHPen => "rank_deficient_H_pen",
920 KktRefusalDiagnosis::PhantomMultiplierWithWellConditionedH => {
921 "phantom_multiplier_with_well_conditioned_H"
922 }
923 KktRefusalDiagnosis::ActiveSetIncomplete => "active_set_incomplete",
924 KktRefusalDiagnosis::AliasingDetectedAtFit => "aliasing_detected_at_fit",
925 }
926 }
927
928 pub fn parse_from_error(message: &str) -> Option<Self> {
932 let marker = "diagnosis: ";
933 let start = message.rfind(marker)? + marker.len();
934 let tail = &message[start..];
935 let end = tail
936 .find(|c: char| c == ';' || c == '\n' || c == ' ')
937 .unwrap_or(tail.len());
938 match &tail[..end] {
939 "rank_deficient_H_pen" => Some(KktRefusalDiagnosis::RankDeficientHPen),
940 "phantom_multiplier_with_well_conditioned_H" => {
941 Some(KktRefusalDiagnosis::PhantomMultiplierWithWellConditionedH)
942 }
943 "active_set_incomplete" => Some(KktRefusalDiagnosis::ActiveSetIncomplete),
944 "aliasing_detected_at_fit" => Some(KktRefusalDiagnosis::AliasingDetectedAtFit),
945 _ => None,
946 }
947 }
948
949 pub fn guidance(self) -> &'static str {
950 match self {
951 KktRefusalDiagnosis::RankDeficientHPen => {
952 "check whether the named block has a structural or numerical null direction \
953 not identified by the likelihood/penalty combination; for Duchon-style \
954 smooths this may be a polynomial null space, while marginal-slope fits can \
955 also expose callback-owned weak directions"
956 }
957 KktRefusalDiagnosis::PhantomMultiplierWithWellConditionedH => {
958 "check whether the named block has a near-separated or weakly identified \
959 direction despite a well-conditioned penalized Hessian; in marginal-slope \
960 fits this often indicates marginal/logslope coupling rather than a \
961 Matérn/Duchon polynomial-nullspace failure"
962 }
963 KktRefusalDiagnosis::ActiveSetIncomplete => {
964 "check whether the named block's linear constraints need an additional \
965 active row or a tighter constrained re-solve; this is an active-set \
966 certification failure, not a polynomial-nullspace diagnosis"
967 }
968 KktRefusalDiagnosis::AliasingDetectedAtFit => {
969 "check whether the named block aliases another block after runtime \
970 constraints or callbacks materialize; drop or reparameterize the aliased \
971 direction before fitting"
972 }
973 }
974 }
975}
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980 use ndarray::arr1;
981
982 #[test]
983 fn test_envelopeaudit_noviolation() {
984 let reference = arr1(&[0.0, 0.0, 0.0]);
985 let beta = arr1(&[0.1, 0.2, 0.3]);
986 let result = compute_envelopeaudit(0.0, &reference, 0.0, 0.0, &beta, 1e-8, 1e-6);
987
988 assert!(!result.isviolated);
989 }
990
991 #[test]
992 fn test_envelopeaudit_detects_ridge_mismatch() {
993 let reference = arr1(&[1.0, 0.0, 0.0]);
994 let beta = arr1(&[0.1, 0.2, 0.3]);
995 let result = compute_envelopeaudit(1e-10, &reference, 0.1, 0.0, &beta, 1e-8, 1e-6);
996
997 assert!(result.isviolated);
998 assert!(result.message.contains("Ridge Mismatch"));
999 }
1000
1001 #[test]
1002 fn test_dualridge_check_no_mismatch() {
1003 let beta = arr1(&[0.1, 0.2, 0.3]);
1004 let result = compute_dualridge_check(0.0, 0.0, 0.0, &beta);
1005
1006 assert!(!result.has_mismatch);
1007 }
1008
1009 #[test]
1010 fn test_dualridge_check_detects_mismatch() {
1011 let beta = arr1(&[0.1, 0.2, 0.3]);
1012 let result = compute_dualridge_check(1e-4, 0.0, 0.0, &beta);
1013
1014 assert!(result.has_mismatch);
1015 assert!(result.message.contains("Ridge Mismatch detected"));
1016 }
1017
1018 #[test]
1019 fn diagnostics_from_predictions_computes_residual_metrics() {
1020 let observed = [1.0, 2.0, 4.0];
1021 let predicted = [1.5, 1.5, 3.0];
1022
1023 let result = diagnostics_from_predictions(&observed, &predicted).unwrap();
1024
1025 assert_eq!(result.residuals, vec![-0.5, 0.5, 1.0]);
1026 assert_eq!(result.n_obs, 3);
1027 assert_eq!(result.mae, 2.0 / 3.0);
1028 assert_eq!(result.bias, 1.0 / 3.0);
1029 assert_eq!(result.rmse, (1.5_f64 / 3.0).sqrt());
1030 assert_eq!(result.r_squared, Some(1.0 - 1.5 / (14.0 / 3.0)));
1031 }
1032
1033 #[test]
1034 fn diagnostics_from_predictions_omits_r_squared_for_constant_observed() {
1035 let observed = [2.0, 2.0];
1036 let predicted = [1.0, 3.0];
1037
1038 let result = diagnostics_from_predictions(&observed, &predicted).unwrap();
1039
1040 assert_eq!(result.r_squared, None);
1041 }
1042
1043 #[test]
1044 fn diagnostics_from_predictions_rejects_invalid_inputs() {
1045 assert_eq!(
1046 diagnostics_from_predictions(&[], &[]),
1047 Err("diagnostics_from_predictions requires at least one observation".to_string())
1048 );
1049 assert_eq!(
1050 diagnostics_from_predictions(&[1.0], &[1.0, 2.0]),
1051 Err(
1052 "diagnostics_from_predictions length mismatch: observed has 1 values but predicted mean has 2"
1053 .to_string()
1054 )
1055 );
1056 assert_eq!(
1057 diagnostics_from_predictions(&[f64::NAN], &[1.0]),
1058 Err("observed values must contain only finite numbers".to_string())
1059 );
1060 assert_eq!(
1061 diagnostics_from_predictions(&[1.0], &[f64::INFINITY]),
1062 Err("predicted mean values must contain only finite numbers".to_string())
1063 );
1064 }
1065
1066 #[test]
1067 fn auc_is_tie_aware_and_weighted_auc_reduces_to_unit_weights() {
1068 let observed = [0.0, 1.0, 0.0, 1.0];
1069 let predicted = [0.1, 0.8, 0.8, 0.9];
1070 let auc = auc_from_predictions(&observed, &predicted).unwrap();
1071 assert_eq!(auc, 0.875);
1072 assert_eq!(
1073 weighted_auc_from_predictions(&observed, &predicted, Some(&[1.0; 4])).unwrap(),
1074 auc
1075 );
1076
1077 let weighted = weighted_auc_from_predictions(
1078 &[1.0, 0.0, 1.0],
1079 &[0.5, 0.5, 0.9],
1080 Some(&[2.0, 3.0, 1.0]),
1081 )
1082 .unwrap();
1083 assert_eq!(weighted, 2.0 / 3.0);
1084 assert!(
1085 weighted_auc_from_predictions(&[0.0, 1.0], &[0.2, 0.8], Some(&[1.0, -1.0])).is_err()
1086 );
1087 assert_eq!(
1088 weighted_auc_from_predictions(&[1.0, 0.0], &[0.5, 0.5], Some(&[f64::MAX, f64::MAX]),)
1089 .unwrap(),
1090 0.5
1091 );
1092 }
1093
1094 #[test]
1095 fn precision_recall_auc_consumes_ties_as_one_threshold() {
1096 let first = precision_recall_auc_from_predictions(&[1.0, 0.0], &[0.5, 0.5]).unwrap();
1097 let reversed = precision_recall_auc_from_predictions(&[0.0, 1.0], &[0.5, 0.5]).unwrap();
1098 assert_eq!(first, 0.75);
1099 assert_eq!(first, reversed);
1100 }
1101
1102 #[test]
1103 fn probability_scores_match_closed_forms() {
1104 let observed = [0.0, 1.0];
1105 let predicted = [0.5, 0.5];
1106 let log_loss =
1107 binary_log_loss_from_predictions(&observed, &predicted, DEFAULT_PROBABILITY_CLIP)
1108 .unwrap();
1109 assert!((log_loss - std::f64::consts::LN_2).abs() < 1.0e-15);
1110 assert_eq!(brier_from_predictions(&observed, &predicted).unwrap(), 0.25);
1111 assert_eq!(
1112 expected_calibration_error_from_predictions(&observed, &predicted, 2).unwrap(),
1113 0.0
1114 );
1115 assert!(
1116 binary_log_loss_from_predictions(&observed, &predicted, 0.5).is_err(),
1117 "a clip that collapses the probability interval must be rejected"
1118 );
1119 assert!(classification_metrics_from_predictions(&observed, &[0.5, 2.0], 0.5).is_err());
1120 assert!(classification_metrics_from_predictions(&[-0.1, 1.0], &predicted, 0.5).is_err());
1121 }
1122
1123 #[test]
1124 fn nagelkerke_and_classification_panel_share_the_core_kernels() {
1125 let observed = [0.0, 0.0, 1.0, 1.0];
1126 let predicted = [0.1, 0.2, 0.8, 0.9];
1127 let metrics = classification_metrics_from_predictions(&observed, &predicted, 0.5).unwrap();
1128 assert_eq!(metrics.auc, 1.0);
1129 assert_eq!(metrics.precision_recall_auc, 1.0);
1130 assert_eq!(
1131 metrics.nagelkerke_r_squared,
1132 nagelkerke_r_squared_from_predictions(
1133 &observed,
1134 &predicted,
1135 0.5,
1136 DEFAULT_PROBABILITY_CLIP,
1137 )
1138 .unwrap()
1139 );
1140 assert!(metrics.nagelkerke_r_squared.unwrap() > 0.8);
1141 assert_eq!(
1142 nagelkerke_r_squared_from_predictions(
1143 &observed,
1144 &predicted,
1145 1.0,
1146 DEFAULT_PROBABILITY_CLIP,
1147 )
1148 .unwrap(),
1149 None
1150 );
1151 }
1152
1153 #[test]
1154 fn gaussian_log_loss_matches_closed_form_and_rejects_invalid_sigma() {
1155 let observed = [1.0, 2.0, 3.0, 4.0];
1156 let predicted = observed;
1157 let sigma = [1.5];
1158 let got = gaussian_log_loss_from_predictions(
1159 &observed,
1160 &predicted,
1161 &sigma,
1162 DEFAULT_GAUSSIAN_SCALE_FLOOR,
1163 )
1164 .unwrap();
1165 let expected = 0.5 * (std::f64::consts::TAU * 1.5 * 1.5).ln();
1166 assert!((got - expected).abs() < 1.0e-12);
1167 assert!(
1168 gaussian_log_loss_from_predictions(
1169 &observed,
1170 &predicted,
1171 &[1.0, 2.0],
1172 DEFAULT_GAUSSIAN_SCALE_FLOOR,
1173 )
1174 .is_err()
1175 );
1176 assert!(
1177 gaussian_log_loss_from_predictions(
1178 &observed,
1179 &predicted,
1180 &[0.0],
1181 DEFAULT_GAUSSIAN_SCALE_FLOOR,
1182 )
1183 .is_err()
1184 );
1185 }
1186}