stats_claw/algorithms/feature_selection/mod.rs
1//! Univariate feature selection: variance-threshold filtering and the ANOVA
2//! F-test (`f_classif`) univariate score.
3//!
4//! Both selectors operate on a feature matrix `x` given as one inner `Vec<f64>`
5//! per **sample** (row), with `x[i][j]` the value of feature `j` for sample `i`;
6//! every row must share the same feature count. They return a per-feature result
7//! plus a boolean selection mask so a caller can threshold, rank, or inspect the
8//! scores directly. The numerics pin the reference-library conventions the
9//! equivalence suite checks:
10//!
11//! * **variance threshold** drops features whose **population** variance
12//! (`ddof = 0`) is `≤ threshold`, keeping `variance > threshold`, matching
13//! `sklearn.feature_selection.VarianceThreshold`;
14//! * the **ANOVA F-test** computes, per feature, the one-way ANOVA F-statistic
15//! across the labelled classes and its upper-tail p-value, reproducing
16//! `sklearn.feature_selection.f_classif` (which is itself a per-feature one-way
17//! ANOVA). The selection keeps the top-`k` features by F-score.
18//!
19//! This base block backs the `FeatureSelection` computational method.
20
21mod stats;
22
23use crate::tests_stat::parametric::one_way_anova;
24use stats::population_variance;
25
26/// Errors that prevent a selector from scoring a feature matrix.
27///
28/// Returned (never panicked) so callers stay clear of the crate's `unwrap`/`panic`
29/// lint gate and can surface a clean diagnostic.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum FeatureSelectionError {
32 /// The feature matrix had no samples (no rows), so nothing can be scored.
33 EmptyInput,
34 /// The matrix had rows but no feature columns, so there is nothing to select.
35 NoFeatures,
36 /// The rows had differing feature counts (a ragged matrix), so columns are
37 /// undefined.
38 RaggedRows,
39 /// A matrix entry was non-finite (`NaN` or `±∞`), which has no valid score.
40 NonFinite,
41 /// The supplied threshold was not finite, so the keep rule would be ill-defined.
42 InvalidThreshold,
43 /// The label vector's length did not match the number of samples (rows).
44 LabelLengthMismatch,
45 /// Fewer than two distinct classes were present, so a between-class F-test is
46 /// undefined.
47 TooFewClasses,
48 /// A class had fewer observations than the F-test requires, or the
49 /// within-class variation was zero, so the ANOVA F-statistic is undefined.
50 DegenerateClasses,
51}
52
53impl std::fmt::Display for FeatureSelectionError {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 Self::EmptyInput => write!(f, "feature matrix has no samples"),
57 Self::NoFeatures => write!(f, "feature matrix has no feature columns"),
58 Self::RaggedRows => write!(f, "feature matrix rows have differing lengths"),
59 Self::NonFinite => write!(f, "feature matrix contains a non-finite value"),
60 Self::InvalidThreshold => write!(f, "variance threshold must be finite"),
61 Self::LabelLengthMismatch => {
62 write!(f, "label count does not match the number of samples")
63 }
64 Self::TooFewClasses => write!(f, "fewer than two distinct classes present"),
65 Self::DegenerateClasses => {
66 write!(f, "a class is too small or has zero within-class variation")
67 }
68 }
69 }
70}
71
72impl std::error::Error for FeatureSelectionError {}
73
74/// The result of running a univariate selector over a feature matrix: the
75/// per-feature scores and the boolean selection mask, aligned to column order.
76///
77/// The `scores` vector holds one value per feature column (a variance, or an
78/// ANOVA F-statistic), and `mask[j]` flags whether feature `j` was selected.
79/// The two vectors always share the feature count.
80#[derive(Debug, Clone, PartialEq)]
81pub struct Selection {
82 /// Per-feature score, in column order. The score's meaning depends on the
83 /// selector: the population variance, or the ANOVA F-statistic.
84 scores: Vec<f64>,
85 /// Per-feature selection flag, in column order: `true` where the feature was
86 /// kept.
87 mask: Vec<bool>,
88}
89
90impl Selection {
91 /// The per-feature scores, in column order.
92 #[must_use]
93 pub fn scores(&self) -> &[f64] {
94 &self.scores
95 }
96
97 /// The per-feature selection mask, in column order (`true` = kept).
98 #[must_use]
99 pub fn mask(&self) -> &[bool] {
100 &self.mask
101 }
102
103 /// The number of features selected.
104 #[must_use]
105 pub fn selected_count(&self) -> usize {
106 self.mask.iter().filter(|&&kept| kept).count()
107 }
108
109 /// The zero-based indices of the selected features, in ascending column order.
110 #[must_use]
111 pub fn selected_indices(&self) -> Vec<usize> {
112 self.mask
113 .iter()
114 .enumerate()
115 .filter_map(|(j, &kept)| kept.then_some(j))
116 .collect()
117 }
118}
119
120/// Validates a feature matrix is non-empty, rectangular, and finite, returning the
121/// `(n_samples, n_features)` shape.
122///
123/// Shared front-door check so every selector rejects the same degenerate inputs
124/// before any arithmetic runs.
125///
126/// # Arguments
127///
128/// * `x` — the feature matrix, one inner slice per sample.
129///
130/// # Returns
131///
132/// `(n_samples, n_features)` on success.
133///
134/// # Errors
135///
136/// Returns [`FeatureSelectionError::EmptyInput`],
137/// [`FeatureSelectionError::NoFeatures`], [`FeatureSelectionError::RaggedRows`],
138/// or [`FeatureSelectionError::NonFinite`].
139fn validate_matrix(x: &[Vec<f64>]) -> Result<(usize, usize), FeatureSelectionError> {
140 let n_samples = x.len();
141 if n_samples == 0 {
142 return Err(FeatureSelectionError::EmptyInput);
143 }
144 let n_features = x.first().map_or(0, Vec::len);
145 if n_features == 0 {
146 return Err(FeatureSelectionError::NoFeatures);
147 }
148 if x.iter().any(|row| row.len() != n_features) {
149 return Err(FeatureSelectionError::RaggedRows);
150 }
151 if x.iter().any(|row| row.iter().any(|v| !v.is_finite())) {
152 return Err(FeatureSelectionError::NonFinite);
153 }
154 Ok((n_samples, n_features))
155}
156
157/// Extracts column `j` of a validated feature matrix as an owned vector.
158///
159/// The matrix is row-major (one inner slice per sample), so this gathers the
160/// `j`-th entry of every row into the feature's observation vector. `j` must be a
161/// valid column index (the caller iterates `0..n_features`).
162///
163/// # Arguments
164///
165/// * `x` — the validated, rectangular feature matrix.
166/// * `j` — the zero-based column index to extract.
167///
168/// # Returns
169///
170/// The values of feature `j` across all samples, in sample order.
171fn column(x: &[Vec<f64>], j: usize) -> Vec<f64> {
172 x.iter()
173 .map(|row| row.get(j).copied().unwrap_or(0.0))
174 .collect()
175}
176
177/// Selects features whose population variance exceeds `threshold`.
178///
179/// Computes each feature's population variance (`ddof = 0`, divisor `n`) and keeps
180/// the feature when `variance > threshold`, exactly as
181/// `sklearn.feature_selection.VarianceThreshold` does (it drops features whose
182/// variance is `≤ threshold`). With `threshold = 0.0` this removes only the
183/// zero-variance (constant) features.
184///
185/// # Arguments
186///
187/// * `x` — the feature matrix, one inner `Vec<f64>` per sample; must be non-empty,
188/// rectangular, and finite.
189/// * `threshold` — the variance below or equal to which a feature is dropped; must
190/// be finite (`0.0` keeps every non-constant feature).
191///
192/// # Returns
193///
194/// A [`Selection`] whose `scores` are the per-feature population variances and
195/// whose `mask` flags `variance > threshold`.
196///
197/// # Errors
198///
199/// Returns [`FeatureSelectionError::EmptyInput`],
200/// [`FeatureSelectionError::NoFeatures`], [`FeatureSelectionError::RaggedRows`],
201/// [`FeatureSelectionError::NonFinite`], or
202/// [`FeatureSelectionError::InvalidThreshold`].
203///
204/// # Examples
205///
206/// ```
207/// use stats_claw::algorithms::feature_selection::variance_threshold;
208///
209/// // Feature 0 is constant (variance 0); features 1 and 2 vary.
210/// let x = vec![
211/// vec![0.0, 1.0, 2.0],
212/// vec![0.0, 4.0, 3.0],
213/// vec![0.0, 7.0, 10.0],
214/// ];
215/// let sel = variance_threshold(&x, 1.0)?;
216/// // Variance of feature 0 is 0 (≤ 1, dropped); features 1, 2 exceed 1 (kept).
217/// assert_eq!(sel.mask(), &[false, true, true]);
218/// assert_eq!(sel.selected_indices(), vec![1, 2]);
219/// # Ok::<(), stats_claw::algorithms::feature_selection::FeatureSelectionError>(())
220/// ```
221pub fn variance_threshold(
222 x: &[Vec<f64>],
223 threshold: f64,
224) -> Result<Selection, FeatureSelectionError> {
225 let (_, n_features) = validate_matrix(x)?;
226 if !threshold.is_finite() {
227 return Err(FeatureSelectionError::InvalidThreshold);
228 }
229 let scores: Vec<f64> = (0..n_features)
230 .map(|j| population_variance(&column(x, j)))
231 .collect();
232 let mask: Vec<bool> = scores.iter().map(|&v| v > threshold).collect();
233 Ok(Selection { scores, mask })
234}
235
236/// Scores each feature by the ANOVA F-test across the labelled classes
237/// (`f_classif`), keeping the top-`k` by F-score.
238///
239/// For every feature column, the samples are partitioned by their integer class
240/// label and a one-way ANOVA F-statistic `F = MS_between / MS_within` is formed;
241/// this is precisely what `sklearn.feature_selection.f_classif` computes (a
242/// per-feature one-way ANOVA). The companion p-values are the F upper-tail
243/// probabilities and are returned by [`anova_f_pvalues`]. The selection mask keeps
244/// the `k` features with the largest F-scores (ties broken by lower column index),
245/// mirroring `SelectKBest(f_classif, k)`.
246///
247/// # Arguments
248///
249/// * `x` — the feature matrix, one inner `Vec<f64>` per sample; must be non-empty,
250/// rectangular, and finite.
251/// * `labels` — the integer class label of each sample; its length must equal the
252/// number of rows, and at least two distinct labels must be present.
253/// * `k` — how many top-scoring features to select. `0` selects none; a `k` at or
254/// above the feature count selects all.
255///
256/// # Returns
257///
258/// A [`Selection`] whose `scores` are the per-feature ANOVA F-statistics (in
259/// column order) and whose `mask` flags the top-`k`.
260///
261/// # Errors
262///
263/// Returns [`FeatureSelectionError::EmptyInput`],
264/// [`FeatureSelectionError::NoFeatures`], [`FeatureSelectionError::RaggedRows`],
265/// [`FeatureSelectionError::NonFinite`],
266/// [`FeatureSelectionError::LabelLengthMismatch`],
267/// [`FeatureSelectionError::TooFewClasses`], or
268/// [`FeatureSelectionError::DegenerateClasses`] (a class too small or with zero
269/// within-class variation, so the F-statistic is undefined).
270///
271/// # Examples
272///
273/// ```
274/// use stats_claw::algorithms::feature_selection::anova_f_select;
275///
276/// // Two classes; feature 1 separates them cleanly, feature 0 does not.
277/// let x = vec![
278/// vec![1.0, 0.0],
279/// vec![1.1, 0.1],
280/// vec![0.9, 9.0],
281/// vec![1.0, 9.1],
282/// ];
283/// let labels = [0usize, 0, 1, 1];
284/// let sel = anova_f_select(&x, &labels, 1)?;
285/// // The cleanly-separating feature 1 has the larger F-score and is selected.
286/// assert_eq!(sel.selected_indices(), vec![1]);
287/// # Ok::<(), stats_claw::algorithms::feature_selection::FeatureSelectionError>(())
288/// ```
289pub fn anova_f_select(
290 x: &[Vec<f64>],
291 labels: &[usize],
292 k: usize,
293) -> Result<Selection, FeatureSelectionError> {
294 let scores = anova_f_scores(x, labels)?;
295 let mask = top_k_mask(&scores, k);
296 Ok(Selection { scores, mask })
297}
298
299/// Computes the per-feature ANOVA F-statistics (`f_classif`), in column order.
300///
301/// The scoring half of [`anova_f_select`] without a selection rule: useful when a
302/// caller wants the raw F-scores to rank or threshold itself.
303///
304/// # Arguments
305///
306/// * `x` — the feature matrix, one inner `Vec<f64>` per sample.
307/// * `labels` — the integer class label of each sample.
308///
309/// # Returns
310///
311/// The per-feature ANOVA F-statistics in column order.
312///
313/// # Errors
314///
315/// The same conditions as [`anova_f_select`].
316///
317/// # Examples
318///
319/// ```
320/// use stats_claw::algorithms::feature_selection::anova_f_scores;
321///
322/// let x = vec![vec![0.0], vec![0.1], vec![9.0], vec![9.1]];
323/// let labels = [0usize, 0, 1, 1];
324/// let f = anova_f_scores(&x, &labels)?;
325/// assert_eq!(f.len(), 1);
326/// assert!(f[0] > 0.0, "F was {}", f[0]);
327/// # Ok::<(), stats_claw::algorithms::feature_selection::FeatureSelectionError>(())
328/// ```
329pub fn anova_f_scores(x: &[Vec<f64>], labels: &[usize]) -> Result<Vec<f64>, FeatureSelectionError> {
330 let (scores, _) = anova_f_scores_and_pvalues(x, labels)?;
331 Ok(scores)
332}
333
334/// Computes the per-feature ANOVA F-test p-values (`f_classif`), in column order.
335///
336/// The upper-tail probabilities companion to [`anova_f_scores`]: `p_j` is the
337/// probability that an `F(k − 1, N − k)` variate exceeds feature `j`'s observed
338/// F-statistic, reproducing the p-values `sklearn.feature_selection.f_classif`
339/// returns.
340///
341/// # Arguments
342///
343/// * `x` — the feature matrix, one inner `Vec<f64>` per sample.
344/// * `labels` — the integer class label of each sample.
345///
346/// # Returns
347///
348/// The per-feature p-values in column order, each in `[0, 1]`.
349///
350/// # Errors
351///
352/// The same conditions as [`anova_f_select`].
353///
354/// # Examples
355///
356/// ```
357/// use stats_claw::algorithms::feature_selection::anova_f_pvalues;
358///
359/// let x = vec![vec![0.0], vec![0.1], vec![9.0], vec![9.1]];
360/// let labels = [0usize, 0, 1, 1];
361/// let p = anova_f_pvalues(&x, &labels)?;
362/// assert_eq!(p.len(), 1);
363/// assert!((0.0..=1.0).contains(&p[0]), "p was {}", p[0]);
364/// # Ok::<(), stats_claw::algorithms::feature_selection::FeatureSelectionError>(())
365/// ```
366pub fn anova_f_pvalues(
367 x: &[Vec<f64>],
368 labels: &[usize],
369) -> Result<Vec<f64>, FeatureSelectionError> {
370 let (_, pvalues) = anova_f_scores_and_pvalues(x, labels)?;
371 Ok(pvalues)
372}
373
374/// Computes both the per-feature ANOVA F-statistics and their p-values in one pass.
375///
376/// The shared engine behind [`anova_f_scores`] and [`anova_f_pvalues`]: it
377/// validates the matrix and labels, groups the samples by class once, and for each
378/// feature runs the framework's one-way ANOVA (which supplies both the F-statistic
379/// and the F upper-tail p-value), so the two public accessors never re-do the work
380/// or diverge.
381///
382/// # Arguments
383///
384/// * `x` — the feature matrix, one inner `Vec<f64>` per sample.
385/// * `labels` — the integer class label of each sample.
386///
387/// # Returns
388///
389/// `(scores, pvalues)`, each a per-feature vector in column order.
390///
391/// # Errors
392///
393/// The same conditions as [`anova_f_select`].
394fn anova_f_scores_and_pvalues(
395 x: &[Vec<f64>],
396 labels: &[usize],
397) -> Result<(Vec<f64>, Vec<f64>), FeatureSelectionError> {
398 let (n_samples, n_features) = validate_matrix(x)?;
399 if labels.len() != n_samples {
400 return Err(FeatureSelectionError::LabelLengthMismatch);
401 }
402 let class_order = distinct_in_order(labels);
403 if class_order.len() < 2 {
404 return Err(FeatureSelectionError::TooFewClasses);
405 }
406
407 let mut scores = Vec::with_capacity(n_features);
408 let mut pvalues = Vec::with_capacity(n_features);
409 for j in 0..n_features {
410 let col = column(x, j);
411 // Partition this feature's values by class, in first-seen class order.
412 let groups: Vec<Vec<f64>> = class_order
413 .iter()
414 .map(|&c| {
415 labels
416 .iter()
417 .zip(&col)
418 .filter_map(|(&label, &v)| (label == c).then_some(v))
419 .collect()
420 })
421 .collect();
422 let views: Vec<&[f64]> = groups.iter().map(Vec::as_slice).collect();
423 let result = one_way_anova(&views).map_err(|_| FeatureSelectionError::DegenerateClasses)?;
424 scores.push(result.statistic);
425 pvalues.push(result.p_value);
426 }
427 Ok((scores, pvalues))
428}
429
430/// Returns the distinct values of `labels` in first-seen order.
431///
432/// Class order is deterministic (first appearance) so the grouped F-test is
433/// reproducible; the F-statistic is invariant to class ordering, so this only
434/// fixes a stable iteration order.
435///
436/// # Arguments
437///
438/// * `labels` — the per-sample class labels.
439///
440/// # Returns
441///
442/// Each distinct label once, in the order it first appears.
443fn distinct_in_order(labels: &[usize]) -> Vec<usize> {
444 let mut seen: Vec<usize> = Vec::new();
445 for &label in labels {
446 if !seen.contains(&label) {
447 seen.push(label);
448 }
449 }
450 seen
451}
452
453/// Builds a top-`k` selection mask from per-feature `scores`.
454///
455/// Flags the `k` features with the largest scores, breaking ties toward the lower
456/// column index (a stable, deterministic rule matching `SelectKBest`'s behaviour
457/// of keeping the earlier feature on ties). A `k` of `0` selects none; a `k` at or
458/// above the feature count selects all.
459///
460/// # Arguments
461///
462/// * `scores` — the per-feature scores, in column order.
463/// * `k` — how many top features to flag.
464///
465/// # Returns
466///
467/// A boolean mask aligned to `scores`, with exactly `min(k, len)` entries `true`.
468fn top_k_mask(scores: &[f64], k: usize) -> Vec<bool> {
469 let n = scores.len();
470 let keep = k.min(n);
471 // Order indices by descending score, ties by ascending index (stable rule).
472 let mut order: Vec<usize> = (0..n).collect();
473 order.sort_by(|&a, &b| {
474 let sa = scores.get(a).copied().unwrap_or(f64::NEG_INFINITY);
475 let sb = scores.get(b).copied().unwrap_or(f64::NEG_INFINITY);
476 sb.partial_cmp(&sa)
477 .unwrap_or(std::cmp::Ordering::Equal)
478 .then(a.cmp(&b))
479 });
480 let mut mask = vec![false; n];
481 for &idx in order.iter().take(keep) {
482 if let Some(flag) = mask.get_mut(idx) {
483 *flag = true;
484 }
485 }
486 mask
487}
488
489#[cfg(test)]
490#[path = "tests.rs"]
491mod tests;