use crate::{FeatureImportance, IndexableTarget};
use scirs2_core::ndarray::{s, Array1, Array2, ArrayBase, Axis, Dim, OwnedRepr};
use scirs2_core::rand_prelude::SliceRandom;
use scirs2_core::random::{rngs::StdRng, thread_rng, RngExt, SeedableRng};
use sklears_core::{
error::{validate, Result as SklResult, SklearsError},
traits::{Estimator, Fit, Trained, Transform, Untrained},
types::Float,
};
use std::marker::PhantomData;
type Mat2f64 = ArrayBase<OwnedRepr<f64>, Dim<[usize; 2]>, f64>;
type Vec1f64 = ArrayBase<OwnedRepr<f64>, Dim<[usize; 1]>, f64>;
type Vec1i32 = ArrayBase<OwnedRepr<i32>, Dim<[usize; 1]>, i32>;
type Vec1usize = ArrayBase<OwnedRepr<usize>, Dim<[usize; 1]>, usize>;
use crate::base::SelectorMixin;
pub fn extract_features(x: &Array2<Float>, features: &[usize]) -> SklResult<Array2<Float>> {
let n_samples = x.nrows();
let n_selected = features.len();
let mut x_subset = Array2::zeros((n_samples, n_selected));
for (new_idx, &old_idx) in features.iter().enumerate() {
if old_idx >= x.ncols() {
return Err(SklearsError::InvalidInput(format!(
"Feature index {} out of bounds",
old_idx
)));
}
x_subset.column_mut(new_idx).assign(&x.column(old_idx));
}
Ok(x_subset)
}
#[derive(Debug, Clone)]
pub struct BootstrapSelector<E, State = Untrained> {
estimator: E,
n_bootstrap: usize,
sample_ratio: f64,
stability_threshold: f64,
min_selection_frequency: f64,
max_features: Option<usize>,
random_state: Option<u64>,
state: PhantomData<State>,
feature_frequencies_: Option<Array1<Float>>,
stability_scores_: Option<Array1<Float>>,
selected_features_: Option<Vec<usize>>,
n_features_: Option<usize>,
bootstrap_selections_: Option<Vec<Vec<usize>>>,
}
impl<E: Clone> BootstrapSelector<E, Untrained> {
pub fn new(estimator: E) -> Self {
Self {
estimator,
n_bootstrap: 100,
sample_ratio: 0.8,
stability_threshold: 0.7,
min_selection_frequency: 0.5,
max_features: None,
random_state: None,
state: PhantomData,
feature_frequencies_: None,
stability_scores_: None,
selected_features_: None,
n_features_: None,
bootstrap_selections_: None,
}
}
pub fn n_bootstrap(mut self, n_bootstrap: usize) -> Result<Self, SklearsError> {
if n_bootstrap < 1 {
return Err(SklearsError::InvalidInput(
"n_bootstrap must be at least 1".to_string(),
));
}
self.n_bootstrap = n_bootstrap;
Ok(self)
}
pub fn sample_ratio(mut self, sample_ratio: f64) -> Result<Self, SklearsError> {
if !(0.0..=1.0).contains(&sample_ratio) {
return Err(SklearsError::InvalidInput(
"sample_ratio must be between 0 and 1".to_string(),
));
}
self.sample_ratio = sample_ratio;
Ok(self)
}
pub fn stability_threshold(mut self, threshold: f64) -> Result<Self, SklearsError> {
if !(0.0..=1.0).contains(&threshold) {
return Err(SklearsError::InvalidInput(
"stability_threshold must be between 0 and 1".to_string(),
));
}
self.stability_threshold = threshold;
Ok(self)
}
pub fn min_selection_frequency(mut self, frequency: f64) -> Result<Self, SklearsError> {
if !(0.0..=1.0).contains(&frequency) {
return Err(SklearsError::InvalidInput(
"min_selection_frequency must be between 0 and 1".to_string(),
));
}
self.min_selection_frequency = frequency;
Ok(self)
}
pub fn max_features(mut self, max_features: Option<usize>) -> Self {
self.max_features = max_features;
self
}
pub fn random_state(mut self, seed: u64) -> Self {
self.random_state = Some(seed);
self
}
}
impl<E> Estimator for BootstrapSelector<E, Untrained> {
type Config = ();
type Error = SklearsError;
type Float = f64;
fn config(&self) -> &Self::Config {
&()
}
}
macro_rules! impl_fit_for_bootstrap {
($y_type:ty) => {
impl<E> Fit<Mat2f64, $y_type> for BootstrapSelector<E, Untrained>
where
E: Clone + Fit<Mat2f64, $y_type> + Send + Sync,
E::Fitted: FeatureImportance + Send + Sync,
$y_type: IndexableTarget,
{
type Fitted = BootstrapSelector<E, Trained>;
fn fit(self, x: &Mat2f64, y: &$y_type) -> SklResult<Self::Fitted> {
let (n_samples, n_features) = x.dim();
if n_samples < 2 {
return Err(SklearsError::InvalidInput(
"Bootstrap selection requires at least 2 samples".to_string(),
));
}
let mut rng = if let Some(seed) = self.random_state {
StdRng::seed_from_u64(seed)
} else {
StdRng::from_rng(&mut thread_rng())
};
let sample_size = (n_samples as f64 * self.sample_ratio).round() as usize;
let mut feature_counts = Array1::zeros(n_features);
let mut bootstrap_selections = Vec::with_capacity(self.n_bootstrap);
for _ in 0..self.n_bootstrap {
let mut bootstrap_indices = Vec::with_capacity(sample_size);
for _ in 0..sample_size {
bootstrap_indices.push(rng.random_range(0..n_samples));
}
let x_bootstrap = x.select(Axis(0), &bootstrap_indices);
let y_bootstrap = y.select(&bootstrap_indices);
match self.estimator.clone().fit(&x_bootstrap, &y_bootstrap) {
Ok(fitted_estimator) => {
if let Ok(importances) = fitted_estimator.feature_importances() {
let threshold = importances.mean().unwrap_or(0.0);
let selected_features: Vec<usize> = importances
.iter()
.enumerate()
.filter(|(_, &importance)| importance > threshold)
.map(|(idx, _)| idx)
.collect();
for &feature_idx in &selected_features {
feature_counts[feature_idx] += 1.0;
}
bootstrap_selections.push(selected_features);
}
}
Err(_) => {
continue;
}
}
}
let feature_frequencies = feature_counts / self.n_bootstrap as f64;
let mut stability_scores = Array1::zeros(n_features);
if bootstrap_selections.len() >= 2 {
for feature_idx in 0..n_features {
let mut jaccard_similarities = Vec::new();
for i in 0..(bootstrap_selections.len() - 1) {
let set1_contains = bootstrap_selections[i].contains(&feature_idx);
let set2_contains = bootstrap_selections[i + 1].contains(&feature_idx);
let similarity = if set1_contains == set2_contains {
1.0
} else {
0.0
};
jaccard_similarities.push(similarity);
}
stability_scores[feature_idx] = jaccard_similarities.iter().sum::<f64>()
/ jaccard_similarities.len() as f64;
}
}
let mut selected_features: Vec<usize> = (0..n_features)
.filter(|&i| {
feature_frequencies[i] >= self.min_selection_frequency
&& stability_scores[i] >= self.stability_threshold
})
.collect();
if let Some(max_feat) = self.max_features {
if selected_features.len() > max_feat {
selected_features.sort_by(|&a, &b| {
let score_a: f64 = feature_frequencies[a] * stability_scores[a];
let score_b: f64 = feature_frequencies[b] * stability_scores[b];
score_b.partial_cmp(&score_a).unwrap_or_else(|| {
match (score_b.is_nan(), score_a.is_nan()) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => std::cmp::Ordering::Equal,
}
})
});
selected_features.truncate(max_feat);
selected_features.sort(); }
}
if selected_features.is_empty() {
return Err(SklearsError::InvalidInput(
"No features selected by Bootstrap method. Try reducing thresholds."
.to_string(),
));
}
Ok(BootstrapSelector {
estimator: self.estimator,
n_bootstrap: self.n_bootstrap,
sample_ratio: self.sample_ratio,
stability_threshold: self.stability_threshold,
min_selection_frequency: self.min_selection_frequency,
max_features: self.max_features,
random_state: self.random_state,
state: PhantomData,
feature_frequencies_: Some(feature_frequencies),
stability_scores_: Some(stability_scores),
selected_features_: Some(selected_features),
n_features_: Some(n_features),
bootstrap_selections_: Some(bootstrap_selections),
})
}
}
};
}
impl_fit_for_bootstrap!(Vec1f64);
impl_fit_for_bootstrap!(Vec1i32);
impl_fit_for_bootstrap!(Vec1usize);
impl<E> Transform<Array2<Float>> for BootstrapSelector<E, Trained> {
fn transform(&self, x: &Array2<Float>) -> SklResult<Array2<Float>> {
let n_features = self.n_features_.ok_or_else(|| {
SklearsError::FitError("BootstrapSelector not fitted: n_features_ missing".to_string())
})?;
validate::check_n_features(x, n_features)?;
let selected_features = self.selected_features_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"BootstrapSelector not fitted: selected_features_ missing".to_string(),
)
})?;
extract_features(x, selected_features)
}
}
impl<E> SelectorMixin for BootstrapSelector<E, Trained> {
fn get_support(&self) -> SklResult<Array1<bool>> {
let n_features = self.n_features_.ok_or_else(|| {
SklearsError::FitError("BootstrapSelector not fitted: n_features_ missing".to_string())
})?;
let selected_features = self.selected_features_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"BootstrapSelector not fitted: selected_features_ missing".to_string(),
)
})?;
let mut support = Array1::from_elem(n_features, false);
for &idx in selected_features {
support[idx] = true;
}
Ok(support)
}
fn transform_features(&self, indices: &[usize]) -> SklResult<Vec<usize>> {
let selected_features = self.selected_features_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"BootstrapSelector not fitted: selected_features_ missing".to_string(),
)
})?;
Ok(indices
.iter()
.filter_map(|&idx| selected_features.iter().position(|&f| f == idx))
.collect())
}
}
impl<E> BootstrapSelector<E, Trained> {
pub fn feature_frequencies(&self) -> SklResult<&Array1<Float>> {
self.feature_frequencies_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"BootstrapSelector not fitted: feature_frequencies_ missing".to_string(),
)
})
}
pub fn stability_scores(&self) -> SklResult<&Array1<Float>> {
self.stability_scores_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"BootstrapSelector not fitted: stability_scores_ missing".to_string(),
)
})
}
pub fn selected_features(&self) -> SklResult<&[usize]> {
self.selected_features_.as_deref().ok_or_else(|| {
SklearsError::FitError(
"BootstrapSelector not fitted: selected_features_ missing".to_string(),
)
})
}
pub fn bootstrap_selections(&self) -> SklResult<&[Vec<usize>]> {
self.bootstrap_selections_.as_deref().ok_or_else(|| {
SklearsError::FitError(
"BootstrapSelector not fitted: bootstrap_selections_ missing".to_string(),
)
})
}
pub fn selection_stability(&self) -> SklResult<f64> {
let selections = self.bootstrap_selections_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"BootstrapSelector not fitted: bootstrap_selections_ missing".to_string(),
)
})?;
if selections.len() < 2 {
return Ok(1.0); }
let mut similarities = Vec::new();
for i in 0..(selections.len() - 1) {
for j in (i + 1)..selections.len() {
let set1: std::collections::HashSet<usize> =
selections[i].iter().cloned().collect();
let set2: std::collections::HashSet<usize> =
selections[j].iter().cloned().collect();
let intersection = set1.intersection(&set2).count();
let union = set1.union(&set2).count();
let jaccard = if union == 0 {
1.0
} else {
intersection as f64 / union as f64
};
similarities.push(jaccard);
}
}
Ok(similarities.iter().sum::<f64>() / similarities.len() as f64)
}
}
#[derive(Debug, Clone)]
pub struct BorutaSelector<E, State = Untrained> {
estimator: E,
n_estimators: usize,
max_iter: usize,
alpha: f64,
two_step: bool,
percentile: f64,
random_state: Option<u64>,
state: PhantomData<State>,
confirmed_features_: Option<Vec<usize>>,
tentative_features_: Option<Vec<usize>>,
rejected_features_: Option<Vec<usize>>,
feature_importances_: Option<Array1<Float>>,
shadow_importances_: Option<Array1<Float>>,
n_features_: Option<usize>,
n_iterations_: Option<usize>,
}
impl<E> BorutaSelector<E, Untrained> {
pub fn new(estimator: E) -> Self {
Self {
estimator,
n_estimators: 100,
max_iter: 100,
alpha: 0.05,
two_step: true,
percentile: 100.0,
random_state: None,
state: PhantomData,
confirmed_features_: None,
tentative_features_: None,
rejected_features_: None,
feature_importances_: None,
shadow_importances_: None,
n_features_: None,
n_iterations_: None,
}
}
pub fn n_estimators(mut self, n_estimators: usize) -> Self {
self.n_estimators = n_estimators;
self
}
pub fn max_iter(mut self, max_iter: usize) -> Self {
self.max_iter = max_iter;
self
}
pub fn alpha(mut self, alpha: f64) -> Result<Self, SklearsError> {
if alpha <= 0.0 || alpha >= 1.0 {
return Err(SklearsError::InvalidInput(
"alpha must be between 0 and 1".to_string(),
));
}
self.alpha = alpha;
Ok(self)
}
pub fn two_step(mut self, two_step: bool) -> Self {
self.two_step = two_step;
self
}
pub fn percentile(mut self, percentile: f64) -> Result<Self, SklearsError> {
if percentile <= 0.0 || percentile > 100.0 {
return Err(SklearsError::InvalidInput(
"percentile must be between 0 and 100".to_string(),
));
}
self.percentile = percentile;
Ok(self)
}
pub fn random_state(mut self, random_state: u64) -> Self {
self.random_state = Some(random_state);
self
}
}
impl<E> Estimator for BorutaSelector<E, Untrained> {
type Config = ();
type Error = SklearsError;
type Float = f64;
fn config(&self) -> &Self::Config {
&()
}
}
impl<E> Fit<Mat2f64, Vec1f64> for BorutaSelector<E, Untrained>
where
E: Clone + Fit<Mat2f64, Vec1f64> + FeatureImportance + Send + Sync,
E::Fitted: FeatureImportance + Send + Sync,
{
type Fitted = BorutaSelector<E, Trained>;
fn fit(self, x: &Mat2f64, y: &Vec1f64) -> SklResult<Self::Fitted> {
validate::check_consistent_length(x, y)?;
let (n_samples, n_features) = x.dim();
if n_samples < 2 {
return Err(SklearsError::InvalidInput(
"Boruta requires at least 2 samples".to_string(),
));
}
let mut rng = if let Some(seed) = self.random_state {
StdRng::seed_from_u64(seed)
} else {
StdRng::from_rng(&mut thread_rng())
};
let mut confirmed_features = Vec::new();
let mut tentative_features: Vec<usize> = (0..n_features).collect();
let mut rejected_features = Vec::new();
let mut feature_hits = vec![0; n_features];
let mut shadow_hits = 0;
let mut iteration = 0;
while iteration < self.max_iter && !tentative_features.is_empty() {
let (x_augmented, _feature_mapping) =
self.create_shadow_features(x, &tentative_features, &mut rng)?;
let importances = self.get_ensemble_importances(&x_augmented, y)?;
let n_real_features = tentative_features.len();
let shadow_importances = &importances.slice(s![n_real_features..]);
let shadow_threshold = self.calculate_threshold(shadow_importances)?;
shadow_hits += 1;
for (i, &feature_idx) in tentative_features.iter().enumerate() {
if importances[i] > shadow_threshold {
feature_hits[feature_idx] += 1;
}
}
let _critical_value = self.calculate_critical_value(iteration + 1)?;
let mut to_confirm = Vec::new();
let mut to_reject = Vec::new();
for &feature_idx in &tentative_features {
let hit_rate = feature_hits[feature_idx] as f64 / (iteration + 1) as f64;
let shadow_hit_rate = shadow_hits as f64 / (iteration + 1) as f64;
if self.binomial_test(feature_hits[feature_idx], iteration + 1, shadow_hit_rate)
< self.alpha
&& hit_rate > shadow_hit_rate
{
to_confirm.push(feature_idx);
}
if self.two_step
&& self.binomial_test(
shadow_hits - feature_hits[feature_idx],
iteration + 1,
1.0 - shadow_hit_rate,
) < self.alpha
&& hit_rate < shadow_hit_rate
{
to_reject.push(feature_idx);
}
}
for feature in to_confirm {
if let Some(pos) = tentative_features.iter().position(|&x| x == feature) {
tentative_features.remove(pos);
confirmed_features.push(feature);
}
}
for feature in to_reject {
if let Some(pos) = tentative_features.iter().position(|&x| x == feature) {
tentative_features.remove(pos);
rejected_features.push(feature);
}
}
iteration += 1;
}
let final_x_augmented = self
.create_shadow_features(x, &(0..n_features).collect::<Vec<_>>(), &mut rng)?
.0;
let final_importances = self.get_ensemble_importances(&final_x_augmented, y)?;
let feature_importances = final_importances.slice(s![..n_features]).to_owned();
let shadow_importances = final_importances.slice(s![n_features..]).to_owned();
if iteration >= self.max_iter {
rejected_features.extend(tentative_features.clone());
tentative_features.clear();
}
confirmed_features.sort_unstable();
tentative_features.sort_unstable();
rejected_features.sort_unstable();
Ok(BorutaSelector {
estimator: self.estimator,
n_estimators: self.n_estimators,
max_iter: self.max_iter,
alpha: self.alpha,
two_step: self.two_step,
percentile: self.percentile,
random_state: self.random_state,
state: PhantomData,
confirmed_features_: Some(confirmed_features),
tentative_features_: Some(tentative_features),
rejected_features_: Some(rejected_features),
feature_importances_: Some(feature_importances),
shadow_importances_: Some(shadow_importances),
n_features_: Some(n_features),
n_iterations_: Some(iteration),
})
}
}
impl<E> BorutaSelector<E, Untrained>
where
E: Clone + Fit<Mat2f64, Vec1f64> + FeatureImportance,
E::Fitted: FeatureImportance,
{
fn create_shadow_features(
&self,
x: &Mat2f64,
feature_indices: &[usize],
rng: &mut StdRng,
) -> SklResult<(Mat2f64, Vec<usize>)> {
let (n_samples, _) = x.dim();
let n_features = feature_indices.len();
let mut x_augmented = Array2::<f64>::zeros((n_samples, n_features * 2));
for (new_idx, &orig_idx) in feature_indices.iter().enumerate() {
x_augmented.column_mut(new_idx).assign(&x.column(orig_idx));
}
for (new_idx, &orig_idx) in feature_indices.iter().enumerate() {
let mut shadow_column: Vec<Float> = x.column(orig_idx).to_vec();
shadow_column.shuffle(rng);
let shadow_idx = n_features + new_idx;
for (row, &value) in shadow_column.iter().enumerate() {
x_augmented[[row, shadow_idx]] = value;
}
}
let feature_mapping = feature_indices.to_vec();
Ok((x_augmented, feature_mapping))
}
fn get_ensemble_importances(&self, x: &Mat2f64, y: &Vec1f64) -> SklResult<Array1<Float>> {
let n_features = x.ncols();
let mut total_importances = Array1::<Float>::zeros(n_features);
for _ in 0..self.n_estimators {
let estimator = self.estimator.clone();
let trained = estimator.fit(x, y)?;
let importances = trained.feature_importances()?;
total_importances = total_importances + importances;
}
total_importances /= self.n_estimators as Float;
Ok(total_importances)
}
fn calculate_threshold(
&self,
shadow_importances: &ArrayBase<
scirs2_core::ndarray::ViewRepr<&Float>,
scirs2_core::ndarray::Dim<[usize; 1]>,
>,
) -> SklResult<Float> {
if shadow_importances.is_empty() {
return Ok(0.0);
}
let mut sorted_importances: Vec<Float> = shadow_importances.to_vec();
sorted_importances.sort_by(|a, b| {
b.partial_cmp(a)
.unwrap_or_else(|| match (b.is_nan(), a.is_nan()) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => std::cmp::Ordering::Equal,
})
});
let index = ((self.percentile / 100.0) * sorted_importances.len() as f64).ceil() as usize;
let threshold_index = (index - 1).min(sorted_importances.len() - 1);
Ok(sorted_importances[threshold_index])
}
fn calculate_critical_value(&self, n_trials: usize) -> SklResult<f64> {
let corrected_alpha = self.alpha / n_trials as f64;
Ok(corrected_alpha)
}
fn binomial_test(&self, successes: usize, trials: usize, expected_prob: f64) -> f64 {
if trials == 0 {
return 1.0;
}
let observed_prob = successes as f64 / trials as f64;
if trials > 30 {
let mean = expected_prob;
let variance = expected_prob * (1.0 - expected_prob) / trials as f64;
let std_dev = variance.sqrt();
if std_dev > 1e-10 {
let z_score = (observed_prob - mean) / std_dev;
2.0 * (1.0 - self.standard_normal_cdf(z_score.abs()))
} else {
1.0
}
} else {
if (observed_prob - expected_prob).abs() < 1e-10 {
1.0
} else {
0.01 }
}
}
fn standard_normal_cdf(&self, x: f64) -> f64 {
0.5 * (1.0 + self.erf(x / 2.0_f64.sqrt()))
}
fn erf(&self, x: f64) -> f64 {
let a1 = 0.254829592;
let a2 = -0.284496736;
let a3 = 1.421413741;
let a4 = -1.453152027;
let a5 = 1.061405429;
let p = 0.3275911;
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let x = x.abs();
let t = 1.0 / (1.0 + p * x);
let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp();
sign * y
}
}
impl<E> Transform<Array2<f64>> for BorutaSelector<E, Trained> {
fn transform(&self, x: &Array2<f64>) -> SklResult<Array2<f64>> {
let support = self.get_support()?;
let selected_indices: Vec<usize> = support
.iter()
.enumerate()
.filter_map(|(i, &selected)| if selected { Some(i) } else { None })
.collect();
if selected_indices.is_empty() {
return Err(SklearsError::InvalidInput(
"No features were selected".to_string(),
));
}
let n_samples = x.nrows();
let n_selected = selected_indices.len();
let mut result = Array2::<f64>::zeros((n_samples, n_selected));
for (new_idx, &orig_idx) in selected_indices.iter().enumerate() {
result.column_mut(new_idx).assign(&x.column(orig_idx));
}
Ok(result)
}
}
impl<E> SelectorMixin for BorutaSelector<E, Trained> {
fn get_support(&self) -> SklResult<Array1<bool>> {
let n_features = self.n_features_.ok_or_else(|| {
SklearsError::FitError("BorutaSelector not fitted: n_features_ missing".to_string())
})?;
let confirmed = self.confirmed_features_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"BorutaSelector not fitted: confirmed_features_ missing".to_string(),
)
})?;
let tentative = self.tentative_features_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"BorutaSelector not fitted: tentative_features_ missing".to_string(),
)
})?;
let mut support = Array1::<bool>::from_elem(n_features, false);
for &idx in confirmed {
support[idx] = true;
}
for &idx in tentative {
support[idx] = true;
}
Ok(support)
}
fn transform_features(&self, indices: &[usize]) -> SklResult<Vec<usize>> {
let confirmed = self.confirmed_features_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"BorutaSelector not fitted: confirmed_features_ missing".to_string(),
)
})?;
let tentative = self.tentative_features_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"BorutaSelector not fitted: tentative_features_ missing".to_string(),
)
})?;
let mut selected_set = std::collections::HashSet::new();
for &idx in confirmed {
selected_set.insert(idx);
}
for &idx in tentative {
selected_set.insert(idx);
}
let transformed: Vec<usize> = indices
.iter()
.filter(|&&idx| selected_set.contains(&idx))
.enumerate()
.map(|(new_idx, _)| new_idx)
.collect();
Ok(transformed)
}
}
impl<E> BorutaSelector<E, Trained> {
pub fn confirmed_features(&self) -> SklResult<&[usize]> {
self.confirmed_features_.as_deref().ok_or_else(|| {
SklearsError::FitError(
"BorutaSelector not fitted: confirmed_features_ missing".to_string(),
)
})
}
pub fn tentative_features(&self) -> SklResult<&[usize]> {
self.tentative_features_.as_deref().ok_or_else(|| {
SklearsError::FitError(
"BorutaSelector not fitted: tentative_features_ missing".to_string(),
)
})
}
pub fn rejected_features(&self) -> SklResult<&[usize]> {
self.rejected_features_.as_deref().ok_or_else(|| {
SklearsError::FitError(
"BorutaSelector not fitted: rejected_features_ missing".to_string(),
)
})
}
pub fn feature_importances(&self) -> SklResult<&Array1<Float>> {
self.feature_importances_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"BorutaSelector not fitted: feature_importances_ missing".to_string(),
)
})
}
pub fn shadow_importances(&self) -> SklResult<&Array1<Float>> {
self.shadow_importances_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"BorutaSelector not fitted: shadow_importances_ missing".to_string(),
)
})
}
pub fn n_iterations(&self) -> SklResult<usize> {
self.n_iterations_.ok_or_else(|| {
SklearsError::FitError("BorutaSelector not fitted: n_iterations_ missing".to_string())
})
}
pub fn selected_features(&self) -> SklResult<Vec<usize>> {
let confirmed = self.confirmed_features_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"BorutaSelector not fitted: confirmed_features_ missing".to_string(),
)
})?;
let tentative = self.tentative_features_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"BorutaSelector not fitted: tentative_features_ missing".to_string(),
)
})?;
let mut selected = confirmed.clone();
selected.extend_from_slice(tentative);
selected.sort_unstable();
Ok(selected)
}
pub fn converged(&self) -> SklResult<bool> {
let tentative = self.tentative_features_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"BorutaSelector not fitted: tentative_features_ missing".to_string(),
)
})?;
Ok(tentative.is_empty())
}
}
#[derive(Debug, Clone)]
pub enum AggregationMethod {
Average,
WeightedAverage,
Median,
BordaCount,
ReciprocalRankFusion,
MajorityVoting,
}
pub trait SelectorFunction: Send + Sync + std::fmt::Debug {
fn compute_scores(
&self,
x: &Array2<f64>,
y_classif: Option<&Array1<i32>>,
y_regression: Option<&Array1<f64>>,
) -> SklResult<Array1<f64>>;
fn name(&self) -> &str;
fn clone_box(&self) -> Box<dyn SelectorFunction>;
}
impl Clone for Box<dyn SelectorFunction> {
fn clone(&self) -> Self {
self.clone_box()
}
}
#[derive(Debug, Clone)]
pub struct UnivariateSelector {
method: UnivariateMethod,
}
#[derive(Debug, Clone)]
pub enum UnivariateMethod {
Chi2,
FClassif,
FRegression,
MutualInfoClassif,
MutualInfoRegression,
VarianceThreshold(f64),
}
impl UnivariateSelector {
pub fn new(method: UnivariateMethod) -> Self {
Self { method }
}
}
impl SelectorFunction for UnivariateSelector {
fn compute_scores(
&self,
x: &Array2<f64>,
y_classif: Option<&Array1<i32>>,
y_regression: Option<&Array1<f64>>,
) -> SklResult<Array1<f64>> {
use crate::statistical_tests::*;
match &self.method {
UnivariateMethod::Chi2 => {
let y = y_classif.ok_or_else(|| {
SklearsError::InvalidInput("Chi2 requires classification target".to_string())
})?;
let y_f64 = y.mapv(|v| v as f64);
let (scores, _) = chi2(&x.view(), &y_f64.view())
.map_err(|e| SklearsError::FitError(format!("Chi2 test failed: {:?}", e)))?;
Ok(scores)
}
UnivariateMethod::FClassif => {
let y = y_classif.ok_or_else(|| {
SklearsError::InvalidInput(
"F-classif requires classification target".to_string(),
)
})?;
let y_f64 = y.mapv(|v| v as f64);
let (scores, _) = f_classif(&x.view(), &y_f64.view())
.map_err(|e| SklearsError::FitError(format!("F-test failed: {:?}", e)))?;
Ok(scores)
}
UnivariateMethod::FRegression => {
let y = y_regression.ok_or_else(|| {
SklearsError::InvalidInput(
"F-regression requires regression target".to_string(),
)
})?;
let (scores, _) = f_regression(&x.view(), &y.view())
.map_err(|e| SklearsError::FitError(format!("F-regression failed: {:?}", e)))?;
Ok(scores)
}
UnivariateMethod::MutualInfoClassif => {
let y = y_classif.ok_or_else(|| {
SklearsError::InvalidInput(
"Mutual info classif requires classification target".to_string(),
)
})?;
let y_f64 = y.mapv(|v| v as f64);
mutual_info_classif(&x.view(), &y_f64.view()).map_err(|e| {
SklearsError::FitError(format!("Mutual info classif failed: {:?}", e))
})
}
UnivariateMethod::MutualInfoRegression => {
let y = y_regression.ok_or_else(|| {
SklearsError::InvalidInput(
"Mutual info regression requires regression target".to_string(),
)
})?;
mutual_info_regression(&x.view(), &y.view()).map_err(|e| {
SklearsError::FitError(format!("Mutual info regression failed: {:?}", e))
})
}
UnivariateMethod::VarianceThreshold(_threshold) => {
let variances = x
.axis_iter(Axis(1))
.map(|col| {
let mean = col.mean().unwrap_or(0.0);
col.mapv(|x| (x - mean).powi(2)).mean().unwrap_or(0.0)
})
.collect::<Vec<f64>>();
Ok(Array1::from(variances))
}
}
}
fn name(&self) -> &str {
match &self.method {
UnivariateMethod::Chi2 => "chi2",
UnivariateMethod::FClassif => "f_classif",
UnivariateMethod::FRegression => "f_regression",
UnivariateMethod::MutualInfoClassif => "mutual_info_classif",
UnivariateMethod::MutualInfoRegression => "mutual_info_regression",
UnivariateMethod::VarianceThreshold(_) => "variance_threshold",
}
}
fn clone_box(&self) -> Box<dyn SelectorFunction> {
Box::new(self.clone())
}
}
#[derive(Debug, Clone)]
pub struct EnsembleFeatureRanking<State = Untrained> {
selectors: Vec<Box<dyn SelectorFunction>>,
aggregation_method: AggregationMethod,
weights: Option<Vec<f64>>,
state: PhantomData<State>,
rankings_: Option<Vec<Array1<f64>>>,
final_ranking_: Option<Array1<f64>>,
n_features_: Option<usize>,
}
impl Default for EnsembleFeatureRanking<Untrained> {
fn default() -> Self {
Self::new()
}
}
impl EnsembleFeatureRanking<Untrained> {
pub fn new() -> Self {
Self {
selectors: Vec::new(),
aggregation_method: AggregationMethod::Average,
weights: None,
state: PhantomData,
rankings_: None,
final_ranking_: None,
n_features_: None,
}
}
pub fn add_selector(mut self, selector: Box<dyn SelectorFunction>) -> Self {
self.selectors.push(selector);
self
}
pub fn aggregation_method(mut self, method: AggregationMethod) -> Self {
self.aggregation_method = method;
self
}
pub fn weights(mut self, weights: Vec<f64>) -> Self {
self.weights = Some(weights);
self
}
pub fn add_univariate_selectors_classif(mut self) -> Self {
self.selectors
.push(Box::new(UnivariateSelector::new(UnivariateMethod::Chi2)));
self.selectors.push(Box::new(UnivariateSelector::new(
UnivariateMethod::FClassif,
)));
self.selectors.push(Box::new(UnivariateSelector::new(
UnivariateMethod::MutualInfoClassif,
)));
self
}
pub fn add_univariate_selectors_regression(mut self) -> Self {
self.selectors.push(Box::new(UnivariateSelector::new(
UnivariateMethod::FRegression,
)));
self.selectors.push(Box::new(UnivariateSelector::new(
UnivariateMethod::MutualInfoRegression,
)));
self.selectors.push(Box::new(UnivariateSelector::new(
UnivariateMethod::VarianceThreshold(0.0),
)));
self
}
pub fn add_tree_selector(mut self) -> Self {
self.selectors.push(Box::new(TreeImportanceSelector::new()));
self
}
}
#[derive(Debug, Clone)]
pub struct TreeImportanceSelector {}
impl Default for TreeImportanceSelector {
fn default() -> Self {
Self::new()
}
}
impl TreeImportanceSelector {
pub fn new() -> Self {
Self {}
}
}
impl SelectorFunction for TreeImportanceSelector {
fn compute_scores(
&self,
x: &Array2<f64>,
_y_classif: Option<&Array1<i32>>,
_y_regression: Option<&Array1<f64>>,
) -> SklResult<Array1<f64>> {
let n_features = x.ncols();
Ok(Array1::ones(n_features))
}
fn name(&self) -> &str {
"tree_importance"
}
fn clone_box(&self) -> Box<dyn SelectorFunction> {
Box::new(self.clone())
}
}
impl Fit<Array2<f64>, Array1<i32>> for EnsembleFeatureRanking<Untrained> {
type Fitted = EnsembleFeatureRanking<Trained>;
fn fit(self, x: &Array2<f64>, y: &Array1<i32>) -> SklResult<Self::Fitted> {
validate::check_consistent_length(x, y)?;
let n_features = x.ncols();
let n_selectors = self.selectors.len();
if n_selectors == 0 {
return Err(SklearsError::InvalidInput(
"No selectors added to ensemble".to_string(),
));
}
let mut rankings = Vec::with_capacity(n_selectors);
for selector in &self.selectors {
let scores = selector.compute_scores(x, Some(y), None)?;
rankings.push(scores);
}
let final_ranking = self.aggregate_rankings(&rankings)?;
Ok(EnsembleFeatureRanking {
selectors: self.selectors,
aggregation_method: self.aggregation_method,
weights: self.weights,
state: PhantomData,
rankings_: Some(rankings),
final_ranking_: Some(final_ranking),
n_features_: Some(n_features),
})
}
}
impl Fit<Array2<f64>, Array1<f64>> for EnsembleFeatureRanking<Untrained> {
type Fitted = EnsembleFeatureRanking<Trained>;
fn fit(self, x: &Array2<f64>, y: &Array1<f64>) -> SklResult<Self::Fitted> {
validate::check_consistent_length(x, y)?;
let n_features = x.ncols();
let n_selectors = self.selectors.len();
if n_selectors == 0 {
return Err(SklearsError::InvalidInput(
"No selectors added to ensemble".to_string(),
));
}
let mut rankings = Vec::with_capacity(n_selectors);
for selector in &self.selectors {
let scores = selector.compute_scores(x, None, Some(y))?;
rankings.push(scores);
}
let final_ranking = self.aggregate_rankings(&rankings)?;
Ok(EnsembleFeatureRanking {
selectors: self.selectors,
aggregation_method: self.aggregation_method,
weights: self.weights,
state: PhantomData,
rankings_: Some(rankings),
final_ranking_: Some(final_ranking),
n_features_: Some(n_features),
})
}
}
impl EnsembleFeatureRanking<Untrained> {
fn aggregate_rankings(&self, rankings: &[Array1<f64>]) -> SklResult<Array1<f64>> {
let n_features = rankings[0].len();
let n_selectors = rankings.len();
match &self.aggregation_method {
AggregationMethod::Average => {
let mut aggregated = Array1::<f64>::zeros(n_features);
for ranking in rankings {
aggregated += ranking;
}
aggregated /= n_selectors as f64;
Ok(aggregated)
}
AggregationMethod::WeightedAverage => {
let weights = self.weights.as_ref().ok_or_else(|| {
SklearsError::InvalidInput("Weights required for weighted average".to_string())
})?;
if weights.len() != n_selectors {
return Err(SklearsError::InvalidInput(
"Number of weights must match number of selectors".to_string(),
));
}
let mut aggregated = Array1::<f64>::zeros(n_features);
let weight_sum: f64 = weights.iter().sum();
for (ranking, &weight) in rankings.iter().zip(weights.iter()) {
aggregated += &(ranking * weight);
}
aggregated /= weight_sum;
Ok(aggregated)
}
AggregationMethod::Median => {
let mut aggregated = Array1::<f64>::zeros(n_features);
for i in 0..n_features {
let mut feature_scores: Vec<f64> = rankings.iter().map(|r| r[i]).collect();
feature_scores
.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
if n_selectors.is_multiple_of(2) {
aggregated[i] = (feature_scores[n_selectors / 2 - 1]
+ feature_scores[n_selectors / 2])
/ 2.0;
} else {
aggregated[i] = feature_scores[n_selectors / 2];
}
}
Ok(aggregated)
}
AggregationMethod::BordaCount => {
let mut aggregated = Array1::<f64>::zeros(n_features);
for ranking in rankings {
let ranks = self.scores_to_ranks(ranking);
aggregated += &ranks;
}
let max_rank = aggregated.iter().fold(0.0f64, |acc, &x| acc.max(x));
aggregated.mapv_inplace(|rank| max_rank - rank);
Ok(aggregated)
}
AggregationMethod::ReciprocalRankFusion => {
let mut aggregated = Array1::<f64>::zeros(n_features);
for ranking in rankings {
let ranks = self.scores_to_ranks(ranking);
for (i, &rank) in ranks.iter().enumerate() {
aggregated[i] += 1.0 / (rank + 60.0); }
}
Ok(aggregated)
}
AggregationMethod::MajorityVoting => {
let mut vote_count = Array1::<f64>::zeros(n_features);
for ranking in rankings {
let mut sorted_scores = ranking.to_vec();
sorted_scores
.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let threshold = sorted_scores[sorted_scores.len() / 2];
for (i, &score) in ranking.iter().enumerate() {
if score >= threshold {
vote_count[i] += 1.0;
}
}
}
Ok(vote_count)
}
}
}
fn scores_to_ranks(&self, scores: &Array1<f64>) -> Array1<f64> {
let mut indexed_scores: Vec<(usize, f64)> = scores
.indexed_iter()
.map(|(idx, &score)| (idx, score))
.collect();
indexed_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let mut ranks = Array1::<f64>::zeros(scores.len());
for (rank, &(idx, _)) in indexed_scores.iter().enumerate() {
ranks[idx] = rank as f64;
}
ranks
}
}
impl EnsembleFeatureRanking<Trained> {
pub fn feature_importances(&self) -> SklResult<&Array1<f64>> {
self.final_ranking_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"EnsembleFeatureRanking not fitted: final_ranking_ missing".to_string(),
)
})
}
pub fn individual_rankings(&self) -> SklResult<&[Array1<f64>]> {
self.rankings_.as_deref().ok_or_else(|| {
SklearsError::FitError(
"EnsembleFeatureRanking not fitted: rankings_ missing".to_string(),
)
})
}
pub fn select_k_best(&self, k: usize) -> SklResult<Vec<usize>> {
let ranking = self.final_ranking_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"EnsembleFeatureRanking not fitted: final_ranking_ missing".to_string(),
)
})?;
let mut indexed_scores: Vec<(usize, f64)> = ranking
.indexed_iter()
.map(|(idx, &score)| (idx, score))
.collect();
indexed_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(indexed_scores
.into_iter()
.take(k)
.map(|(idx, _)| idx)
.collect())
}
pub fn select_by_threshold(&self, threshold: f64) -> SklResult<Vec<usize>> {
let ranking = self.final_ranking_.as_ref().ok_or_else(|| {
SklearsError::FitError(
"EnsembleFeatureRanking not fitted: final_ranking_ missing".to_string(),
)
})?;
Ok(ranking
.indexed_iter()
.filter_map(|(idx, &score)| if score >= threshold { Some(idx) } else { None })
.collect())
}
}
impl Transform<Array2<f64>> for EnsembleFeatureRanking<Trained> {
fn transform(&self, x: &Array2<f64>) -> SklResult<Array2<f64>> {
let n_features = self.n_features_.ok_or_else(|| {
SklearsError::FitError(
"EnsembleFeatureRanking not fitted: n_features_ missing".to_string(),
)
})?;
if x.ncols() != n_features {
return Err(SklearsError::InvalidInput(format!(
"Expected {} features, got {}",
n_features,
x.ncols()
)));
}
let selected = self.select_k_best(n_features / 2)?;
let n_samples = x.nrows();
let n_selected = selected.len();
let mut x_new = Array2::<f64>::zeros((n_samples, n_selected));
for (new_idx, &old_idx) in selected.iter().enumerate() {
x_new.column_mut(new_idx).assign(&x.column(old_idx));
}
Ok(x_new)
}
}
impl SelectorMixin for EnsembleFeatureRanking<Trained> {
fn get_support(&self) -> SklResult<Array1<bool>> {
let n_features = self.n_features_.ok_or_else(|| {
SklearsError::FitError(
"EnsembleFeatureRanking not fitted: n_features_ missing".to_string(),
)
})?;
let k = n_features / 2;
let selected = self.select_k_best(k)?;
let mut support = vec![false; n_features];
for &idx in &selected {
support[idx] = true;
}
Ok(Array1::from(support))
}
fn transform_features(&self, indices: &[usize]) -> SklResult<Vec<usize>> {
let n_features = self.n_features_.ok_or_else(|| {
SklearsError::FitError(
"EnsembleFeatureRanking not fitted: n_features_ missing".to_string(),
)
})?;
let selected = self.select_k_best(n_features / 2)?;
Ok(indices
.iter()
.filter_map(|&idx| selected.iter().position(|&f| f == idx))
.collect())
}
}