diff --git a/src/importance.rs b/src/importance.rs new file mode 100644 index 0000000..8590302 --- /dev/null +++ b/src/importance.rs @@ -0,0 +1,93 @@ +//! Parameter importance via Spearman rank correlation. + +/// Assign average ranks to a slice of `f64` values (handles ties). +#[allow(clippy::cast_precision_loss, clippy::float_cmp)] +pub(crate) fn rank(values: &[f64]) -> Vec { + let n = values.len(); + let mut indexed: Vec<(usize, f64)> = values.iter().copied().enumerate().collect(); + indexed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal)); + + let mut ranks = vec![0.0; n]; + let mut i = 0; + while i < n { + // Find the run of tied values. + let mut j = i + 1; + while j < n && indexed[j].1 == indexed[i].1 { + j += 1; + } + // Average rank for the tie group (1-based ranks). + let avg = (i + 1..=j).sum::() as f64 / (j - i) as f64; + for item in &indexed[i..j] { + ranks[item.0] = avg; + } + i = j; + } + ranks +} + +/// Pearson correlation coefficient on two equal-length slices. +#[allow(clippy::cast_precision_loss)] +fn pearson(x: &[f64], y: &[f64]) -> f64 { + let n = x.len() as f64; + let mean_x = x.iter().sum::() / n; + let mean_y = y.iter().sum::() / n; + + let mut cov = 0.0; + let mut var_x = 0.0; + let mut var_y = 0.0; + for (xi, yi) in x.iter().zip(y.iter()) { + let dx = xi - mean_x; + let dy = yi - mean_y; + cov += dx * dy; + var_x += dx * dx; + var_y += dy * dy; + } + + let denom = (var_x * var_y).sqrt(); + if denom == 0.0 { 0.0 } else { cov / denom } +} + +/// Spearman rank correlation (Pearson on ranks). +pub(crate) fn spearman(x: &[f64], y: &[f64]) -> f64 { + pearson(&rank(x), &rank(y)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rank_no_ties() { + let ranks = rank(&[30.0, 10.0, 20.0]); + assert_eq!(ranks, vec![3.0, 1.0, 2.0]); + } + + #[test] + fn rank_with_ties() { + let ranks = rank(&[10.0, 20.0, 20.0, 30.0]); + assert_eq!(ranks, vec![1.0, 2.5, 2.5, 4.0]); + } + + #[test] + fn perfect_positive_correlation() { + let x = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let y = vec![2.0, 4.0, 6.0, 8.0, 10.0]; + let r = spearman(&x, &y); + assert!((r - 1.0).abs() < 1e-10); + } + + #[test] + fn perfect_negative_correlation() { + let x = vec![1.0, 2.0, 3.0, 4.0, 5.0]; + let y = vec![10.0, 8.0, 6.0, 4.0, 2.0]; + let r = spearman(&x, &y); + assert!((r + 1.0).abs() < 1e-10); + } + + #[test] + fn zero_variance_returns_zero() { + let x = vec![5.0, 5.0, 5.0]; + let y = vec![1.0, 2.0, 3.0]; + assert!(spearman(&x, &y).abs() < f64::EPSILON); + } +} diff --git a/src/lib.rs b/src/lib.rs index 2283d5d..4079fff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -216,6 +216,7 @@ macro_rules! trace_debug { mod distribution; mod error; +mod importance; mod kde; mod param; pub mod parameter; diff --git a/src/study.rs b/src/study.rs index a5d2dba..0875b94 100644 --- a/src/study.rs +++ b/src/study.rs @@ -1812,6 +1812,110 @@ where } } +impl Study +where + V: PartialOrd + Clone + Into, +{ + /// Computes parameter importance scores using Spearman rank correlation. + /// + /// For each parameter, the absolute Spearman correlation between its values + /// and the objective values is computed across all completed trials. Scores + /// are normalized so they sum to 1.0 and sorted in descending order. + /// + /// Parameters that appear in fewer than 2 trials are omitted. + /// Returns an empty `Vec` if the study has fewer than 2 completed trials. + /// + /// # Examples + /// + /// ``` + /// use optimizer::parameter::{FloatParam, Parameter}; + /// use optimizer::{Direction, Study}; + /// + /// let study: Study = Study::new(Direction::Minimize); + /// let x = FloatParam::new(0.0, 10.0).name("x"); + /// + /// study + /// .optimize(20, |trial| { + /// let xv = x.suggest(trial)?; + /// Ok::<_, optimizer::Error>(xv * xv) + /// }) + /// .unwrap(); + /// + /// let importance = study.param_importance(); + /// assert_eq!(importance.len(), 1); + /// assert_eq!(importance[0].0, "x"); + /// ``` + #[must_use] + #[allow(clippy::cast_precision_loss)] + pub fn param_importance(&self) -> Vec<(String, f64)> { + use std::collections::BTreeSet; + + use crate::importance::spearman; + use crate::param::ParamValue; + use crate::types::TrialState; + + let trials = self.completed_trials.read(); + let complete: Vec<_> = trials + .iter() + .filter(|t| t.state == TrialState::Complete) + .collect(); + + if complete.len() < 2 { + return Vec::new(); + } + + // Collect all parameter IDs across trials. + let all_param_ids: BTreeSet<_> = complete.iter().flat_map(|t| t.params.keys()).collect(); + + let mut scores: Vec<(String, f64)> = Vec::new(); + + for ¶m_id in &all_param_ids { + // Collect (param_value_f64, objective_f64) for trials that have this param. + let mut param_vals = Vec::new(); + let mut obj_vals = Vec::new(); + + for trial in &complete { + if let Some(pv) = trial.params.get(param_id) { + let f = match *pv { + ParamValue::Float(v) => v, + ParamValue::Int(v) => v as f64, + ParamValue::Categorical(v) => v as f64, + }; + param_vals.push(f); + obj_vals.push(trial.value.clone().into()); + } + } + + if param_vals.len() < 2 { + continue; + } + + let corr = spearman(¶m_vals, &obj_vals).abs(); + + // Determine label: use param_labels if available, else "param_{id}". + let label = complete + .iter() + .find_map(|t| t.param_labels.get(param_id)) + .map_or_else(|| param_id.to_string(), Clone::clone); + + scores.push((label, corr)); + } + + // Normalize so scores sum to 1.0. + let sum: f64 = scores.iter().map(|(_, s)| *s).sum(); + if sum > 0.0 { + for entry in &mut scores { + entry.1 /= sum; + } + } + + // Sort descending by score. + scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(core::cmp::Ordering::Equal)); + + scores + } +} + impl IntoIterator for &Study where V: PartialOrd + Clone, diff --git a/tests/importance_tests.rs b/tests/importance_tests.rs new file mode 100644 index 0000000..58c1648 --- /dev/null +++ b/tests/importance_tests.rs @@ -0,0 +1,166 @@ +use optimizer::parameter::{CategoricalParam, FloatParam, IntParam, Parameter}; +use optimizer::{Direction, Study}; + +#[test] +fn known_perfect_correlation() { + let study: Study = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 + ); +}