Skip to main content

ferrolearn_preprocess/
stat_selectors.rs

1//! Statistical-test-based feature selectors.
2//!
3//! Three selectors that choose features based on p-values obtained from a
4//! statistical test (e.g., ANOVA F-test, chi-squared test):
5//!
6//! - [`SelectFpr`] — **False Positive Rate**: selects every feature whose
7//!   p-value is below `alpha`.
8//! - [`SelectFdr`] — **False Discovery Rate**: applies the Benjamini-Hochberg
9//!   procedure to control the expected proportion of false positives.
10//! - [`SelectFwe`] — **Family-Wise Error**: applies the Bonferroni correction
11//!   (`alpha / n_features`) to control the probability of any false positive.
12//!
13//! All three take a pre-computed vector of p-values (one per feature) at fit
14//! time, allowing integration with any upstream scoring function.
15
16use ferrolearn_core::error::FerroError;
17use ferrolearn_core::traits::{Fit, Transform};
18use ndarray::{Array1, Array2};
19use num_traits::Float;
20
21// ---------------------------------------------------------------------------
22// Shared helper
23// ---------------------------------------------------------------------------
24
25/// Build a new `Array2<F>` containing only the columns listed in `indices`.
26fn select_columns<F: Float>(x: &Array2<F>, indices: &[usize]) -> Array2<F> {
27    let nrows = x.nrows();
28    let ncols = indices.len();
29    if ncols == 0 {
30        return Array2::zeros((nrows, 0));
31    }
32    let mut out = Array2::zeros((nrows, ncols));
33    for (new_j, &old_j) in indices.iter().enumerate() {
34        for i in 0..nrows {
35            out[[i, new_j]] = x[[i, old_j]];
36        }
37    }
38    out
39}
40
41/// Validate common inputs for all three selectors.
42fn validate_inputs(n_features: usize, alpha: f64) -> Result<(), FerroError> {
43    if n_features == 0 {
44        return Err(FerroError::InvalidParameter {
45            name: "p_values".into(),
46            reason: "p-value vector must not be empty".into(),
47        });
48    }
49    if alpha <= 0.0 || alpha > 1.0 {
50        return Err(FerroError::InvalidParameter {
51            name: "alpha".into(),
52            reason: format!("alpha must be in (0, 1], got {alpha}"),
53        });
54    }
55    Ok(())
56}
57
58// ===========================================================================
59// SelectFpr — False Positive Rate
60// ===========================================================================
61
62/// Select features with p-values below `alpha`.
63///
64/// A feature is selected if its p-value is strictly less than `alpha`.
65/// This controls the per-feature false positive rate but does not adjust
66/// for multiple comparisons.
67///
68/// # Examples
69///
70/// ```
71/// use ferrolearn_preprocess::stat_selectors::SelectFpr;
72/// use ferrolearn_core::traits::{Fit, Transform};
73/// use ndarray::array;
74///
75/// let sel = SelectFpr::<f64>::new(0.05);
76/// let p_values = array![0.01, 0.5, 0.03, 0.9];
77/// let fitted = sel.fit(&p_values, &()).unwrap();
78/// // Features 0 (p=0.01) and 2 (p=0.03) are below alpha=0.05
79/// assert_eq!(fitted.selected_indices(), &[0, 2]);
80/// ```
81#[must_use]
82#[derive(Debug, Clone)]
83pub struct SelectFpr<F> {
84    /// Significance threshold.
85    alpha: f64,
86    _marker: std::marker::PhantomData<F>,
87}
88
89impl<F: Float + Send + Sync + 'static> SelectFpr<F> {
90    /// Create a new `SelectFpr` with the given significance level.
91    pub fn new(alpha: f64) -> Self {
92        Self {
93            alpha,
94            _marker: std::marker::PhantomData,
95        }
96    }
97
98    /// Return the significance level.
99    #[must_use]
100    pub fn alpha(&self) -> f64 {
101        self.alpha
102    }
103}
104
105/// A fitted `SelectFpr` holding the selected indices.
106#[derive(Debug, Clone)]
107pub struct FittedSelectFpr<F> {
108    /// Number of features seen during fitting.
109    n_features_in: usize,
110    /// P-values supplied during fitting.
111    p_values: Array1<F>,
112    /// Indices of selected columns (sorted).
113    selected_indices: Vec<usize>,
114}
115
116impl<F: Float + Send + Sync + 'static> FittedSelectFpr<F> {
117    /// Return the p-values.
118    #[must_use]
119    pub fn p_values(&self) -> &Array1<F> {
120        &self.p_values
121    }
122
123    /// Return the indices of the selected columns.
124    #[must_use]
125    pub fn selected_indices(&self) -> &[usize] {
126        &self.selected_indices
127    }
128
129    /// Return the number of selected features.
130    #[must_use]
131    pub fn n_features_selected(&self) -> usize {
132        self.selected_indices.len()
133    }
134}
135
136impl<F: Float + Send + Sync + 'static> Fit<Array1<F>, ()> for SelectFpr<F> {
137    type Fitted = FittedSelectFpr<F>;
138    type Error = FerroError;
139
140    /// Fit by selecting features whose p-value is below `alpha`.
141    ///
142    /// # Errors
143    ///
144    /// - [`FerroError::InvalidParameter`] if p-values are empty or alpha is
145    ///   not in `(0, 1]`.
146    fn fit(&self, x: &Array1<F>, _y: &()) -> Result<FittedSelectFpr<F>, FerroError> {
147        let n = x.len();
148        validate_inputs(n, self.alpha)?;
149
150        let alpha_f = F::from(self.alpha).unwrap_or_else(F::zero);
151        let selected_indices: Vec<usize> = x
152            .iter()
153            .enumerate()
154            .filter(|&(_, &p)| p < alpha_f)
155            .map(|(j, _)| j)
156            .collect();
157
158        Ok(FittedSelectFpr {
159            n_features_in: n,
160            p_values: x.clone(),
161            selected_indices,
162        })
163    }
164}
165
166impl<F: Float + Send + Sync + 'static> Transform<Array2<F>> for FittedSelectFpr<F> {
167    type Output = Array2<F>;
168    type Error = FerroError;
169
170    /// Return a matrix containing only the selected columns.
171    ///
172    /// # Errors
173    ///
174    /// Returns [`FerroError::ShapeMismatch`] if column count does not match.
175    fn transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
176        if x.ncols() != self.n_features_in {
177            return Err(FerroError::ShapeMismatch {
178                expected: vec![x.nrows(), self.n_features_in],
179                actual: vec![x.nrows(), x.ncols()],
180                context: "FittedSelectFpr::transform".into(),
181            });
182        }
183        Ok(select_columns(x, &self.selected_indices))
184    }
185}
186
187// ===========================================================================
188// SelectFdr — False Discovery Rate (Benjamini-Hochberg)
189// ===========================================================================
190
191/// Select features controlling the false discovery rate via the
192/// Benjamini-Hochberg procedure.
193///
194/// Features are sorted by p-value. Feature *i* (0-indexed, sorted ascending)
195/// is selected if `p_value[i] <= alpha * (i+1) / n_features`. All features
196/// with rank at or below the highest qualifying rank are selected.
197///
198/// # Examples
199///
200/// ```
201/// use ferrolearn_preprocess::stat_selectors::SelectFdr;
202/// use ferrolearn_core::traits::{Fit, Transform};
203/// use ndarray::array;
204///
205/// let sel = SelectFdr::<f64>::new(0.05);
206/// let p_values = array![0.01, 0.5, 0.03, 0.9];
207/// let fitted = sel.fit(&p_values, &()).unwrap();
208/// assert!(fitted.selected_indices().contains(&0));
209/// ```
210#[must_use]
211#[derive(Debug, Clone)]
212pub struct SelectFdr<F> {
213    /// Target false discovery rate.
214    alpha: f64,
215    _marker: std::marker::PhantomData<F>,
216}
217
218impl<F: Float + Send + Sync + 'static> SelectFdr<F> {
219    /// Create a new `SelectFdr` with the given FDR level.
220    pub fn new(alpha: f64) -> Self {
221        Self {
222            alpha,
223            _marker: std::marker::PhantomData,
224        }
225    }
226
227    /// Return the FDR level.
228    #[must_use]
229    pub fn alpha(&self) -> f64 {
230        self.alpha
231    }
232}
233
234/// A fitted `SelectFdr` holding the selected indices.
235#[derive(Debug, Clone)]
236pub struct FittedSelectFdr<F> {
237    /// Number of features seen during fitting.
238    n_features_in: usize,
239    /// P-values supplied during fitting.
240    p_values: Array1<F>,
241    /// Indices of selected columns (sorted in original order).
242    selected_indices: Vec<usize>,
243}
244
245impl<F: Float + Send + Sync + 'static> FittedSelectFdr<F> {
246    /// Return the p-values.
247    #[must_use]
248    pub fn p_values(&self) -> &Array1<F> {
249        &self.p_values
250    }
251
252    /// Return the indices of the selected columns.
253    #[must_use]
254    pub fn selected_indices(&self) -> &[usize] {
255        &self.selected_indices
256    }
257
258    /// Return the number of selected features.
259    #[must_use]
260    pub fn n_features_selected(&self) -> usize {
261        self.selected_indices.len()
262    }
263}
264
265impl<F: Float + Send + Sync + 'static> Fit<Array1<F>, ()> for SelectFdr<F> {
266    type Fitted = FittedSelectFdr<F>;
267    type Error = FerroError;
268
269    /// Fit using the Benjamini-Hochberg procedure.
270    ///
271    /// # Errors
272    ///
273    /// - [`FerroError::InvalidParameter`] if p-values are empty or alpha is
274    ///   not in `(0, 1]`.
275    fn fit(&self, x: &Array1<F>, _y: &()) -> Result<FittedSelectFdr<F>, FerroError> {
276        let n = x.len();
277        validate_inputs(n, self.alpha)?;
278
279        let alpha_f = F::from(self.alpha).unwrap_or_else(F::zero);
280        let n_f = F::from(n).unwrap_or_else(F::one);
281
282        // Sort features by p-value (ascending), keeping original indices
283        let mut ranked: Vec<(usize, F)> = x.iter().copied().enumerate().collect();
284        ranked.sort_by(|a, b| {
285            a.1.partial_cmp(&b.1)
286                .unwrap_or(std::cmp::Ordering::Equal)
287        });
288
289        // Find the largest rank k where p_(k) <= alpha * (k+1) / n
290        let mut max_qualifying_rank: Option<usize> = None;
291        for (rank, &(_, p_val)) in ranked.iter().enumerate() {
292            let bh_threshold = alpha_f * F::from(rank + 1).unwrap_or_else(F::one) / n_f;
293            if p_val <= bh_threshold {
294                max_qualifying_rank = Some(rank);
295            }
296        }
297
298        // Select all features at or below the max qualifying rank
299        let mut selected_indices: Vec<usize> = match max_qualifying_rank {
300            Some(max_rank) => ranked[..=max_rank]
301                .iter()
302                .map(|&(idx, _)| idx)
303                .collect(),
304            None => Vec::new(),
305        };
306        selected_indices.sort_unstable();
307
308        Ok(FittedSelectFdr {
309            n_features_in: n,
310            p_values: x.clone(),
311            selected_indices,
312        })
313    }
314}
315
316impl<F: Float + Send + Sync + 'static> Transform<Array2<F>> for FittedSelectFdr<F> {
317    type Output = Array2<F>;
318    type Error = FerroError;
319
320    /// Return a matrix containing only the selected columns.
321    ///
322    /// # Errors
323    ///
324    /// Returns [`FerroError::ShapeMismatch`] if column count does not match.
325    fn transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
326        if x.ncols() != self.n_features_in {
327            return Err(FerroError::ShapeMismatch {
328                expected: vec![x.nrows(), self.n_features_in],
329                actual: vec![x.nrows(), x.ncols()],
330                context: "FittedSelectFdr::transform".into(),
331            });
332        }
333        Ok(select_columns(x, &self.selected_indices))
334    }
335}
336
337// ===========================================================================
338// SelectFwe — Family-Wise Error (Bonferroni)
339// ===========================================================================
340
341/// Select features controlling the family-wise error rate via the
342/// Bonferroni correction.
343///
344/// A feature is selected if its p-value is strictly less than
345/// `alpha / n_features`.
346///
347/// # Examples
348///
349/// ```
350/// use ferrolearn_preprocess::stat_selectors::SelectFwe;
351/// use ferrolearn_core::traits::{Fit, Transform};
352/// use ndarray::array;
353///
354/// let sel = SelectFwe::<f64>::new(0.05);
355/// let p_values = array![0.001, 0.5, 0.03, 0.9];
356/// let fitted = sel.fit(&p_values, &()).unwrap();
357/// // Bonferroni threshold = 0.05/4 = 0.0125; only feature 0 qualifies
358/// assert_eq!(fitted.selected_indices(), &[0]);
359/// ```
360#[must_use]
361#[derive(Debug, Clone)]
362pub struct SelectFwe<F> {
363    /// Significance level before Bonferroni correction.
364    alpha: f64,
365    _marker: std::marker::PhantomData<F>,
366}
367
368impl<F: Float + Send + Sync + 'static> SelectFwe<F> {
369    /// Create a new `SelectFwe` with the given significance level.
370    pub fn new(alpha: f64) -> Self {
371        Self {
372            alpha,
373            _marker: std::marker::PhantomData,
374        }
375    }
376
377    /// Return the significance level.
378    #[must_use]
379    pub fn alpha(&self) -> f64 {
380        self.alpha
381    }
382}
383
384/// A fitted `SelectFwe` holding the selected indices.
385#[derive(Debug, Clone)]
386pub struct FittedSelectFwe<F> {
387    /// Number of features seen during fitting.
388    n_features_in: usize,
389    /// P-values supplied during fitting.
390    p_values: Array1<F>,
391    /// Indices of selected columns (sorted).
392    selected_indices: Vec<usize>,
393}
394
395impl<F: Float + Send + Sync + 'static> FittedSelectFwe<F> {
396    /// Return the p-values.
397    #[must_use]
398    pub fn p_values(&self) -> &Array1<F> {
399        &self.p_values
400    }
401
402    /// Return the indices of the selected columns.
403    #[must_use]
404    pub fn selected_indices(&self) -> &[usize] {
405        &self.selected_indices
406    }
407
408    /// Return the number of selected features.
409    #[must_use]
410    pub fn n_features_selected(&self) -> usize {
411        self.selected_indices.len()
412    }
413}
414
415impl<F: Float + Send + Sync + 'static> Fit<Array1<F>, ()> for SelectFwe<F> {
416    type Fitted = FittedSelectFwe<F>;
417    type Error = FerroError;
418
419    /// Fit using the Bonferroni correction: `p < alpha / n_features`.
420    ///
421    /// # Errors
422    ///
423    /// - [`FerroError::InvalidParameter`] if p-values are empty or alpha is
424    ///   not in `(0, 1]`.
425    fn fit(&self, x: &Array1<F>, _y: &()) -> Result<FittedSelectFwe<F>, FerroError> {
426        let n = x.len();
427        validate_inputs(n, self.alpha)?;
428
429        let adjusted_alpha = self.alpha / n as f64;
430        let adjusted_alpha_f = F::from(adjusted_alpha).unwrap_or_else(F::zero);
431
432        let selected_indices: Vec<usize> = x
433            .iter()
434            .enumerate()
435            .filter(|&(_, &p)| p < adjusted_alpha_f)
436            .map(|(j, _)| j)
437            .collect();
438
439        Ok(FittedSelectFwe {
440            n_features_in: n,
441            p_values: x.clone(),
442            selected_indices,
443        })
444    }
445}
446
447impl<F: Float + Send + Sync + 'static> Transform<Array2<F>> for FittedSelectFwe<F> {
448    type Output = Array2<F>;
449    type Error = FerroError;
450
451    /// Return a matrix containing only the selected columns.
452    ///
453    /// # Errors
454    ///
455    /// Returns [`FerroError::ShapeMismatch`] if column count does not match.
456    fn transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
457        if x.ncols() != self.n_features_in {
458            return Err(FerroError::ShapeMismatch {
459                expected: vec![x.nrows(), self.n_features_in],
460                actual: vec![x.nrows(), x.ncols()],
461                context: "FittedSelectFwe::transform".into(),
462            });
463        }
464        Ok(select_columns(x, &self.selected_indices))
465    }
466}
467
468// ---------------------------------------------------------------------------
469// Tests
470// ---------------------------------------------------------------------------
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use ndarray::array;
476
477    // ========================================================================
478    // SelectFpr tests
479    // ========================================================================
480
481    #[test]
482    fn test_fpr_selects_below_alpha() {
483        let sel = SelectFpr::<f64>::new(0.05);
484        let p = array![0.01, 0.5, 0.03, 0.9];
485        let fitted = sel.fit(&p, &()).unwrap();
486        assert_eq!(fitted.selected_indices(), &[0, 2]);
487    }
488
489    #[test]
490    fn test_fpr_none_below_alpha() {
491        let sel = SelectFpr::<f64>::new(0.001);
492        let p = array![0.01, 0.5, 0.03];
493        let fitted = sel.fit(&p, &()).unwrap();
494        assert_eq!(fitted.n_features_selected(), 0);
495    }
496
497    #[test]
498    fn test_fpr_all_below_alpha() {
499        let sel = SelectFpr::<f64>::new(0.99);
500        let p = array![0.01, 0.5, 0.03];
501        let fitted = sel.fit(&p, &()).unwrap();
502        assert_eq!(fitted.n_features_selected(), 3);
503    }
504
505    #[test]
506    fn test_fpr_transform() {
507        let sel = SelectFpr::<f64>::new(0.05);
508        let p = array![0.01, 0.5, 0.03];
509        let fitted = sel.fit(&p, &()).unwrap();
510        let x = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
511        let out = fitted.transform(&x).unwrap();
512        assert_eq!(out.ncols(), 2); // features 0 and 2
513        assert_eq!(out[[0, 0]], 1.0);
514        assert_eq!(out[[0, 1]], 3.0);
515    }
516
517    #[test]
518    fn test_fpr_empty_error() {
519        let sel = SelectFpr::<f64>::new(0.05);
520        let p: Array1<f64> = Array1::zeros(0);
521        assert!(sel.fit(&p, &()).is_err());
522    }
523
524    #[test]
525    fn test_fpr_invalid_alpha() {
526        let sel = SelectFpr::<f64>::new(0.0);
527        let p = array![0.01];
528        assert!(sel.fit(&p, &()).is_err());
529
530        let sel2 = SelectFpr::<f64>::new(1.5);
531        assert!(sel2.fit(&p, &()).is_err());
532    }
533
534    #[test]
535    fn test_fpr_shape_mismatch() {
536        let sel = SelectFpr::<f64>::new(0.05);
537        let p = array![0.01, 0.5];
538        let fitted = sel.fit(&p, &()).unwrap();
539        let x_bad = array![[1.0, 2.0, 3.0]];
540        assert!(fitted.transform(&x_bad).is_err());
541    }
542
543    #[test]
544    fn test_fpr_accessor() {
545        let sel = SelectFpr::<f64>::new(0.05);
546        assert_eq!(sel.alpha(), 0.05);
547    }
548
549    #[test]
550    fn test_fpr_p_values_accessor() {
551        let sel = SelectFpr::<f64>::new(0.05);
552        let p = array![0.01, 0.5];
553        let fitted = sel.fit(&p, &()).unwrap();
554        assert_eq!(fitted.p_values().len(), 2);
555    }
556
557    // ========================================================================
558    // SelectFdr tests (Benjamini-Hochberg)
559    // ========================================================================
560
561    #[test]
562    fn test_fdr_basic() {
563        let sel = SelectFdr::<f64>::new(0.05);
564        // Sorted p-values: 0.01 (feat 0), 0.03 (feat 2), 0.5 (feat 1), 0.9 (feat 3)
565        // BH thresholds: 0.05*1/4=0.0125, 0.05*2/4=0.025, 0.05*3/4=0.0375, 0.05*4/4=0.05
566        // 0.01 <= 0.0125 ✓ (rank 0)
567        // 0.03 <= 0.025  ✗ → but check all: max qualifying rank = 0
568        let p = array![0.01, 0.5, 0.03, 0.9];
569        let fitted = sel.fit(&p, &()).unwrap();
570        assert!(fitted.selected_indices().contains(&0));
571    }
572
573    #[test]
574    fn test_fdr_multiple_pass() {
575        let sel = SelectFdr::<f64>::new(0.10);
576        // Sorted: 0.005 (rank 0), 0.02 (rank 1), 0.04 (rank 2), 0.5 (rank 3)
577        // BH: 0.1*1/4=0.025, 0.1*2/4=0.05, 0.1*3/4=0.075, 0.1*4/4=0.1
578        // 0.005 <= 0.025 ✓
579        // 0.02  <= 0.05  ✓
580        // 0.04  <= 0.075 ✓ → max rank = 2 → select rank 0,1,2
581        let p = array![0.02, 0.5, 0.005, 0.04];
582        let fitted = sel.fit(&p, &()).unwrap();
583        assert_eq!(fitted.n_features_selected(), 3);
584        assert!(fitted.selected_indices().contains(&0)); // 0.02
585        assert!(fitted.selected_indices().contains(&2)); // 0.005
586        assert!(fitted.selected_indices().contains(&3)); // 0.04
587    }
588
589    #[test]
590    fn test_fdr_none_selected() {
591        let sel = SelectFdr::<f64>::new(0.001);
592        let p = array![0.01, 0.5, 0.03];
593        let fitted = sel.fit(&p, &()).unwrap();
594        assert_eq!(fitted.n_features_selected(), 0);
595    }
596
597    #[test]
598    fn test_fdr_transform() {
599        let sel = SelectFdr::<f64>::new(0.10);
600        let p = array![0.001, 0.5, 0.9];
601        let fitted = sel.fit(&p, &()).unwrap();
602        let x = array![[1.0, 2.0, 3.0]];
603        let out = fitted.transform(&x).unwrap();
604        // Feature 0 (p=0.001) selected: BH threshold = 0.1*1/3 ≈ 0.033
605        assert!(out.ncols() >= 1);
606    }
607
608    #[test]
609    fn test_fdr_empty_error() {
610        let sel = SelectFdr::<f64>::new(0.05);
611        let p: Array1<f64> = Array1::zeros(0);
612        assert!(sel.fit(&p, &()).is_err());
613    }
614
615    #[test]
616    fn test_fdr_invalid_alpha() {
617        let sel = SelectFdr::<f64>::new(0.0);
618        let p = array![0.01];
619        assert!(sel.fit(&p, &()).is_err());
620    }
621
622    #[test]
623    fn test_fdr_shape_mismatch() {
624        let sel = SelectFdr::<f64>::new(0.05);
625        let p = array![0.01, 0.5];
626        let fitted = sel.fit(&p, &()).unwrap();
627        let x_bad = array![[1.0, 2.0, 3.0]];
628        assert!(fitted.transform(&x_bad).is_err());
629    }
630
631    #[test]
632    fn test_fdr_accessor() {
633        let sel = SelectFdr::<f64>::new(0.05);
634        assert_eq!(sel.alpha(), 0.05);
635    }
636
637    // ========================================================================
638    // SelectFwe tests (Bonferroni)
639    // ========================================================================
640
641    #[test]
642    fn test_fwe_basic() {
643        let sel = SelectFwe::<f64>::new(0.05);
644        // Bonferroni threshold = 0.05/4 = 0.0125
645        let p = array![0.001, 0.5, 0.03, 0.9];
646        let fitted = sel.fit(&p, &()).unwrap();
647        assert_eq!(fitted.selected_indices(), &[0]);
648    }
649
650    #[test]
651    fn test_fwe_two_features() {
652        let sel = SelectFwe::<f64>::new(0.10);
653        // Bonferroni: 0.1/3 ≈ 0.0333
654        let p = array![0.01, 0.02, 0.5];
655        let fitted = sel.fit(&p, &()).unwrap();
656        assert_eq!(fitted.selected_indices(), &[0, 1]);
657    }
658
659    #[test]
660    fn test_fwe_none_selected() {
661        let sel = SelectFwe::<f64>::new(0.01);
662        // Bonferroni: 0.01/3 ≈ 0.00333
663        let p = array![0.005, 0.5, 0.03];
664        let fitted = sel.fit(&p, &()).unwrap();
665        assert_eq!(fitted.n_features_selected(), 0);
666    }
667
668    #[test]
669    fn test_fwe_transform() {
670        let sel = SelectFwe::<f64>::new(0.05);
671        let p = array![0.001, 0.5, 0.9];
672        let fitted = sel.fit(&p, &()).unwrap();
673        let x = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
674        let out = fitted.transform(&x).unwrap();
675        assert_eq!(out.ncols(), 1);
676        assert_eq!(out[[0, 0]], 1.0);
677    }
678
679    #[test]
680    fn test_fwe_empty_error() {
681        let sel = SelectFwe::<f64>::new(0.05);
682        let p: Array1<f64> = Array1::zeros(0);
683        assert!(sel.fit(&p, &()).is_err());
684    }
685
686    #[test]
687    fn test_fwe_invalid_alpha() {
688        let sel = SelectFwe::<f64>::new(0.0);
689        let p = array![0.01];
690        assert!(sel.fit(&p, &()).is_err());
691    }
692
693    #[test]
694    fn test_fwe_shape_mismatch() {
695        let sel = SelectFwe::<f64>::new(0.05);
696        let p = array![0.01, 0.5];
697        let fitted = sel.fit(&p, &()).unwrap();
698        let x_bad = array![[1.0, 2.0, 3.0]];
699        assert!(fitted.transform(&x_bad).is_err());
700    }
701
702    #[test]
703    fn test_fwe_accessor() {
704        let sel = SelectFwe::<f64>::new(0.05);
705        assert_eq!(sel.alpha(), 0.05);
706    }
707
708    #[test]
709    fn test_fwe_single_feature() {
710        let sel = SelectFwe::<f64>::new(0.05);
711        // Bonferroni: 0.05/1 = 0.05; p=0.01 < 0.05 ✓
712        let p = array![0.01];
713        let fitted = sel.fit(&p, &()).unwrap();
714        assert_eq!(fitted.selected_indices(), &[0]);
715    }
716
717    #[test]
718    fn test_fwe_f32() {
719        let sel = SelectFwe::<f32>::new(0.05);
720        let p: Array1<f32> = array![0.001f32, 0.5];
721        let fitted = sel.fit(&p, &()).unwrap();
722        // Bonferroni: 0.05/2 = 0.025; p=0.001 < 0.025 ✓
723        assert_eq!(fitted.selected_indices(), &[0]);
724    }
725}