use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct FeatureImportance {
pub name: String,
pub score: f64,
}
#[derive(Debug, Clone)]
pub struct FeatureSelectionConfig {
pub min_variance: f64,
pub max_correlation: f64,
pub max_features: Option<usize>,
}
impl Default for FeatureSelectionConfig {
fn default() -> Self {
Self {
min_variance: 0.01,
max_correlation: 0.95,
max_features: None,
}
}
}
pub struct FeatureSelector;
impl FeatureSelector {
pub fn variance_threshold(features: &HashMap<String, Vec<f64>>, threshold: f64) -> Vec<String> {
let mut selected: Vec<String> = features
.iter()
.filter(|(_, values)| compute_variance(values) >= threshold)
.map(|(name, _)| name.clone())
.collect();
selected.sort();
selected
}
pub fn correlation_filter(features: &HashMap<String, Vec<f64>>, threshold: f64) -> Vec<String> {
let mut names: Vec<&String> = features.keys().collect();
names.sort();
let mut to_remove = std::collections::HashSet::new();
for i in 0..names.len() {
if to_remove.contains(names[i]) {
continue;
}
let vals_i = &features[names[i]];
for j in (i + 1)..names.len() {
if to_remove.contains(names[j]) {
continue;
}
let vals_j = &features[names[j]];
let corr = pearson_correlation(vals_i, vals_j);
if corr.abs() > threshold {
to_remove.insert(names[j].clone());
}
}
}
let mut selected: Vec<String> = names
.into_iter()
.filter(|n| !to_remove.contains(*n))
.cloned()
.collect();
selected.sort();
selected
}
pub fn select_top_k(importances: &[FeatureImportance], k: usize) -> Vec<String> {
let mut sorted: Vec<&FeatureImportance> = importances.iter().collect();
sorted.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
sorted.iter().take(k).map(|fi| fi.name.clone()).collect()
}
}
pub fn rank_features(features: &HashMap<String, Vec<f64>>) -> Vec<FeatureImportance> {
if features.is_empty() {
return Vec::new();
}
let mut raw_scores: Vec<(String, f64)> = features
.iter()
.map(|(name, values)| (name.clone(), compute_variance(values)))
.collect();
let total: f64 = raw_scores.iter().map(|(_, s)| s).sum();
let mut importances: Vec<FeatureImportance> = if total > 0.0 {
raw_scores
.drain(..)
.map(|(name, score)| FeatureImportance {
name,
score: score / total,
})
.collect()
} else {
let n = raw_scores.len() as f64;
raw_scores
.drain(..)
.map(|(name, _)| FeatureImportance {
name,
score: 1.0 / n,
})
.collect()
};
importances.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
importances
}
pub fn select_features(
features: &HashMap<String, Vec<f64>>,
config: FeatureSelectionConfig,
) -> Vec<String> {
let after_variance = FeatureSelector::variance_threshold(features, config.min_variance);
let subset: HashMap<String, Vec<f64>> = after_variance
.iter()
.filter_map(|name| features.get(name).map(|v| (name.clone(), v.clone())))
.collect();
let after_correlation = FeatureSelector::correlation_filter(&subset, config.max_correlation);
match config.max_features {
Some(k) if k < after_correlation.len() => {
let remaining: HashMap<String, Vec<f64>> = after_correlation
.iter()
.filter_map(|name| features.get(name).map(|v| (name.clone(), v.clone())))
.collect();
let importances = rank_features(&remaining);
let top = FeatureSelector::select_top_k(&importances, k);
let mut result = top;
result.sort();
result
}
_ => after_correlation,
}
}
fn compute_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
}
fn pearson_correlation(a: &[f64], b: &[f64]) -> f64 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let n = a.len() as f64;
let mean_a = a.iter().sum::<f64>() / n;
let mean_b = b.iter().sum::<f64>() / n;
let mut cov = 0.0;
let mut var_a = 0.0;
let mut var_b = 0.0;
for (ai, bi) in a.iter().zip(b.iter()) {
let da = ai - mean_a;
let db = bi - mean_b;
cov += da * db;
var_a += da * da;
var_b += db * db;
}
let denom = (var_a * var_b).sqrt();
if denom < 1e-15 {
return 0.0;
}
cov / denom
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn make_features(entries: &[(&str, Vec<f64>)]) -> HashMap<String, Vec<f64>> {
entries
.iter()
.map(|(name, vals)| (name.to_string(), vals.clone()))
.collect()
}
#[test]
fn variance_threshold_removes_constant_features() {
let features = make_features(&[
("varying", vec![1.0, 2.0, 3.0, 4.0]),
("constant", vec![5.0, 5.0, 5.0, 5.0]),
("low_var", vec![1.0, 1.001, 1.002, 0.999]),
]);
let selected = FeatureSelector::variance_threshold(&features, 0.01);
assert!(selected.contains(&"varying".to_string()));
assert!(!selected.contains(&"constant".to_string()));
assert!(!selected.contains(&"low_var".to_string()));
}
#[test]
fn variance_threshold_keeps_all_above() {
let features = make_features(&[("a", vec![1.0, 10.0, 20.0]), ("b", vec![5.0, 15.0, 25.0])]);
let selected = FeatureSelector::variance_threshold(&features, 0.01);
assert_eq!(selected.len(), 2);
}
#[test]
fn variance_threshold_empty_map() {
let features: HashMap<String, Vec<f64>> = HashMap::new();
let selected = FeatureSelector::variance_threshold(&features, 0.01);
assert!(selected.is_empty());
}
#[test]
fn correlation_filter_removes_redundant_features() {
let features = make_features(&[
("a", vec![1.0, 2.0, 3.0, 4.0, 5.0]),
("b", vec![3.0, 5.0, 7.0, 9.0, 11.0]),
("c", vec![5.0, 3.0, 1.0, 4.0, 2.0]),
]);
let selected = FeatureSelector::correlation_filter(&features, 0.95);
assert!(selected.contains(&"a".to_string()));
assert!(!selected.contains(&"b".to_string()));
assert!(selected.contains(&"c".to_string()));
}
#[test]
fn correlation_filter_keeps_uncorrelated() {
let features = make_features(&[
("x", vec![1.0, 0.0, -1.0, 0.0]),
("y", vec![0.0, 1.0, 0.0, -1.0]),
]);
let selected = FeatureSelector::correlation_filter(&features, 0.95);
assert_eq!(selected.len(), 2);
}
#[test]
fn correlation_filter_single_feature() {
let features = make_features(&[("only", vec![1.0, 2.0, 3.0])]);
let selected = FeatureSelector::correlation_filter(&features, 0.95);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0], "only");
}
#[test]
fn select_top_k_returns_correct_count() {
let importances = vec![
FeatureImportance {
name: "a".to_string(),
score: 0.5,
},
FeatureImportance {
name: "b".to_string(),
score: 0.8,
},
FeatureImportance {
name: "c".to_string(),
score: 0.3,
},
FeatureImportance {
name: "d".to_string(),
score: 0.9,
},
];
let top2 = FeatureSelector::select_top_k(&importances, 2);
assert_eq!(top2.len(), 2);
assert_eq!(top2[0], "d");
assert_eq!(top2[1], "b");
}
#[test]
fn select_top_k_exceeds_length() {
let importances = vec![FeatureImportance {
name: "only".to_string(),
score: 1.0,
}];
let top5 = FeatureSelector::select_top_k(&importances, 5);
assert_eq!(top5.len(), 1);
}
#[test]
fn rank_features_scores_sum_to_one() {
let features = make_features(&[
("a", vec![1.0, 10.0, 3.0]),
("b", vec![5.0, 5.0, 5.0]),
("c", vec![2.0, 8.0, 4.0]),
]);
let ranked = rank_features(&features);
let total: f64 = ranked.iter().map(|r| r.score).sum();
assert_relative_eq!(total, 1.0, epsilon = 1e-10);
}
#[test]
fn rank_features_highest_variance_first() {
let features = make_features(&[
("low_var", vec![1.0, 1.1, 0.9]),
("high_var", vec![1.0, 100.0, 50.0]),
]);
let ranked = rank_features(&features);
assert_eq!(ranked[0].name, "high_var");
assert!(ranked[0].score > ranked[1].score);
}
#[test]
fn rank_features_empty() {
let features: HashMap<String, Vec<f64>> = HashMap::new();
let ranked = rank_features(&features);
assert!(ranked.is_empty());
}
#[test]
fn rank_features_all_constant() {
let features = make_features(&[("a", vec![1.0, 1.0, 1.0]), ("b", vec![2.0, 2.0, 2.0])]);
let ranked = rank_features(&features);
for fi in &ranked {
assert_relative_eq!(fi.score, 0.5, epsilon = 1e-10);
}
}
#[test]
fn select_features_full_pipeline() {
let features = make_features(&[
("varying", vec![1.0, 2.0, 3.0, 4.0, 5.0]),
("constant", vec![7.0, 7.0, 7.0, 7.0, 7.0]),
("correlated", vec![2.0, 4.0, 6.0, 8.0, 10.0]), ("independent", vec![5.0, 3.0, 1.0, 4.0, 2.0]),
]);
let config = FeatureSelectionConfig {
min_variance: 0.01,
max_correlation: 0.95,
max_features: None,
};
let selected = select_features(&features, config);
assert!(!selected.contains(&"constant".to_string()));
assert!(selected.contains(&"independent".to_string()));
let has_corr = selected.contains(&"correlated".to_string());
let has_vary = selected.contains(&"varying".to_string());
assert!(has_corr || has_vary);
assert!(!(has_corr && has_vary));
}
#[test]
fn select_features_with_max_features() {
let features = make_features(&[
("a", vec![1.0, 10.0, 3.0, 7.0]),
("b", vec![2.0, 20.0, 5.0, 15.0]),
("c", vec![100.0, 1.0, 50.0, 25.0]),
("d", vec![3.0, 3.0, 3.0, 3.0]),
]);
let config = FeatureSelectionConfig {
min_variance: 0.01,
max_correlation: 0.95,
max_features: Some(1),
};
let selected = select_features(&features, config);
assert!(selected.len() <= 1);
}
#[test]
fn select_features_empty_input() {
let features: HashMap<String, Vec<f64>> = HashMap::new();
let config = FeatureSelectionConfig::default();
let selected = select_features(&features, config);
assert!(selected.is_empty());
}
#[test]
fn select_features_all_identical_values() {
let features = make_features(&[
("a", vec![1.0, 1.0, 1.0]),
("b", vec![2.0, 2.0, 2.0]),
("c", vec![3.0, 3.0, 3.0]),
]);
let config = FeatureSelectionConfig::default();
let selected = select_features(&features, config);
assert!(selected.is_empty());
}
#[test]
fn pearson_correlation_perfect() {
let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let b = vec![2.0, 4.0, 6.0, 8.0, 10.0];
assert_relative_eq!(pearson_correlation(&a, &b), 1.0, epsilon = 1e-10);
}
#[test]
fn pearson_correlation_negative() {
let a = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let b = vec![10.0, 8.0, 6.0, 4.0, 2.0];
assert_relative_eq!(pearson_correlation(&a, &b), -1.0, epsilon = 1e-10);
}
#[test]
fn compute_variance_works() {
let values = vec![2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
assert_relative_eq!(compute_variance(&values), 4.0, epsilon = 1e-10);
}
#[test]
fn compute_variance_empty() {
assert_relative_eq!(compute_variance(&[]), 0.0, epsilon = 1e-10);
}
}