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
350 .iter()
351 .filter(|(_, positive, _)| *positive)
352 .map(|(_, _, weight)| weight)
353 .copied()
354 .fold(0.0_f64, f64::max);
355 let negative_scale = pairs
356 .iter()
357 .filter(|(_, positive, _)| !*positive)
358 .map(|(_, _, weight)| weight)
359 .copied()
360 .fold(0.0_f64, f64::max);
361 if positive_scale <= 0.0 || negative_scale <= 0.0 {
362 return Ok(0.5);
363 }
364 let positive_weight: f64 = pairs
365 .iter()
366 .filter(|(_, positive, _)| *positive)
367 .map(|(_, _, weight)| weight / positive_scale)
368 .sum();
369 let negative_weight: f64 = pairs
370 .iter()
371 .filter(|(_, positive, _)| !*positive)
372 .map(|(_, _, weight)| weight / negative_scale)
373 .sum();
374 pairs.sort_by(|(left, _, _), (right, _, _)| left.total_cmp(right));
375
376 let mut concordant = 0.0_f64;
377 let mut negative_weight_below = 0.0_f64;
378 let mut start = 0usize;
379 while start < pairs.len() {
380 let mut end = start + 1;
381 while end < pairs.len() && pairs[end].0 == pairs[start].0 {
382 end += 1;
383 }
384 let positive_in_group: f64 = pairs[start..end]
385 .iter()
386 .filter(|(_, positive, _)| *positive)
387 .map(|(_, _, weight)| weight / positive_scale)
388 .sum();
389 let negative_in_group: f64 = pairs[start..end]
390 .iter()
391 .filter(|(_, positive, _)| !*positive)
392 .map(|(_, _, weight)| weight / negative_scale)
393 .sum();
394 concordant += positive_in_group * negative_weight_below;
395 concordant += 0.5 * positive_in_group * negative_in_group;
396 negative_weight_below += negative_in_group;
397 start = end;
398 }
399 let auc = concordant / (positive_weight * negative_weight);
403 if auc.is_finite() {
404 Ok(auc)
405 } else {
406 Err("auc: weighted pair total is not representable in f64".to_string())
407 }
408}
409
410pub fn brier_from_predictions(observed: &[f64], predicted_mean: &[f64]) -> Result<f64, String> {
412 validate_probability_inputs("brier", observed, predicted_mean)?;
413 let diagnostics = diagnostics_from_predictions(observed, predicted_mean)?;
414 Ok(diagnostics.rmse * diagnostics.rmse)
415}
416
417pub fn binary_log_loss_from_predictions(
419 observed: &[f64],
420 predicted_mean: &[f64],
421 probability_clip: f64,
422) -> Result<f64, String> {
423 validate_probability_inputs("log_loss", observed, predicted_mean)?;
424 validate_probability_clip("log_loss", probability_clip)?;
425 let loss = observed
426 .iter()
427 .zip(predicted_mean)
428 .map(|(&y, &prediction)| {
429 let probability = prediction.clamp(probability_clip, 1.0 - probability_clip);
430 -(y * probability.ln() + (1.0 - y) * (1.0 - probability).ln())
431 })
432 .sum::<f64>()
433 / observed.len() as f64;
434 if loss.is_finite() {
435 Ok(loss)
436 } else {
437 Err("log_loss: result is not representable in f64".to_string())
438 }
439}
440
441pub fn nagelkerke_r_squared_from_log_likelihoods(
444 model_log_likelihood: f64,
445 null_log_likelihood: f64,
446 n_observations: usize,
447) -> Option<f64> {
448 if n_observations == 0 || !model_log_likelihood.is_finite() || !null_log_likelihood.is_finite()
449 {
450 return None;
451 }
452 let scale = 2.0 / n_observations as f64;
453 let cox_snell = -(scale * (null_log_likelihood - model_log_likelihood)).exp_m1();
454 let maximum_cox_snell = -(scale * null_log_likelihood).exp_m1();
455 if cox_snell.is_finite() && maximum_cox_snell.is_finite() && maximum_cox_snell > 0.0 {
456 Some(cox_snell / maximum_cox_snell)
457 } else {
458 None
459 }
460}
461
462pub fn nagelkerke_r_squared_from_predictions(
464 observed: &[f64],
465 predicted_mean: &[f64],
466 null_mean: f64,
467 probability_clip: f64,
468) -> Result<Option<f64>, String> {
469 if observed.is_empty() {
470 return Ok(None);
471 }
472 validate_probability_inputs("nagelkerke_r_squared", observed, predicted_mean)?;
473 validate_probability_clip("nagelkerke_r_squared", probability_clip)?;
474 if !null_mean.is_finite() || null_mean <= 0.0 || null_mean >= 1.0 {
475 return Ok(None);
476 }
477
478 let log_null = null_mean.ln();
479 let log_not_null = (1.0 - null_mean).ln();
480 let null_log_likelihood = observed
481 .iter()
482 .map(|&y| y * log_null + (1.0 - y) * log_not_null)
483 .sum::<f64>();
484 let model_log_likelihood = observed
485 .iter()
486 .zip(predicted_mean)
487 .map(|(&y, &prediction)| {
488 let probability = prediction.clamp(probability_clip, 1.0 - probability_clip);
489 y * probability.ln() + (1.0 - y) * (1.0 - probability).ln()
490 })
491 .sum::<f64>();
492 Ok(nagelkerke_r_squared_from_log_likelihoods(
493 model_log_likelihood,
494 null_log_likelihood,
495 observed.len(),
496 ))
497}
498
499pub fn precision_recall_auc_from_predictions(
503 observed: &[f64],
504 predicted_mean: &[f64],
505) -> Result<f64, String> {
506 validate_metric_inputs("precision_recall_auc", observed, predicted_mean)?;
507 let mut pairs: Vec<(f64, bool)> = observed
508 .iter()
509 .zip(predicted_mean)
510 .map(|(&y, &prediction)| (prediction, y > 0.5))
511 .collect();
512 let positives = pairs.iter().filter(|(_, positive)| *positive).count();
513 if positives == 0 {
514 return Ok(0.0);
515 }
516 pairs.sort_by(|(left, _), (right, _)| right.total_cmp(left));
517 let mut true_positives = 0usize;
518 let mut false_positives = 0usize;
519 let mut previous_precision = 1.0_f64;
520 let mut previous_recall = 0.0_f64;
521 let mut area = 0.0_f64;
522 let mut start = 0usize;
523 while start < pairs.len() {
524 let score = pairs[start].0;
525 let mut end = start;
526 while end < pairs.len() && pairs[end].0 == score {
527 if pairs[end].1 {
528 true_positives += 1;
529 } else {
530 false_positives += 1;
531 }
532 end += 1;
533 }
534 let precision = true_positives as f64 / (true_positives + false_positives) as f64;
535 let recall = true_positives as f64 / positives as f64;
536 area += 0.5 * (precision + previous_precision) * (recall - previous_recall);
537 previous_precision = precision;
538 previous_recall = recall;
539 start = end;
540 }
541 Ok(area)
542}
543
544pub fn expected_calibration_error_from_predictions(
546 observed: &[f64],
547 predicted_mean: &[f64],
548 n_bins: usize,
549) -> Result<f64, String> {
550 validate_probability_inputs("expected_calibration_error", observed, predicted_mean)?;
551 if n_bins == 0 {
552 return Err("expected_calibration_error requires at least one bin".to_string());
553 }
554 let mut bins: BTreeMap<usize, (usize, f64, f64)> = BTreeMap::new();
558 for (&y, &prediction) in observed.iter().zip(predicted_mean) {
559 let index = ((prediction.clamp(0.0, 1.0) * n_bins as f64).floor() as usize).min(n_bins - 1);
560 let bin = bins.entry(index).or_insert((0, 0.0, 0.0));
561 bin.0 += 1;
562 bin.1 += y;
563 bin.2 += prediction;
564 }
565 let n = observed.len() as f64;
566 Ok(bins
567 .into_values()
568 .map(|(count, observed_sum, predicted_sum)| {
569 let count = count as f64;
570 (count / n) * ((observed_sum / count) - (predicted_sum / count)).abs()
571 })
572 .sum())
573}
574
575pub fn gaussian_log_loss_from_predictions(
578 observed: &[f64],
579 predicted_mean: &[f64],
580 sigma: &[f64],
581 sigma_floor: f64,
582) -> Result<f64, String> {
583 validate_metric_inputs("gaussian_log_loss", observed, predicted_mean)?;
584 if sigma.len() != 1 && sigma.len() != observed.len() {
585 return Err(format!(
586 "gaussian_log_loss: sigma length must be 1 or {}; got {}",
587 observed.len(),
588 sigma.len()
589 ));
590 }
591 if !(sigma_floor.is_finite() && sigma_floor > 0.0) {
592 return Err(format!(
593 "gaussian_log_loss: sigma_floor must be finite and positive; got {sigma_floor}"
594 ));
595 }
596 let shared_sigma = sigma.len() == 1;
597 let mut total = 0.0_f64;
598 for (index, (&y, &mean)) in observed.iter().zip(predicted_mean).enumerate() {
599 let raw_sigma = if shared_sigma { sigma[0] } else { sigma[index] };
600 if !raw_sigma.is_finite() || raw_sigma <= 0.0 {
601 return Err(format!(
602 "gaussian_log_loss: sigma[{}] must be finite and positive; got {raw_sigma}",
603 if shared_sigma { 0 } else { index }
604 ));
605 }
606 let sigma = raw_sigma.max(sigma_floor);
607 let standardized_residual = (y - mean) / sigma;
608 total += 0.5 * std::f64::consts::TAU.ln()
609 + sigma.ln()
610 + 0.5 * standardized_residual * standardized_residual;
611 }
612 let loss = total / observed.len() as f64;
613 if loss.is_finite() {
614 Ok(loss)
615 } else {
616 Err("gaussian_log_loss: result is not representable in f64".to_string())
617 }
618}
619
620pub fn classification_metrics_from_predictions(
622 observed: &[f64],
623 predicted_mean: &[f64],
624 null_mean: f64,
625) -> Result<ClassificationPredictionMetrics, String> {
626 validate_probability_inputs("classification_metrics", observed, predicted_mean)?;
627 Ok(ClassificationPredictionMetrics {
628 auc: auc_from_predictions(observed, predicted_mean)?,
629 precision_recall_auc: precision_recall_auc_from_predictions(observed, predicted_mean)?,
630 brier: brier_from_predictions(observed, predicted_mean)?,
631 log_loss: binary_log_loss_from_predictions(
632 observed,
633 predicted_mean,
634 DEFAULT_PROBABILITY_CLIP,
635 )?,
636 nagelkerke_r_squared: nagelkerke_r_squared_from_predictions(
637 observed,
638 predicted_mean,
639 null_mean,
640 DEFAULT_PROBABILITY_CLIP,
641 )?,
642 expected_calibration_error: expected_calibration_error_from_predictions(
643 observed,
644 predicted_mean,
645 DEFAULT_CALIBRATION_BINS,
646 )?,
647 })
648}
649
650pub fn diagnostics_from_predictions(
652 observed: &[f64],
653 predicted_mean: &[f64],
654) -> Result<PredictionDiagnostics, String> {
655 if observed.is_empty() {
656 return Err("diagnostics_from_predictions requires at least one observation".to_string());
657 }
658 if observed.len() != predicted_mean.len() {
659 return Err(format!(
660 "diagnostics_from_predictions length mismatch: observed has {} values but predicted mean has {}",
661 observed.len(),
662 predicted_mean.len()
663 ));
664 }
665 if observed.iter().any(|value| !value.is_finite()) {
666 return Err("observed values must contain only finite numbers".to_string());
667 }
668 if predicted_mean.iter().any(|value| !value.is_finite()) {
669 return Err("predicted mean values must contain only finite numbers".to_string());
670 }
671
672 let n_obs = observed.len();
673 let n_obs_f = n_obs as f64;
674 let mut residuals = Vec::with_capacity(n_obs);
675 let mut abs_sum = 0.0_f64;
676 let mut residual_sum = 0.0_f64;
677 let mut residual_sum_squares = 0.0_f64;
678 let mut observed_sum = 0.0_f64;
679 for (obs, pred) in observed.iter().zip(predicted_mean.iter()) {
680 let residual = obs - pred;
681 residuals.push(residual);
682 abs_sum += residual.abs();
683 residual_sum += residual;
684 residual_sum_squares += residual * residual;
685 observed_sum += obs;
686 }
687
688 let observed_mean = observed_sum / n_obs_f;
689 let total_sum_squares = observed
690 .iter()
691 .map(|value| {
692 let centered = value - observed_mean;
693 centered * centered
694 })
695 .sum::<f64>();
696 let r_squared = if total_sum_squares > 0.0 {
697 Some(1.0 - residual_sum_squares / total_sum_squares)
698 } else {
699 None
700 };
701
702 Ok(PredictionDiagnostics {
703 n_obs,
704 mae: abs_sum / n_obs_f,
705 rmse: (residual_sum_squares / n_obs_f).sqrt(),
706 bias: residual_sum / n_obs_f,
707 r_squared,
708 residuals,
709 })
710}
711
712#[derive(Clone, Debug, Default)]
714pub struct GradientDiagnosticReport {
715 pub envelopeaudit: Option<EnvelopeAudit>,
717 pub spectral_bleed: Vec<SpectralBleedResult>,
719 pub dualridge: Option<DualRidgeResult>,
721}
722
723impl GradientDiagnosticReport {
724 pub fn new() -> Self {
726 Self::default()
727 }
728
729 pub fn summary(&self) -> String {
731 let mut lines = Vec::new();
732
733 if let Some(ref audit) = self.envelopeaudit
734 && audit.isviolated
735 {
736 lines.push(format!("[DIAG] {}", audit));
737 }
738
739 for bleed in &self.spectral_bleed {
740 if bleed.has_bleed {
741 lines.push(format!("[DIAG] {}", bleed));
742 }
743 }
744
745 if let Some(ref ridge) = self.dualridge
746 && ridge.has_mismatch
747 {
748 lines.push(format!("[DIAG] {}", ridge));
749 }
750
751 if lines.is_empty() {
752 "No gradient diagnostic issues detected.".to_string()
753 } else {
754 lines.join("\n")
755 }
756 }
757}
758
759#[derive(Clone, Copy, Debug, PartialEq, Eq)]
780pub enum KktRefusalDiagnosis {
781 RankDeficientHPen,
782 PhantomMultiplierWithWellConditionedH,
783 ActiveSetIncomplete,
784 AliasingDetectedAtFit,
790}
791
792impl KktRefusalDiagnosis {
793 pub fn as_str(&self) -> &'static str {
794 match self {
795 KktRefusalDiagnosis::RankDeficientHPen => "rank_deficient_H_pen",
796 KktRefusalDiagnosis::PhantomMultiplierWithWellConditionedH => {
797 "phantom_multiplier_with_well_conditioned_H"
798 }
799 KktRefusalDiagnosis::ActiveSetIncomplete => "active_set_incomplete",
800 KktRefusalDiagnosis::AliasingDetectedAtFit => "aliasing_detected_at_fit",
801 }
802 }
803
804 pub fn parse_from_error(message: &str) -> Option<Self> {
808 let marker = "diagnosis: ";
809 let start = message.rfind(marker)? + marker.len();
810 let tail = &message[start..];
811 let end = tail
812 .find(|c: char| c == ';' || c == '\n' || c == ' ')
813 .unwrap_or(tail.len());
814 match &tail[..end] {
815 "rank_deficient_H_pen" => Some(KktRefusalDiagnosis::RankDeficientHPen),
816 "phantom_multiplier_with_well_conditioned_H" => {
817 Some(KktRefusalDiagnosis::PhantomMultiplierWithWellConditionedH)
818 }
819 "active_set_incomplete" => Some(KktRefusalDiagnosis::ActiveSetIncomplete),
820 "aliasing_detected_at_fit" => Some(KktRefusalDiagnosis::AliasingDetectedAtFit),
821 _ => None,
822 }
823 }
824
825 pub fn guidance(self) -> &'static str {
826 match self {
827 KktRefusalDiagnosis::RankDeficientHPen => {
828 "check whether the named block has a structural or numerical null direction \
829 not identified by the likelihood/penalty combination; for Duchon-style \
830 smooths this may be a polynomial null space, while marginal-slope fits can \
831 also expose callback-owned weak directions"
832 }
833 KktRefusalDiagnosis::PhantomMultiplierWithWellConditionedH => {
834 "check whether the named block has a near-separated or weakly identified \
835 direction despite a well-conditioned penalized Hessian; in marginal-slope \
836 fits this often indicates marginal/slope coupling rather than a \
837 Matérn/Duchon polynomial-nullspace failure"
838 }
839 KktRefusalDiagnosis::ActiveSetIncomplete => {
840 "check whether the named block's linear constraints need an additional \
841 active row or a tighter constrained re-solve; this is an active-set \
842 certification failure, not a polynomial-nullspace diagnosis"
843 }
844 KktRefusalDiagnosis::AliasingDetectedAtFit => {
845 "check whether the named block aliases another block after runtime \
846 constraints or callbacks materialize; drop or reparameterize the aliased \
847 direction before fitting"
848 }
849 }
850 }
851}
852
853#[cfg(test)]
854mod tests {
855 use super::*;
856
857 #[test]
858 fn diagnostics_from_predictions_computes_residual_metrics() {
859 let observed = [1.0, 2.0, 4.0];
860 let predicted = [1.5, 1.5, 3.0];
861
862 let result = diagnostics_from_predictions(&observed, &predicted).unwrap();
863
864 assert_eq!(result.residuals, vec![-0.5, 0.5, 1.0]);
865 assert_eq!(result.n_obs, 3);
866 assert_eq!(result.mae, 2.0 / 3.0);
867 assert_eq!(result.bias, 1.0 / 3.0);
868 assert_eq!(result.rmse, (1.5_f64 / 3.0).sqrt());
869 assert_eq!(result.r_squared, Some(1.0 - 1.5 / (14.0 / 3.0)));
870 }
871
872 #[test]
873 fn diagnostics_from_predictions_omits_r_squared_for_constant_observed() {
874 let observed = [2.0, 2.0];
875 let predicted = [1.0, 3.0];
876
877 let result = diagnostics_from_predictions(&observed, &predicted).unwrap();
878
879 assert_eq!(result.r_squared, None);
880 }
881
882 #[test]
883 fn diagnostics_from_predictions_rejects_invalid_inputs() {
884 assert_eq!(
885 diagnostics_from_predictions(&[], &[]),
886 Err("diagnostics_from_predictions requires at least one observation".to_string())
887 );
888 assert_eq!(
889 diagnostics_from_predictions(&[1.0], &[1.0, 2.0]),
890 Err(
891 "diagnostics_from_predictions length mismatch: observed has 1 values but predicted mean has 2"
892 .to_string()
893 )
894 );
895 assert_eq!(
896 diagnostics_from_predictions(&[f64::NAN], &[1.0]),
897 Err("observed values must contain only finite numbers".to_string())
898 );
899 assert_eq!(
900 diagnostics_from_predictions(&[1.0], &[f64::INFINITY]),
901 Err("predicted mean values must contain only finite numbers".to_string())
902 );
903 }
904
905 #[test]
906 fn auc_is_tie_aware_and_weighted_auc_reduces_to_unit_weights() {
907 let observed = [0.0, 1.0, 0.0, 1.0];
908 let predicted = [0.1, 0.8, 0.8, 0.9];
909 let auc = auc_from_predictions(&observed, &predicted).unwrap();
910 assert_eq!(auc, 0.875);
911 assert_eq!(
912 weighted_auc_from_predictions(&observed, &predicted, Some(&[1.0; 4])).unwrap(),
913 auc
914 );
915
916 let weighted = weighted_auc_from_predictions(
917 &[1.0, 0.0, 1.0],
918 &[0.5, 0.5, 0.9],
919 Some(&[2.0, 3.0, 1.0]),
920 )
921 .unwrap();
922 assert_eq!(weighted, 2.0 / 3.0);
923 assert!(
924 weighted_auc_from_predictions(&[0.0, 1.0], &[0.2, 0.8], Some(&[1.0, -1.0])).is_err()
925 );
926 assert_eq!(
927 weighted_auc_from_predictions(&[1.0, 0.0], &[0.5, 0.5], Some(&[f64::MAX, f64::MAX]),)
928 .unwrap(),
929 0.5
930 );
931 }
932
933 #[test]
947 fn a_perfectly_separable_ranking_scores_exactly_one() {
948 for (negatives, positives) in [(100usize, 100usize), (97, 63), (13, 501)] {
949 let mut observed = Vec::with_capacity(negatives + positives);
950 let mut predicted = Vec::with_capacity(negatives + positives);
951 for index in 0..negatives {
952 observed.push(0.0);
953 predicted.push(index as f64);
954 }
955 for index in 0..positives {
956 observed.push(1.0);
957 predicted.push((negatives + index) as f64);
958 }
959 let auc = auc_from_predictions(&observed, &predicted).unwrap();
960 assert_eq!(
961 auc, 1.0,
962 "separable ranking with {negatives} negatives and {positives} positives \
963 must score exactly 1.0, got {auc:?}"
964 );
965
966 let reversed: Vec<f64> = observed.iter().map(|y| 1.0 - y).collect();
969 let auc_reversed = auc_from_predictions(&reversed, &predicted).unwrap();
970 assert_eq!(auc_reversed, 0.0, "reversed ranking must score exactly 0.0");
971 }
972 }
973
974 #[test]
977 fn unit_weights_match_the_unweighted_score_at_scale() {
978 let observed: Vec<f64> = (0..200).map(|i| f64::from(i % 3 == 0)).collect();
979 let predicted: Vec<f64> = (0..200).map(|i| ((i * 37) % 101) as f64).collect();
980 let plain = auc_from_predictions(&observed, &predicted).unwrap();
981 let unit =
982 weighted_auc_from_predictions(&observed, &predicted, Some(&vec![1.0; 200])).unwrap();
983 assert_eq!(plain, unit);
984 let doubled =
986 weighted_auc_from_predictions(&observed, &predicted, Some(&vec![2.0; 200])).unwrap();
987 assert_eq!(plain, doubled);
988 }
989
990 #[test]
991 fn precision_recall_auc_consumes_ties_as_one_threshold() {
992 let first = precision_recall_auc_from_predictions(&[1.0, 0.0], &[0.5, 0.5]).unwrap();
993 let reversed = precision_recall_auc_from_predictions(&[0.0, 1.0], &[0.5, 0.5]).unwrap();
994 assert_eq!(first, 0.75);
995 assert_eq!(first, reversed);
996 }
997
998 #[test]
999 fn probability_scores_match_closed_forms() {
1000 let observed = [0.0, 1.0];
1001 let predicted = [0.5, 0.5];
1002 let log_loss =
1003 binary_log_loss_from_predictions(&observed, &predicted, DEFAULT_PROBABILITY_CLIP)
1004 .unwrap();
1005 assert!((log_loss - std::f64::consts::LN_2).abs() < 1.0e-15);
1006 assert_eq!(brier_from_predictions(&observed, &predicted).unwrap(), 0.25);
1007 assert_eq!(
1008 expected_calibration_error_from_predictions(&observed, &predicted, 2).unwrap(),
1009 0.0
1010 );
1011 assert!(
1012 binary_log_loss_from_predictions(&observed, &predicted, 0.5).is_err(),
1013 "a clip that collapses the probability interval must be rejected"
1014 );
1015 assert!(classification_metrics_from_predictions(&observed, &[0.5, 2.0], 0.5).is_err());
1016 assert!(classification_metrics_from_predictions(&[-0.1, 1.0], &predicted, 0.5).is_err());
1017 }
1018
1019 #[test]
1020 fn nagelkerke_and_classification_panel_share_the_core_kernels() {
1021 let observed = [0.0, 0.0, 1.0, 1.0];
1022 let predicted = [0.1, 0.2, 0.8, 0.9];
1023 let metrics = classification_metrics_from_predictions(&observed, &predicted, 0.5).unwrap();
1024 assert_eq!(metrics.auc, 1.0);
1025 assert_eq!(metrics.precision_recall_auc, 1.0);
1026 assert_eq!(
1027 metrics.nagelkerke_r_squared,
1028 nagelkerke_r_squared_from_predictions(
1029 &observed,
1030 &predicted,
1031 0.5,
1032 DEFAULT_PROBABILITY_CLIP,
1033 )
1034 .unwrap()
1035 );
1036 assert!(metrics.nagelkerke_r_squared.unwrap() > 0.8);
1037 assert_eq!(
1038 nagelkerke_r_squared_from_predictions(
1039 &observed,
1040 &predicted,
1041 1.0,
1042 DEFAULT_PROBABILITY_CLIP,
1043 )
1044 .unwrap(),
1045 None
1046 );
1047 }
1048
1049 #[test]
1050 fn gaussian_log_loss_matches_closed_form_and_rejects_invalid_sigma() {
1051 let observed = [1.0, 2.0, 3.0, 4.0];
1052 let predicted = observed;
1053 let sigma = [1.5];
1054 let got = gaussian_log_loss_from_predictions(
1055 &observed,
1056 &predicted,
1057 &sigma,
1058 DEFAULT_GAUSSIAN_SCALE_FLOOR,
1059 )
1060 .unwrap();
1061 let expected = 0.5 * (std::f64::consts::TAU * 1.5 * 1.5).ln();
1062 assert!((got - expected).abs() < 1.0e-12);
1063 assert!(
1064 gaussian_log_loss_from_predictions(
1065 &observed,
1066 &predicted,
1067 &[1.0, 2.0],
1068 DEFAULT_GAUSSIAN_SCALE_FLOOR,
1069 )
1070 .is_err()
1071 );
1072 assert!(
1073 gaussian_log_loss_from_predictions(
1074 &observed,
1075 &predicted,
1076 &[0.0],
1077 DEFAULT_GAUSSIAN_SCALE_FLOOR,
1078 )
1079 .is_err()
1080 );
1081 }
1082}