Files
rust-optimizer/src/fanova.rs
T
Manuel Raimann 47b5f9cec8 feat: unify optimize and optimize_with via blanket Objective impl
- Add blanket `impl Objective<V> for Fn(&mut Trial) -> Result<V, E>`
  so closures work directly with `optimize`
- Rewrite optimize, optimize_async, optimize_parallel to accept
  `impl Objective<V>` with before_trial/after_trial hooks
- Remove optimize_with, optimize_with_async, optimize_with_parallel
- Remove max_retries and retry logic from Objective trait
- Add explicit closure type annotations for HRTB inference
- Convert FnMut test closures to Fn via RefCell/Cell
2026-02-12 13:09:14 +01:00

605 lines
19 KiB
Rust

//! fANOVA (functional ANOVA) parameter importance via random forest.
//!
//! fANOVA decomposes the variance of the objective function into
//! contributions from individual parameters (**main effects**) and
//! parameter pairs (**interaction effects**). This helps answer the
//! question: *"Which parameters matter most, and do any parameters
//! interact?"*
//!
//! # Algorithm
//!
//! 1. Fit a random forest to the mapping `(parameters) → objective`
//! 2. Apply functional ANOVA decomposition to the trained forest
//! 3. Compute main effects: the variance explained by each parameter alone
//! 4. Compute interaction effects: the additional variance explained by
//! pairs of parameters beyond their individual contributions
//! 5. Normalize so all importances sum to 1.0
//!
//! # When to use
//!
//! - **After optimization**: call [`Study::fanova()`](crate::Study::fanova)
//! or [`Study::fanova_with_config()`](crate::Study::fanova_with_config)
//! to identify which parameters had the most impact
//! - **Interaction detection**: unlike Spearman correlation
//! ([`Study::param_importance()`](crate::Study::param_importance)),
//! fANOVA can detect non-linear relationships and parameter interactions
//! - **Hyperparameter tuning**: focus tuning effort on high-importance
//! parameters and fix low-importance ones to reasonable defaults
//!
//! # Reference
//!
//! Hutter, F., Hoos, H. & Leyton-Brown, K. (2014). "An Efficient
//! Approach for Assessing Hyperparameter Importance." ICML 2014.
//!
//! # Example
//!
//! ```
//! use optimizer::prelude::*;
//!
//! 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");
//!
//! study
//! .optimize(50, |trial: &mut optimizer::Trial| {
//! let xv = x.suggest(trial)?;
//! let yv = y.suggest(trial)?;
//! // x matters much more than y
//! Ok::<_, optimizer::Error>(3.0 * xv + 0.1 * yv)
//! })
//! .unwrap();
//!
//! let result = study.fanova().unwrap();
//! // Main effects sorted by descending importance
//! assert_eq!(result.main_effects[0].0, "x");
//! ```
/// Result of fANOVA analysis.
///
/// All importance values are fractions of total variance and sum to 1.0
/// across main effects and interactions combined.
#[derive(Debug, Clone)]
pub struct FanovaResult {
/// Per-parameter importance (fraction of total variance explained).
///
/// Sorted by descending importance. Each entry is
/// `(parameter_name, importance)` where importance is in `[0.0, 1.0]`.
pub main_effects: Vec<(String, f64)>,
/// Pairwise interaction importance (fraction of total variance explained).
///
/// Sorted by descending importance. Each entry is
/// `((param_a, param_b), importance)`. Only pairs with non-negligible
/// interaction (> 1e-10) are included.
pub interactions: Vec<((String, String), f64)>,
}
/// Configuration for fANOVA analysis.
///
/// Use [`Default::default()`] for reasonable settings, or customize
/// the random forest parameters for specific needs. Pass to
/// [`Study::fanova_with_config()`](crate::Study::fanova_with_config).
#[derive(Debug, Clone)]
pub struct FanovaConfig {
/// Number of trees in the random forest (default: 64).
pub n_trees: usize,
/// Maximum depth of each tree. `None` for unlimited (default: `None`).
pub max_depth: Option<usize>,
/// Minimum samples required to split a node (default: 2).
pub min_samples_split: usize,
/// Minimum samples required in a leaf node (default: 1).
pub min_samples_leaf: usize,
/// Random seed for reproducibility (default: `Some(42)`).
pub seed: Option<u64>,
}
impl Default for FanovaConfig {
fn default() -> Self {
Self {
n_trees: 64,
max_depth: None,
min_samples_split: 2,
min_samples_leaf: 1,
seed: Some(42),
}
}
}
// --- Decision Tree ---
/// A node in the regression tree (arena-allocated).
#[derive(Debug, Clone)]
enum TreeNode {
Leaf {
value: f64,
n_samples: usize,
},
Split {
feature: usize,
threshold: f64,
left: usize,
right: usize,
n_samples: usize,
},
}
/// A regression decision tree for fANOVA.
#[derive(Debug, Clone)]
struct DecisionTree {
nodes: Vec<TreeNode>,
}
impl DecisionTree {
/// Build a tree from the given data using the specified bootstrap indices.
fn build(
data: &[Vec<f64>],
targets: &[f64],
indices: &[usize],
config: &FanovaConfig,
rng: &mut fastrand::Rng,
) -> Self {
let mut tree = Self { nodes: Vec::new() };
tree.build_node(data, targets, indices, 0, config, rng);
tree
}
#[allow(clippy::cast_precision_loss)]
fn build_node(
&mut self,
data: &[Vec<f64>],
targets: &[f64],
indices: &[usize],
depth: usize,
config: &FanovaConfig,
rng: &mut fastrand::Rng,
) -> usize {
let n = indices.len();
let mean = indices.iter().map(|&i| targets[i]).sum::<f64>() / n as f64;
// Stopping conditions
if n < config.min_samples_split || config.max_depth.is_some_and(|d| depth >= d) {
let idx = self.nodes.len();
self.nodes.push(TreeNode::Leaf {
value: mean,
n_samples: n,
});
return idx;
}
// Pure node check (all targets identical)
#[allow(clippy::float_cmp)]
if indices.iter().all(|&i| targets[i] == targets[indices[0]]) {
let idx = self.nodes.len();
self.nodes.push(TreeNode::Leaf {
value: mean,
n_samples: n,
});
return idx;
}
let n_features = data[0].len();
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let max_features = ((n_features as f64).sqrt().ceil() as usize)
.max(1)
.min(n_features);
let candidates = partial_shuffle(n_features, max_features, rng);
// Total variance at this node
let total_var: f64 = indices.iter().map(|&i| (targets[i] - mean).powi(2)).sum();
if total_var == 0.0 {
let idx = self.nodes.len();
self.nodes.push(TreeNode::Leaf {
value: mean,
n_samples: n,
});
return idx;
}
let mut best_score = f64::NEG_INFINITY;
let mut best_feature = 0;
let mut best_threshold = 0.0;
for &feat in &candidates {
let mut values: Vec<f64> = indices.iter().map(|&i| data[i][feat]).collect();
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
values.dedup();
if values.len() < 2 {
continue;
}
for w in values.windows(2) {
let threshold = f64::midpoint(w[0], w[1]);
let (l_sum, l_sq, l_n, r_sum, r_sq, r_n) =
split_stats(data, targets, indices, feat, threshold);
if l_n < config.min_samples_leaf || r_n < config.min_samples_leaf {
continue;
}
let l_var = l_sq - l_sum * l_sum / l_n as f64;
let r_var = r_sq - r_sum * r_sum / r_n as f64;
let score = total_var - l_var - r_var;
if score > best_score {
best_score = score;
best_feature = feat;
best_threshold = threshold;
}
}
}
if best_score <= 0.0 {
let idx = self.nodes.len();
self.nodes.push(TreeNode::Leaf {
value: mean,
n_samples: n,
});
return idx;
}
let (left_indices, right_indices): (Vec<usize>, Vec<usize>) = indices
.iter()
.partition(|&&i| data[i][best_feature] <= best_threshold);
if left_indices.is_empty() || right_indices.is_empty() {
let idx = self.nodes.len();
self.nodes.push(TreeNode::Leaf {
value: mean,
n_samples: n,
});
return idx;
}
// Reserve slot for this split node (placeholder replaced below)
let node_idx = self.nodes.len();
self.nodes.push(TreeNode::Leaf {
value: 0.0,
n_samples: 0,
});
let left = self.build_node(data, targets, &left_indices, depth + 1, config, rng);
let right = self.build_node(data, targets, &right_indices, depth + 1, config, rng);
self.nodes[node_idx] = TreeNode::Split {
feature: best_feature,
threshold: best_threshold,
left,
right,
n_samples: n,
};
node_idx
}
/// Compute marginal prediction for a given feature subset.
///
/// Features in `subset` use values from `feature_values`.
/// Features not in `subset` are marginalized by weighting branches
/// proportionally to their training-data fractions.
fn marginal_predict(&self, subset: &[usize], feature_values: &[f64]) -> f64 {
self.marginal_predict_at(0, subset, feature_values)
}
#[allow(clippy::cast_precision_loss)]
fn marginal_predict_at(&self, idx: usize, subset: &[usize], vals: &[f64]) -> f64 {
match self.nodes[idx] {
TreeNode::Leaf { value, .. } => value,
TreeNode::Split {
feature,
threshold,
left,
right,
n_samples,
} => {
if subset.contains(&feature) {
if vals[feature] <= threshold {
self.marginal_predict_at(left, subset, vals)
} else {
self.marginal_predict_at(right, subset, vals)
}
} else {
let l_n = self.n_samples(left) as f64;
let r_n = self.n_samples(right) as f64;
let total = n_samples as f64;
(l_n / total) * self.marginal_predict_at(left, subset, vals)
+ (r_n / total) * self.marginal_predict_at(right, subset, vals)
}
}
}
}
fn n_samples(&self, idx: usize) -> usize {
match self.nodes[idx] {
TreeNode::Leaf { n_samples, .. } | TreeNode::Split { n_samples, .. } => n_samples,
}
}
}
// --- Helper Functions ---
/// Select `k` random indices from `0..n` using partial Fisher-Yates shuffle.
fn partial_shuffle(n: usize, k: usize, rng: &mut fastrand::Rng) -> Vec<usize> {
let mut indices: Vec<usize> = (0..n).collect();
let k = k.min(n);
for i in 0..k {
let j = rng.usize(i..n);
indices.swap(i, j);
}
indices.truncate(k);
indices
}
/// Compute left/right split statistics for variance reduction.
#[allow(clippy::cast_precision_loss)]
fn split_stats(
data: &[Vec<f64>],
targets: &[f64],
indices: &[usize],
feature: usize,
threshold: f64,
) -> (f64, f64, usize, f64, f64, usize) {
let (mut l_sum, mut l_sq, mut l_n) = (0.0, 0.0, 0usize);
let (mut r_sum, mut r_sq, mut r_n) = (0.0, 0.0, 0usize);
for &i in indices {
let y = targets[i];
if data[i][feature] <= threshold {
l_sum += y;
l_sq += y * y;
l_n += 1;
} else {
r_sum += y;
r_sq += y * y;
r_n += 1;
}
}
(l_sum, l_sq, l_n, r_sum, r_sq, r_n)
}
/// Population variance of a slice.
#[allow(clippy::cast_precision_loss)]
fn variance(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
let n = values.len() as f64;
let mean = values.iter().sum::<f64>() / n;
values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n
}
// --- Public API ---
/// Run fANOVA analysis on pre-processed numerical data.
///
/// `data` is `n_samples` rows, each with `n_features` columns.
/// `targets` has one entry per sample.
/// `feature_names` maps feature index to human-readable name.
#[allow(clippy::cast_precision_loss)]
pub(crate) fn compute_fanova(
data: &[Vec<f64>],
targets: &[f64],
feature_names: &[String],
config: &FanovaConfig,
) -> FanovaResult {
let n_samples = data.len();
let n_features = data[0].len();
let mut rng: fastrand::Rng = config
.seed
.map_or_else(fastrand::Rng::new, fastrand::Rng::with_seed);
// Build random forest with bootstrap sampling
let trees: Vec<DecisionTree> = (0..config.n_trees)
.map(|_| {
let bootstrap: Vec<usize> = (0..n_samples).map(|_| rng.usize(0..n_samples)).collect();
DecisionTree::build(data, targets, &bootstrap, config, &mut rng)
})
.collect();
// Compute main effects: V_j = Var[E[f | x_j]]
let main_var: Vec<f64> = (0..n_features)
.map(|j| {
let subset = [j];
let preds: Vec<f64> = (0..n_samples)
.map(|i| {
trees
.iter()
.map(|t| t.marginal_predict(&subset, &data[i]))
.sum::<f64>()
/ trees.len() as f64
})
.collect();
variance(&preds)
})
.collect();
// Compute pairwise interaction effects: V_{j,k} - V_j - V_k
let mut interactions: Vec<((String, String), f64)> = Vec::new();
for j in 0..n_features {
for k in (j + 1)..n_features {
let subset = [j, k];
let preds: Vec<f64> = (0..n_samples)
.map(|i| {
trees
.iter()
.map(|t| t.marginal_predict(&subset, &data[i]))
.sum::<f64>()
/ trees.len() as f64
})
.collect();
let joint = variance(&preds);
let interaction = (joint - main_var[j] - main_var[k]).max(0.0);
if interaction > 1e-10 {
interactions.push((
(feature_names[j].clone(), feature_names[k].clone()),
interaction,
));
}
}
}
// Normalize so all importances sum to 1.0
let total: f64 =
main_var.iter().sum::<f64>() + interactions.iter().map(|(_, v)| *v).sum::<f64>();
let mut main_effects: Vec<(String, f64)> = feature_names
.iter()
.zip(&main_var)
.map(|(name, &v)| (name.clone(), if total > 0.0 { v / total } else { 0.0 }))
.collect();
main_effects.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(core::cmp::Ordering::Equal));
if total > 0.0 {
for entry in &mut interactions {
entry.1 /= total;
}
}
interactions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(core::cmp::Ordering::Equal));
FanovaResult {
main_effects,
interactions,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rng_util;
#[test]
fn single_dominant_parameter() {
// f(x, y) = x — only x matters
let mut rng = fastrand::Rng::with_seed(0);
let n = 100;
let data: Vec<Vec<f64>> = (0..n)
.map(|_| {
vec![
rng_util::f64_range(&mut rng, 0.0, 10.0),
rng_util::f64_range(&mut rng, 0.0, 10.0),
]
})
.collect();
let targets: Vec<f64> = data.iter().map(|row| row[0]).collect();
let result = compute_fanova(
&data,
&targets,
&["x".into(), "y".into()],
&FanovaConfig::default(),
);
assert_eq!(result.main_effects[0].0, "x");
assert!(
result.main_effects[0].1 > 0.8,
"x importance = {}",
result.main_effects[0].1
);
}
#[test]
fn interaction_detection() {
// f(x, y) = x * y — both matter and interact
let mut rng = fastrand::Rng::with_seed(42);
let n = 200;
let data: Vec<Vec<f64>> = (0..n)
.map(|_| {
vec![
rng_util::f64_range(&mut rng, 0.0, 10.0),
rng_util::f64_range(&mut rng, 0.0, 10.0),
]
})
.collect();
let targets: Vec<f64> = data.iter().map(|row| row[0] * row[1]).collect();
let config = FanovaConfig {
n_trees: 128,
..FanovaConfig::default()
};
let result = compute_fanova(&data, &targets, &["x".into(), "y".into()], &config);
assert!(
!result.interactions.is_empty(),
"should detect x*y interaction"
);
assert!(
result.interactions[0].1 > 0.05,
"interaction importance = {}",
result.interactions[0].1
);
}
#[test]
fn variance_computation() {
assert!((variance(&[1.0, 2.0, 3.0, 4.0, 5.0]) - 2.0).abs() < 1e-10);
assert!(variance(&[5.0, 5.0, 5.0]).abs() < 1e-10);
assert!(variance(&[]).abs() < 1e-10);
}
#[test]
fn three_params_one_dominant() {
// f(x, y, z) = 3*x + 0.1*y + 0*z
let mut rng = fastrand::Rng::with_seed(7);
let n = 150;
let data: Vec<Vec<f64>> = (0..n)
.map(|_| {
vec![
rng_util::f64_range(&mut rng, 0.0, 10.0),
rng_util::f64_range(&mut rng, 0.0, 10.0),
rng_util::f64_range(&mut rng, 0.0, 10.0),
]
})
.collect();
let targets: Vec<f64> = data.iter().map(|r| 3.0 * r[0] + 0.1 * r[1]).collect();
let result = compute_fanova(
&data,
&targets,
&["x".into(), "y".into(), "z".into()],
&FanovaConfig::default(),
);
// x should be the most important
assert_eq!(result.main_effects[0].0, "x");
assert!(result.main_effects[0].1 > 0.5);
// z should have near-zero importance
let z_imp = result
.main_effects
.iter()
.find(|(name, _)| name == "z")
.map_or(0.0, |(_, v)| *v);
assert!(z_imp < 0.1, "z importance = {z_imp}");
}
#[test]
fn importances_sum_to_one() {
let mut rng = fastrand::Rng::with_seed(3);
let n = 100;
let data: Vec<Vec<f64>> = (0..n)
.map(|_| {
vec![
rng_util::f64_range(&mut rng, 0.0, 10.0),
rng_util::f64_range(&mut rng, 0.0, 10.0),
]
})
.collect();
let targets: Vec<f64> = data.iter().map(|r| r[0] + r[1]).collect();
let result = compute_fanova(
&data,
&targets,
&["x".into(), "y".into()],
&FanovaConfig::default(),
);
let total: f64 = result.main_effects.iter().map(|(_, v)| *v).sum::<f64>()
+ result.interactions.iter().map(|(_, v)| *v).sum::<f64>();
assert!(
(total - 1.0).abs() < 1e-10,
"importances should sum to 1.0, got {total}"
);
}
}