Files
rust-optimizer/tests/importance_tests.rs
T
Manuel Raimann 0cae3b4227 feat: add parameter importance via Spearman rank correlation
Compute per-parameter importance scores by measuring the absolute
Spearman rank correlation between each parameter's values and the
objective across completed trials. Scores are normalized to sum to 1.0
and returned sorted by descending importance.
2026-02-11 18:56:19 +01:00

167 lines
5.2 KiB
Rust

use optimizer::parameter::{CategoricalParam, FloatParam, IntParam, Parameter};
use optimizer::{Direction, Study};
#[test]
fn known_perfect_correlation() {
let study: Study<f64> = Study::new(Direction::Minimize);
let x = FloatParam::new(0.0, 100.0).name("x");
// Objective = x, so perfect correlation.
for _ in 0..30 {
let mut trial = study.ask();
let xv = x.suggest(&mut trial).unwrap();
study.tell(trial, Ok::<_, &str>(xv));
}
let importance = study.param_importance();
assert_eq!(importance.len(), 1);
assert_eq!(importance[0].0, "x");
assert!(
(importance[0].1 - 1.0).abs() < 1e-10,
"single param should be 1.0"
);
}
#[test]
fn no_effect_parameter() {
let study: Study<f64> = Study::new(Direction::Minimize);
let x = FloatParam::new(0.0, 100.0).name("x");
let noise = FloatParam::new(0.0, 100.0).name("noise");
// Objective depends only on x; noise is unused in objective.
for _ in 0..50 {
let mut trial = study.ask();
let xv = x.suggest(&mut trial).unwrap();
let _nv = noise.suggest(&mut trial).unwrap();
study.tell(trial, Ok::<_, &str>(xv));
}
let importance = study.param_importance();
assert_eq!(importance.len(), 2);
// x should have much higher importance than noise.
let x_score = importance.iter().find(|(l, _)| l == "x").unwrap().1;
let noise_score = importance.iter().find(|(l, _)| l == "noise").unwrap().1;
assert!(
x_score > noise_score,
"x ({x_score}) should outrank noise ({noise_score})"
);
// x should dominate
assert!(x_score > 0.7, "x importance {x_score} should be dominant");
}
#[test]
fn multiple_parameters_varying_importance() {
let study: Study<f64> = Study::new(Direction::Minimize);
let x = FloatParam::new(0.0, 10.0).name("x");
let y = FloatParam::new(0.0, 10.0).name("y");
// Objective = 10*x + 0.01*y → x should be far more important.
for _ in 0..50 {
let mut trial = study.ask();
let xv = x.suggest(&mut trial).unwrap();
let yv = y.suggest(&mut trial).unwrap();
study.tell(trial, Ok::<_, &str>(10.0 * xv + 0.01 * yv));
}
let importance = study.param_importance();
assert_eq!(importance.len(), 2);
assert_eq!(importance[0].0, "x", "x should rank first");
assert!(importance[0].1 > importance[1].1);
}
#[test]
fn fewer_than_two_trials_returns_empty() {
let study: Study<f64> = Study::new(Direction::Minimize);
// 0 trials
assert!(study.param_importance().is_empty());
// 1 trial
let x = FloatParam::new(0.0, 1.0).name("x");
let mut trial = study.ask();
let xv = x.suggest(&mut trial).unwrap();
study.tell(trial, Ok::<_, &str>(xv));
assert!(study.param_importance().is_empty());
}
#[test]
fn int_parameter() {
let study: Study<f64> = Study::new(Direction::Minimize);
let n = IntParam::new(1, 100).name("n");
for _ in 0..30 {
let mut trial = study.ask();
let nv = n.suggest(&mut trial).unwrap();
study.tell(trial, Ok::<_, &str>(nv as f64));
}
let importance = study.param_importance();
assert_eq!(importance.len(), 1);
assert_eq!(importance[0].0, "n");
assert!(importance[0].1 > 0.9);
}
#[test]
fn categorical_parameter() {
let study: Study<f64> = Study::new(Direction::Minimize);
let cat = CategoricalParam::new(vec!["a", "b", "c"]).name("cat");
let x = FloatParam::new(0.0, 100.0).name("x");
// Objective depends only on x; categorical is random noise.
for _ in 0..50 {
let mut trial = study.ask();
let _c = cat.suggest(&mut trial).unwrap();
let xv = x.suggest(&mut trial).unwrap();
study.tell(trial, Ok::<_, &str>(xv));
}
let importance = study.param_importance();
assert_eq!(importance.len(), 2);
let x_score = importance.iter().find(|(l, _)| l == "x").unwrap().1;
assert!(x_score > 0.5, "x should dominate over categorical noise");
}
#[test]
fn normalization_sums_to_one() {
let study: Study<f64> = Study::new(Direction::Minimize);
let x = FloatParam::new(0.0, 10.0).name("x");
let y = FloatParam::new(0.0, 10.0).name("y");
let z = FloatParam::new(0.0, 10.0).name("z");
for _ in 0..50 {
let mut trial = study.ask();
let xv = x.suggest(&mut trial).unwrap();
let yv = y.suggest(&mut trial).unwrap();
let zv = z.suggest(&mut trial).unwrap();
study.tell(trial, Ok::<_, &str>(xv + 0.5 * yv + 0.1 * zv));
}
let importance = study.param_importance();
let sum: f64 = importance.iter().map(|(_, s)| *s).sum();
assert!(
(sum - 1.0).abs() < 1e-10,
"scores should sum to 1.0, got {sum}"
);
}
#[test]
fn label_when_unnamed_uses_debug() {
let study: Study<f64> = Study::new(Direction::Minimize);
// No .name() call → label defaults to Debug representation.
let x = FloatParam::new(0.0, 10.0);
for _ in 0..10 {
let mut trial = study.ask();
let xv = x.suggest(&mut trial).unwrap();
study.tell(trial, Ok::<_, &str>(xv));
}
let importance = study.param_importance();
assert_eq!(importance.len(), 1);
assert!(
importance[0].0.starts_with("FloatParam"),
"expected Debug label, got {:?}",
importance[0].0
);
}