feat: add fANOVA parameter importance via random forest
Implement functional ANOVA decomposition behind the `fanova` feature flag. A self-contained random forest is trained on trial data, then marginal predictions are used to compute per-parameter main effects and pairwise interaction effects, normalized to sum to 1.0. Adds Study::fanova() / fanova_with_config(), FanovaResult, and FanovaConfig. Includes unit and integration tests covering dominant parameters, interaction detection, consistency with correlation-based importance, and error handling.
This commit is contained in:
@@ -36,6 +36,7 @@ tracing = ["dep:tracing"]
|
|||||||
sobol = ["dep:sobol_burley"]
|
sobol = ["dep:sobol_burley"]
|
||||||
cma-es = ["dep:nalgebra"]
|
cma-es = ["dep:nalgebra"]
|
||||||
visualization = []
|
visualization = []
|
||||||
|
fanova = []
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
||||||
|
|||||||
+536
@@ -0,0 +1,536 @@
|
|||||||
|
//! fANOVA (functional ANOVA) parameter importance via random forest.
|
||||||
|
//!
|
||||||
|
//! Decomposes the variance of the objective function into contributions
|
||||||
|
//! from individual parameters (main effects) and parameter interactions.
|
||||||
|
//!
|
||||||
|
//! The algorithm:
|
||||||
|
//! 1. Fits a random forest to `(parameters) -> objective_value`
|
||||||
|
//! 2. Applies functional ANOVA decomposition to the forest
|
||||||
|
//! 3. Computes main effects (single-parameter importance)
|
||||||
|
//! 4. Computes interaction effects (pairwise parameter importance)
|
||||||
|
|
||||||
|
use rand::rngs::StdRng;
|
||||||
|
use rand::{RngExt, SeedableRng};
|
||||||
|
|
||||||
|
/// Result of fANOVA analysis.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FanovaResult {
|
||||||
|
/// Per-parameter importance (fraction of total variance explained).
|
||||||
|
/// Sorted by descending importance.
|
||||||
|
pub main_effects: Vec<(String, f64)>,
|
||||||
|
/// Pairwise interaction importance (fraction of total variance explained).
|
||||||
|
/// Sorted by descending importance.
|
||||||
|
pub interactions: Vec<((String, String), f64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for fANOVA analysis.
|
||||||
|
#[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 StdRng,
|
||||||
|
) -> 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 StdRng,
|
||||||
|
) -> 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 StdRng) -> Vec<usize> {
|
||||||
|
let mut indices: Vec<usize> = (0..n).collect();
|
||||||
|
let k = k.min(n);
|
||||||
|
for i in 0..k {
|
||||||
|
let j = rng.random_range(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: StdRng = config
|
||||||
|
.seed
|
||||||
|
.map_or_else(rand::make_rng, StdRng::seed_from_u64);
|
||||||
|
|
||||||
|
// Build random forest with bootstrap sampling
|
||||||
|
let trees: Vec<DecisionTree> = (0..config.n_trees)
|
||||||
|
.map(|_| {
|
||||||
|
let bootstrap: Vec<usize> = (0..n_samples)
|
||||||
|
.map(|_| rng.random_range(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::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_dominant_parameter() {
|
||||||
|
// f(x, y) = x — only x matters
|
||||||
|
let mut rng = StdRng::seed_from_u64(0);
|
||||||
|
let n = 100;
|
||||||
|
let data: Vec<Vec<f64>> = (0..n)
|
||||||
|
.map(|_| vec![rng.random_range(0.0..10.0), rng.random_range(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 = StdRng::seed_from_u64(0);
|
||||||
|
let n = 200;
|
||||||
|
let data: Vec<Vec<f64>> = (0..n)
|
||||||
|
.map(|_| vec![rng.random_range(0.0..10.0), rng.random_range(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 = StdRng::seed_from_u64(7);
|
||||||
|
let n = 150;
|
||||||
|
let data: Vec<Vec<f64>> = (0..n)
|
||||||
|
.map(|_| {
|
||||||
|
vec![
|
||||||
|
rng.random_range(0.0..10.0),
|
||||||
|
rng.random_range(0.0..10.0),
|
||||||
|
rng.random_range(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 = StdRng::seed_from_u64(3);
|
||||||
|
let n = 100;
|
||||||
|
let data: Vec<Vec<f64>> = (0..n)
|
||||||
|
.map(|_| vec![rng.random_range(0.0..10.0), rng.random_range(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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,6 +219,8 @@ macro_rules! trace_debug {
|
|||||||
|
|
||||||
mod distribution;
|
mod distribution;
|
||||||
mod error;
|
mod error;
|
||||||
|
#[cfg(feature = "fanova")]
|
||||||
|
mod fanova;
|
||||||
mod importance;
|
mod importance;
|
||||||
mod kde;
|
mod kde;
|
||||||
pub mod multi_objective;
|
pub mod multi_objective;
|
||||||
@@ -234,6 +236,8 @@ mod types;
|
|||||||
mod visualization;
|
mod visualization;
|
||||||
|
|
||||||
pub use error::{Error, Result, TrialPruned};
|
pub use error::{Error, Result, TrialPruned};
|
||||||
|
#[cfg(feature = "fanova")]
|
||||||
|
pub use fanova::{FanovaConfig, FanovaResult};
|
||||||
pub use multi_objective::{MultiObjectiveSampler, MultiObjectiveStudy, MultiObjectiveTrial};
|
pub use multi_objective::{MultiObjectiveSampler, MultiObjectiveStudy, MultiObjectiveTrial};
|
||||||
#[cfg(feature = "derive")]
|
#[cfg(feature = "derive")]
|
||||||
pub use optimizer_derive::Categorical;
|
pub use optimizer_derive::Categorical;
|
||||||
@@ -274,6 +278,8 @@ pub mod prelude {
|
|||||||
pub use optimizer_derive::Categorical as DeriveCategory;
|
pub use optimizer_derive::Categorical as DeriveCategory;
|
||||||
|
|
||||||
pub use crate::error::{Error, Result, TrialPruned};
|
pub use crate::error::{Error, Result, TrialPruned};
|
||||||
|
#[cfg(feature = "fanova")]
|
||||||
|
pub use crate::fanova::{FanovaConfig, FanovaResult};
|
||||||
pub use crate::multi_objective::{MultiObjectiveStudy, MultiObjectiveTrial};
|
pub use crate::multi_objective::{MultiObjectiveStudy, MultiObjectiveTrial};
|
||||||
pub use crate::param::ParamValue;
|
pub use crate::param::ParamValue;
|
||||||
pub use crate::parameter::{
|
pub use crate::parameter::{
|
||||||
|
|||||||
+104
@@ -2027,6 +2027,110 @@ where
|
|||||||
|
|
||||||
scores
|
scores
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Computes parameter importance using fANOVA (functional ANOVA) with
|
||||||
|
/// default configuration.
|
||||||
|
///
|
||||||
|
/// Fits a random forest to the trial data and decomposes variance into
|
||||||
|
/// per-parameter main effects and pairwise interaction effects. This is
|
||||||
|
/// more accurate than correlation-based importance ([`Self::param_importance`])
|
||||||
|
/// and can detect non-linear relationships and parameter interactions.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::NoCompletedTrials`] if fewer than 2 trials have completed.
|
||||||
|
#[cfg(feature = "fanova")]
|
||||||
|
pub fn fanova(&self) -> crate::Result<crate::fanova::FanovaResult> {
|
||||||
|
self.fanova_with_config(&crate::fanova::FanovaConfig::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes parameter importance using fANOVA with custom configuration.
|
||||||
|
///
|
||||||
|
/// See [`Self::fanova`] for details. The [`FanovaConfig`](crate::fanova::FanovaConfig)
|
||||||
|
/// allows tuning the number of trees, tree depth, and random seed.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::NoCompletedTrials`] if fewer than 2 trials have completed.
|
||||||
|
#[cfg(feature = "fanova")]
|
||||||
|
#[allow(clippy::cast_precision_loss)]
|
||||||
|
pub fn fanova_with_config(
|
||||||
|
&self,
|
||||||
|
config: &crate::fanova::FanovaConfig,
|
||||||
|
) -> crate::Result<crate::fanova::FanovaResult> {
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use crate::fanova::compute_fanova;
|
||||||
|
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 Err(crate::Error::NoCompletedTrials);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all parameter IDs in a stable order.
|
||||||
|
let all_param_ids: Vec<_> = {
|
||||||
|
let set: BTreeSet<_> = complete.iter().flat_map(|t| t.params.keys()).collect();
|
||||||
|
set.into_iter().collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
if all_param_ids.is_empty() {
|
||||||
|
return Ok(crate::fanova::FanovaResult {
|
||||||
|
main_effects: Vec::new(),
|
||||||
|
interactions: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build feature matrix (only trials that have all parameters).
|
||||||
|
let mut data = Vec::new();
|
||||||
|
let mut targets = Vec::new();
|
||||||
|
|
||||||
|
for trial in &complete {
|
||||||
|
let mut row = Vec::with_capacity(all_param_ids.len());
|
||||||
|
let mut has_all = true;
|
||||||
|
|
||||||
|
for &pid in &all_param_ids {
|
||||||
|
if let Some(pv) = trial.params.get(pid) {
|
||||||
|
row.push(match *pv {
|
||||||
|
ParamValue::Float(v) => v,
|
||||||
|
ParamValue::Int(v) => v as f64,
|
||||||
|
ParamValue::Categorical(v) => v as f64,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
has_all = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_all {
|
||||||
|
data.push(row);
|
||||||
|
targets.push(trial.value.clone().into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.len() < 2 {
|
||||||
|
return Err(crate::Error::NoCompletedTrials);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build feature names from parameter labels.
|
||||||
|
let feature_names: Vec<String> = all_param_ids
|
||||||
|
.iter()
|
||||||
|
.map(|&pid| {
|
||||||
|
complete
|
||||||
|
.iter()
|
||||||
|
.find_map(|t| t.param_labels.get(pid))
|
||||||
|
.map_or_else(|| pid.to_string(), Clone::clone)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(compute_fanova(&data, &targets, &feature_names, config))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<V> IntoIterator for &Study<V>
|
impl<V> IntoIterator for &Study<V>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! Integration tests for fANOVA parameter importance.
|
||||||
|
|
||||||
|
#![cfg(feature = "fanova")]
|
||||||
|
|
||||||
|
use optimizer::prelude::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fanova_dominant_parameter() {
|
||||||
|
// f(x, y) = x^2 — x should dominate
|
||||||
|
let x = FloatParam::new(0.0, 10.0).name("x");
|
||||||
|
let y = FloatParam::new(0.0, 10.0).name("y");
|
||||||
|
|
||||||
|
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
|
||||||
|
study
|
||||||
|
.optimize(50, |trial| {
|
||||||
|
let xv = x.suggest(trial)?;
|
||||||
|
let _yv = y.suggest(trial)?;
|
||||||
|
Ok::<_, Error>(xv * xv)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = study.fanova().unwrap();
|
||||||
|
assert_eq!(result.main_effects[0].0, "x");
|
||||||
|
assert!(
|
||||||
|
result.main_effects[0].1 > 0.7,
|
||||||
|
"x importance = {}",
|
||||||
|
result.main_effects[0].1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fanova_interaction() {
|
||||||
|
// f(x, y) = x * y — both matter and interact
|
||||||
|
let x = FloatParam::new(0.0, 10.0).name("x");
|
||||||
|
let y = FloatParam::new(0.0, 10.0).name("y");
|
||||||
|
|
||||||
|
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(7));
|
||||||
|
study
|
||||||
|
.optimize(100, |trial| {
|
||||||
|
let xv = x.suggest(trial)?;
|
||||||
|
let yv = y.suggest(trial)?;
|
||||||
|
Ok::<_, Error>(xv * yv)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let config = FanovaConfig {
|
||||||
|
n_trees: 128,
|
||||||
|
..FanovaConfig::default()
|
||||||
|
};
|
||||||
|
let result = study.fanova_with_config(&config).unwrap();
|
||||||
|
|
||||||
|
// Should detect interaction
|
||||||
|
assert!(
|
||||||
|
!result.interactions.is_empty(),
|
||||||
|
"should detect x*y interaction"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fanova_consistent_with_correlation() {
|
||||||
|
// f(x, y) = 3*x + 0.5*y — x should rank higher in both methods
|
||||||
|
let x = FloatParam::new(0.0, 10.0).name("x");
|
||||||
|
let y = FloatParam::new(0.0, 10.0).name("y");
|
||||||
|
|
||||||
|
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(99));
|
||||||
|
study
|
||||||
|
.optimize(80, |trial| {
|
||||||
|
let xv = x.suggest(trial)?;
|
||||||
|
let yv = y.suggest(trial)?;
|
||||||
|
Ok::<_, Error>(3.0 * xv + 0.5 * yv)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let corr = study.param_importance();
|
||||||
|
let fanova = study.fanova().unwrap();
|
||||||
|
|
||||||
|
// Both methods should rank x above y
|
||||||
|
assert_eq!(corr[0].0, "x", "correlation should rank x first");
|
||||||
|
assert_eq!(fanova.main_effects[0].0, "x", "fanova should rank x first");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fanova_too_few_trials() {
|
||||||
|
let study: Study<f64> = Study::new(Direction::Minimize);
|
||||||
|
|
||||||
|
let result = study.fanova();
|
||||||
|
assert!(result.is_err(), "should error with no trials");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user