use std::fmt;
use anofox_statistics::{
diebold_mariano, model_confidence_set, spa_test, Alternative, LossFunction, MCSStatistic,
VarEstimator,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectionVerdict {
SignificantWinner,
MarginalWinner,
Indistinguishable,
}
impl fmt::Display for SelectionVerdict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
SelectionVerdict::SignificantWinner => "SignificantWinner",
SelectionVerdict::MarginalWinner => "MarginalWinner",
SelectionVerdict::Indistinguishable => "Indistinguishable",
};
write!(f, "{}", s)
}
}
#[derive(Debug, Clone)]
pub struct SelectionConfidence {
pub best_model: String,
pub best_score: f64,
pub runner_up_model: String,
pub runner_up_score: f64,
pub score_gap: f64,
pub relative_gap: f64,
pub verdict: SelectionVerdict,
pub dm_statistic: f64,
pub dm_p_value: f64,
pub best_fold_scores: Vec<f64>,
pub runner_up_fold_scores: Vec<f64>,
}
impl SelectionConfidence {
pub fn compare(
best_name: impl Into<String>,
best_errors: Vec<f64>,
runner_up_name: impl Into<String>,
runner_up_errors: Vec<f64>,
) -> Self {
let best_name = best_name.into();
let runner_up_name = runner_up_name.into();
let best_mean = mean(&best_errors);
let runner_up_mean = mean(&runner_up_errors);
let gap = best_mean - runner_up_mean;
let relative_gap = if runner_up_mean.abs() > f64::EPSILON {
gap.abs() / runner_up_mean.abs()
} else {
0.0
};
let (dm_stat, dm_p, verdict) = match diebold_mariano(
&best_errors,
&runner_up_errors,
LossFunction::AbsoluteError,
1,
Alternative::Less,
VarEstimator::Acf,
) {
Ok(result) => {
let v = p_to_verdict(result.p_value);
(result.statistic, result.p_value, v)
}
Err(_) => {
let v = if best_errors.len() < 2 {
SelectionVerdict::Indistinguishable
} else if gap < -f64::EPSILON {
SelectionVerdict::MarginalWinner
} else {
SelectionVerdict::Indistinguishable
};
(f64::NAN, f64::NAN, v)
}
};
Self {
best_model: best_name,
best_score: best_mean,
runner_up_model: runner_up_name,
runner_up_score: runner_up_mean,
score_gap: gap,
relative_gap,
verdict,
dm_statistic: dm_stat,
dm_p_value: dm_p,
best_fold_scores: best_errors,
runner_up_fold_scores: runner_up_errors,
}
}
pub fn is_significant(&self) -> bool {
self.verdict == SelectionVerdict::SignificantWinner
}
pub fn summary(&self) -> String {
if self.dm_p_value.is_nan() {
format!(
"{} (mean={:.4}) vs {} (mean={:.4}): gap={:.4} ({:.1}% relative) — {} (DM: insufficient data)",
self.best_model, self.best_score, self.runner_up_model, self.runner_up_score,
self.score_gap, self.relative_gap * 100.0, self.verdict,
)
} else {
format!(
"{} (mean={:.4}) vs {} (mean={:.4}): gap={:.4} ({:.1}% relative) — {} (DM: stat={:.3}, p={:.4})",
self.best_model, self.best_score, self.runner_up_model, self.runner_up_score,
self.score_gap, self.relative_gap * 100.0, self.verdict,
self.dm_statistic, self.dm_p_value,
)
}
}
}
impl fmt::Display for SelectionConfidence {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.summary())
}
}
#[derive(Debug, Clone)]
pub struct ModelConfidenceSet {
pub included: Vec<String>,
pub eliminated: Vec<String>,
pub mcs_p_value: f64,
pub alpha: f64,
}
impl ModelConfidenceSet {
pub fn from_cv_scores(model_scores: Vec<(String, Vec<f64>)>, alpha: f64) -> Option<Self> {
if model_scores.len() < 2 {
return None;
}
let names: Vec<String> = model_scores.iter().map(|(n, _)| n.clone()).collect();
let losses: Vec<Vec<f64>> = model_scores.into_iter().map(|(_, s)| s).collect();
match model_confidence_set(
&losses,
alpha,
MCSStatistic::Range,
1000,
0.0, Some(42),
) {
Ok(result) => {
let included = result
.included_models
.iter()
.map(|&i| names[i].clone())
.collect();
let eliminated = result
.eliminated_models
.iter()
.map(|&i| names[i].clone())
.collect();
Some(Self {
included,
eliminated,
mcs_p_value: result.mcs_p_value,
alpha,
})
}
Err(_) => None,
}
}
pub fn len(&self) -> usize {
self.included.len()
}
pub fn is_empty(&self) -> bool {
self.included.is_empty()
}
pub fn has_single_winner(&self) -> bool {
self.included.len() == 1
}
}
impl fmt::Display for ModelConfidenceSet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"MCS (alpha={:.2}): {} model(s) in set {:?}, eliminated {:?} (p={:.4})",
self.alpha,
self.included.len(),
self.included,
self.eliminated,
self.mcs_p_value,
)
}
}
#[derive(Debug, Clone)]
pub struct QualityFloor {
pub benchmark_name: String,
pub spa_p_value: f64,
pub spa_p_value_upper: f64,
pub is_outperformed: bool,
pub best_alternative_idx: Option<usize>,
}
impl QualityFloor {
pub fn test(
benchmark_name: impl Into<String>,
benchmark_losses: &[f64],
model_losses: &[Vec<f64>],
) -> Option<Self> {
if model_losses.is_empty() || benchmark_losses.is_empty() {
return None;
}
match spa_test(benchmark_losses, model_losses, 1000, 0.0, Some(42)) {
Ok(result) => Some(Self {
benchmark_name: benchmark_name.into(),
spa_p_value: result.p_value_consistent,
spa_p_value_upper: result.p_value_upper,
is_outperformed: result.p_value_consistent < 0.05,
best_alternative_idx: result.best_model_idx,
}),
Err(_) => None,
}
}
}
impl fmt::Display for QualityFloor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Quality floor (vs {}): {} (SPA p={:.4})",
self.benchmark_name,
if self.is_outperformed {
"PASSED"
} else {
"FAILED"
},
self.spa_p_value,
)
}
}
fn mean(xs: &[f64]) -> f64 {
if xs.is_empty() {
return 0.0;
}
xs.iter().sum::<f64>() / xs.len() as f64
}
fn p_to_verdict(p: f64) -> SelectionVerdict {
if p < 0.05 {
SelectionVerdict::SignificantWinner
} else if p < 0.20 {
SelectionVerdict::MarginalWinner
} else {
SelectionVerdict::Indistinguishable
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dm_significant_winner() {
let best = vec![1.0, 1.1, 0.9, 1.0, 1.05, 0.95, 1.02, 0.98, 1.01, 0.97];
let runner_up = vec![3.0, 3.2, 2.8, 3.1, 3.0, 2.9, 3.15, 3.05, 2.95, 3.1];
let conf = SelectionConfidence::compare("ModelA", best, "ModelB", runner_up);
assert_eq!(conf.verdict, SelectionVerdict::SignificantWinner);
assert!(conf.is_significant());
assert!(conf.best_score < conf.runner_up_score);
assert!(conf.score_gap < 0.0);
assert!(!conf.dm_statistic.is_nan());
assert!(conf.dm_p_value < 0.05);
}
#[test]
fn dm_indistinguishable() {
let best = vec![2.00, 2.01, 1.99, 2.00, 2.01, 1.99, 2.00, 2.01, 1.99, 2.00];
let runner_up = vec![2.01, 2.00, 2.00, 2.01, 2.00, 2.00, 2.01, 2.00, 2.00, 2.01];
let conf = SelectionConfidence::compare("ModelA", best, "ModelB", runner_up);
assert!(!conf.is_significant());
}
#[test]
fn dm_fallback_on_too_few_folds() {
let best = vec![1.0, 1.1];
let runner_up = vec![3.0, 3.2];
let conf = SelectionConfidence::compare("A", best, "B", runner_up);
assert!(conf.dm_statistic.is_nan());
assert_eq!(conf.verdict, SelectionVerdict::MarginalWinner);
}
#[test]
fn relative_gap_correct() {
let best = vec![1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
let runner_up = vec![2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0];
let conf = SelectionConfidence::compare("A", best, "B", runner_up);
assert!((conf.score_gap - (-1.0)).abs() < 1e-10);
assert!((conf.relative_gap - 0.5).abs() < 1e-10);
}
#[test]
fn display_contains_dm() {
let best = vec![1.0, 1.1, 0.9, 1.0, 1.05, 0.95, 1.02, 0.98, 1.01, 0.97];
let runner_up = vec![3.0, 3.2, 2.8, 3.1, 3.0, 2.9, 3.15, 3.05, 2.95, 3.1];
let conf = SelectionConfidence::compare("ModelA", best, "ModelB", runner_up);
let display = format!("{}", conf);
assert!(display.contains("ModelA"));
assert!(display.contains("ModelB"));
assert!(display.contains("DM:"));
}
#[test]
fn verdict_display() {
assert_eq!(
format!("{}", SelectionVerdict::SignificantWinner),
"SignificantWinner"
);
assert_eq!(
format!("{}", SelectionVerdict::MarginalWinner),
"MarginalWinner"
);
assert_eq!(
format!("{}", SelectionVerdict::Indistinguishable),
"Indistinguishable"
);
}
#[test]
fn mcs_clearly_inferior_eliminated() {
let scores = vec![
("Good".into(), vec![1.0; 50]),
("Bad".into(), vec![10.0; 50]),
];
let mcs = ModelConfidenceSet::from_cv_scores(scores, 0.10).unwrap();
assert!(mcs.included.contains(&"Good".to_string()));
assert!(mcs.eliminated.contains(&"Bad".to_string()));
assert!(mcs.has_single_winner());
}
#[test]
fn mcs_equivalent_models_all_included() {
let base: Vec<f64> = (0..50)
.map(|i| (i as f64 * 0.1).sin().abs() + 1.0)
.collect();
let scores = vec![
("A".into(), base.clone()),
("B".into(), base.clone()),
("C".into(), base.clone()),
];
let mcs = ModelConfidenceSet::from_cv_scores(scores, 0.10).unwrap();
assert_eq!(mcs.len(), 3);
assert!(mcs.eliminated.is_empty());
assert!(!mcs.has_single_winner());
}
#[test]
fn mcs_single_model_returns_none() {
let scores = vec![("Only".into(), vec![1.0, 2.0, 3.0])];
assert!(ModelConfidenceSet::from_cv_scores(scores, 0.10).is_none());
}
#[test]
fn mcs_display() {
let scores = vec![("A".into(), vec![1.0; 50]), ("B".into(), vec![10.0; 50])];
let mcs = ModelConfidenceSet::from_cv_scores(scores, 0.10).unwrap();
let text = format!("{}", mcs);
assert!(text.contains("MCS"));
assert!(text.contains("alpha=0.10"));
}
#[test]
fn quality_floor_outperformed() {
let benchmark = vec![10.0; 50];
let models = vec![vec![1.0; 50]];
let qf = QualityFloor::test("Naive", &benchmark, &models).unwrap();
assert!(qf.is_outperformed);
assert!(qf.spa_p_value < 0.05);
assert_eq!(qf.best_alternative_idx, Some(0));
}
#[test]
fn quality_floor_not_outperformed() {
let base: Vec<f64> = (0..50)
.map(|i| (i as f64 * 0.1).sin().abs() + 1.0)
.collect();
let model: Vec<f64> = base.iter().map(|x| x + 0.001).collect();
let models = vec![model];
let qf = QualityFloor::test("Naive", &base, &models).unwrap();
assert!(!qf.is_outperformed);
}
#[test]
fn quality_floor_display() {
let qf = QualityFloor {
benchmark_name: "Naive".into(),
spa_p_value: 0.02,
spa_p_value_upper: 0.03,
is_outperformed: true,
best_alternative_idx: Some(0),
};
let text = format!("{}", qf);
assert!(text.contains("PASSED"));
assert!(text.contains("Naive"));
}
#[test]
fn quality_floor_empty_returns_none() {
assert!(QualityFloor::test("Naive", &[], &[]).is_none());
}
}