Skip to main content

sklears_feature_extraction/
pipelines.rs

1//! Feature extraction pipelines
2//!
3//! This module provides implementations for composing multiple feature extractors
4//! into unified pipelines for complex feature engineering workflows.
5
6use crate::feature_traits::{FeatureExtractor, FeatureUnion};
7use scirs2_core::ndarray::{Array2, Axis};
8use sklears_core::{error::Result as SklResult, prelude::SklearsError, types::Float};
9
10// ============================================================================
11// Sequential Pipeline
12// ============================================================================
13
14/// Sequential feature extraction pipeline
15///
16/// Executes extractors in sequence, passing the output of one extractor
17/// as input to the next (when compatible).
18///
19/// # Examples
20///
21/// ```
22/// use sklears_feature_extraction::pipelines::SequentialPipeline;
23/// use sklears_feature_extraction::text::{EmotionDetector, SentimentAnalyzer};
24///
25/// // Note: This example demonstrates the API structure
26/// // Actual chaining would require compatible input/output types
27/// ```
28#[derive(Debug)]
29pub struct SequentialPipeline<T> {
30    stages: Vec<T>,
31    stage_names: Vec<String>,
32}
33
34impl<T> SequentialPipeline<T> {
35    /// Create a new sequential pipeline
36    pub fn new() -> Self {
37        Self {
38            stages: Vec::new(),
39            stage_names: Vec::new(),
40        }
41    }
42
43    /// Add a stage to the pipeline
44    pub fn add_stage(mut self, stage: T, name: String) -> Self {
45        self.stages.push(stage);
46        self.stage_names.push(name);
47        self
48    }
49
50    /// Get the number of stages
51    pub fn len(&self) -> usize {
52        self.stages.len()
53    }
54
55    /// Check if pipeline is empty
56    pub fn is_empty(&self) -> bool {
57        self.stages.is_empty()
58    }
59}
60
61impl<T> Default for SequentialPipeline<T> {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67// ============================================================================
68// Parallel Feature Union
69// ============================================================================
70
71/// Parallel feature union for combining multiple extractors
72///
73/// Extracts features using multiple extractors in parallel and concatenates
74/// the results horizontally into a single feature matrix.
75///
76/// # Examples
77///
78/// ```text
79/// use sklears_feature_extraction::pipelines::ParallelFeatureUnion;
80/// use sklears_feature_extraction::text::{EmotionDetector, SentimentAnalyzer};
81/// use sklears_feature_extraction::feature_traits::FeatureUnion;
82///
83/// let union = ParallelFeatureUnion::new()
84///     .add_extractor(EmotionDetector::new(), "emotion".to_string())
85///     .add_extractor(SentimentAnalyzer::new(), "sentiment".to_string());
86///
87/// let documents = vec![
88///     "I am happy!".to_string(),
89///     "This is sad.".to_string(),
90/// ];
91///
92/// let combined_features = union.extract_union(&documents).expect("valid documents produce union features");
93/// assert_eq!(combined_features.nrows(), 2);
94/// ```
95#[derive(Debug, Clone)]
96pub struct ParallelFeatureUnion<E> {
97    extractors: Vec<E>,
98    extractor_names: Vec<String>,
99}
100
101impl<E> ParallelFeatureUnion<E> {
102    /// Create a new parallel feature union
103    pub fn new() -> Self {
104        Self {
105            extractors: Vec::new(),
106            extractor_names: Vec::new(),
107        }
108    }
109
110    /// Add an extractor to the union
111    pub fn add_extractor(mut self, extractor: E, name: String) -> Self {
112        self.extractors.push(extractor);
113        self.extractor_names.push(name);
114        self
115    }
116
117    /// Get the number of extractors
118    pub fn len(&self) -> usize {
119        self.extractors.len()
120    }
121
122    /// Check if union is empty
123    pub fn is_empty(&self) -> bool {
124        self.extractors.is_empty()
125    }
126
127    /// Get extractor names
128    pub fn extractor_names(&self) -> &[String] {
129        &self.extractor_names
130    }
131}
132
133impl<E> Default for ParallelFeatureUnion<E> {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl<E> FeatureUnion for ParallelFeatureUnion<E>
140where
141    E: FeatureExtractor<Input = String, Output = Array2<Float>>,
142{
143    type Input = String;
144    type Output = Array2<Float>;
145
146    fn extract_union(&self, input: &[Self::Input]) -> SklResult<Self::Output> {
147        if self.extractors.is_empty() {
148            return Err(SklearsError::InvalidInput(
149                "Feature union has no extractors".to_string(),
150            ));
151        }
152
153        if input.is_empty() {
154            return Err(SklearsError::InvalidInput("Empty input data".to_string()));
155        }
156
157        // Extract features from all extractors
158        let mut feature_matrices: Vec<Array2<Float>> = Vec::new();
159
160        for extractor in &self.extractors {
161            let features = extractor.extract_features(input)?;
162            feature_matrices.push(features);
163        }
164
165        // Validate all matrices have the same number of rows
166        let n_samples = feature_matrices[0].nrows();
167        for (i, matrix) in feature_matrices.iter().enumerate() {
168            if matrix.nrows() != n_samples {
169                return Err(SklearsError::InvalidInput(format!(
170                    "Extractor {} produced {} samples, expected {}",
171                    i,
172                    matrix.nrows(),
173                    n_samples
174                )));
175            }
176        }
177
178        // Concatenate horizontally
179        let mut combined = feature_matrices[0].clone();
180        for matrix in &feature_matrices[1..] {
181            combined =
182                scirs2_core::ndarray::concatenate(Axis(1), &[combined.view(), matrix.view()])
183                    .map_err(|e| {
184                        SklearsError::InvalidInput(format!("Concatenation failed: {}", e))
185                    })?;
186        }
187
188        Ok(combined)
189    }
190
191    fn n_extractors(&self) -> usize {
192        self.extractors.len()
193    }
194
195    fn feature_splits(&self) -> Vec<usize> {
196        let mut splits = vec![0];
197        let mut cumsum = 0;
198
199        for extractor in &self.extractors {
200            if let Some(n_features) = extractor.n_features() {
201                cumsum += n_features;
202                splits.push(cumsum);
203            }
204        }
205
206        splits
207    }
208}
209
210// ============================================================================
211// Weighted Feature Union
212// ============================================================================
213
214/// Weighted feature union that applies weights to each extractor's features
215///
216/// Similar to ParallelFeatureUnion but applies importance weights to features
217/// from each extractor before concatenation.
218#[derive(Debug, Clone)]
219pub struct WeightedFeatureUnion<E> {
220    extractors: Vec<E>,
221    weights: Vec<Float>,
222    extractor_names: Vec<String>,
223}
224
225impl<E> WeightedFeatureUnion<E> {
226    /// Create a new weighted feature union
227    pub fn new() -> Self {
228        Self {
229            extractors: Vec::new(),
230            weights: Vec::new(),
231            extractor_names: Vec::new(),
232        }
233    }
234
235    /// Add an extractor with a weight
236    pub fn add_weighted_extractor(mut self, extractor: E, weight: Float, name: String) -> Self {
237        self.extractors.push(extractor);
238        self.weights.push(weight);
239        self.extractor_names.push(name);
240        self
241    }
242
243    /// Get the number of extractors
244    pub fn len(&self) -> usize {
245        self.extractors.len()
246    }
247
248    /// Check if union is empty
249    pub fn is_empty(&self) -> bool {
250        self.extractors.is_empty()
251    }
252
253    /// Get weights
254    pub fn weights(&self) -> &[Float] {
255        &self.weights
256    }
257}
258
259impl<E> Default for WeightedFeatureUnion<E> {
260    fn default() -> Self {
261        Self::new()
262    }
263}
264
265impl<E> FeatureUnion for WeightedFeatureUnion<E>
266where
267    E: FeatureExtractor<Input = String, Output = Array2<Float>>,
268{
269    type Input = String;
270    type Output = Array2<Float>;
271
272    fn extract_union(&self, input: &[Self::Input]) -> SklResult<Self::Output> {
273        if self.extractors.is_empty() {
274            return Err(SklearsError::InvalidInput(
275                "Feature union has no extractors".to_string(),
276            ));
277        }
278
279        if input.is_empty() {
280            return Err(SklearsError::InvalidInput("Empty input data".to_string()));
281        }
282
283        // Extract features from all extractors and apply weights
284        let mut feature_matrices: Vec<Array2<Float>> = Vec::new();
285
286        for (extractor, weight) in self.extractors.iter().zip(&self.weights) {
287            let mut features = extractor.extract_features(input)?;
288            // Apply weight
289            features *= *weight;
290            feature_matrices.push(features);
291        }
292
293        // Validate all matrices have the same number of rows
294        let n_samples = feature_matrices[0].nrows();
295        for (i, matrix) in feature_matrices.iter().enumerate() {
296            if matrix.nrows() != n_samples {
297                return Err(SklearsError::InvalidInput(format!(
298                    "Extractor {} produced {} samples, expected {}",
299                    i,
300                    matrix.nrows(),
301                    n_samples
302                )));
303            }
304        }
305
306        // Concatenate horizontally
307        let mut combined = feature_matrices[0].clone();
308        for matrix in &feature_matrices[1..] {
309            combined =
310                scirs2_core::ndarray::concatenate(Axis(1), &[combined.view(), matrix.view()])
311                    .map_err(|e| {
312                        SklearsError::InvalidInput(format!("Concatenation failed: {}", e))
313                    })?;
314        }
315
316        Ok(combined)
317    }
318
319    fn n_extractors(&self) -> usize {
320        self.extractors.len()
321    }
322
323    fn feature_splits(&self) -> Vec<usize> {
324        let mut splits = vec![0];
325        let mut cumsum = 0;
326
327        for extractor in &self.extractors {
328            if let Some(n_features) = extractor.n_features() {
329                cumsum += n_features;
330                splits.push(cumsum);
331            }
332        }
333
334        splits
335    }
336}
337
338// ============================================================================
339// Feature Selector
340// ============================================================================
341
342/// Feature selector that selects a subset of features
343#[derive(Debug, Clone)]
344pub struct IndexFeatureSelector {
345    selected_indices: Vec<usize>,
346}
347
348impl IndexFeatureSelector {
349    /// Create a new feature selector
350    pub fn new(selected_indices: Vec<usize>) -> Self {
351        Self { selected_indices }
352    }
353
354    /// Select features from a feature matrix
355    pub fn select_features(&self, features: &Array2<Float>) -> SklResult<Array2<Float>> {
356        if self.selected_indices.is_empty() {
357            return Err(SklearsError::InvalidInput(
358                "No features selected".to_string(),
359            ));
360        }
361
362        let n_features = features.ncols();
363        for &idx in &self.selected_indices {
364            if idx >= n_features {
365                return Err(SklearsError::InvalidInput(format!(
366                    "Feature index {} out of range (max: {})",
367                    idx,
368                    n_features - 1
369                )));
370            }
371        }
372
373        // Select columns
374        let selected_cols: Vec<_> = self
375            .selected_indices
376            .iter()
377            .map(|&idx| features.column(idx).to_owned())
378            .collect();
379
380        let n_samples = features.nrows();
381        let n_selected = selected_cols.len();
382        let mut result = Array2::zeros((n_samples, n_selected));
383
384        for (i, col) in selected_cols.iter().enumerate() {
385            result.column_mut(i).assign(col);
386        }
387
388        Ok(result)
389    }
390
391    /// Get selected feature indices
392    pub fn selected_indices(&self) -> &[usize] {
393        &self.selected_indices
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::text::EmotionDetector;
401
402    #[test]
403    fn test_parallel_feature_union_basic() {
404        // Test with multiple instances of the same extractor type
405        let detector1 = EmotionDetector::new();
406        let detector2 = EmotionDetector::new().min_confidence(0.2);
407
408        let union = ParallelFeatureUnion::new()
409            .add_extractor(detector1, "emotion1".to_string())
410            .add_extractor(detector2, "emotion2".to_string());
411
412        let documents = vec!["I am happy!".to_string(), "This is sad.".to_string()];
413
414        let features = union
415            .extract_union(&documents)
416            .expect("operation should succeed");
417
418        assert_eq!(features.nrows(), 2);
419        assert_eq!(features.ncols(), 14 + 14); // emotion (14) + emotion (14)
420    }
421
422    #[test]
423    fn test_parallel_feature_union_feature_splits() {
424        let union = ParallelFeatureUnion::new()
425            .add_extractor(EmotionDetector::new(), "emotion1".to_string())
426            .add_extractor(EmotionDetector::new(), "emotion2".to_string());
427
428        let splits = union.feature_splits();
429        assert_eq!(splits, vec![0, 14, 28]); // 0, 14 (emotion), 28 (emotion + emotion)
430    }
431
432    #[test]
433    fn test_weighted_feature_union() {
434        let union = WeightedFeatureUnion::new()
435            .add_weighted_extractor(EmotionDetector::new(), 2.0, "emotion1".to_string())
436            .add_weighted_extractor(EmotionDetector::new(), 0.5, "emotion2".to_string());
437
438        let documents = vec!["I am happy!".to_string()];
439
440        let features = union
441            .extract_union(&documents)
442            .expect("operation should succeed");
443
444        assert_eq!(features.nrows(), 1);
445        assert_eq!(features.ncols(), 14 + 14);
446    }
447
448    #[test]
449    fn test_feature_selector() {
450        let features = Array2::from_shape_vec(
451            (2, 5),
452            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0],
453        )
454        .expect("operation should succeed");
455
456        let selector = IndexFeatureSelector::new(vec![0, 2, 4]);
457        let selected = selector
458            .select_features(&features)
459            .expect("operation should succeed");
460
461        assert_eq!(selected.nrows(), 2);
462        assert_eq!(selected.ncols(), 3);
463        assert_eq!(selected[[0, 0]], 1.0);
464        assert_eq!(selected[[0, 1]], 3.0);
465        assert_eq!(selected[[0, 2]], 5.0);
466    }
467
468    #[test]
469    fn test_feature_selector_out_of_range() {
470        let features = Array2::zeros((2, 5));
471        let selector = IndexFeatureSelector::new(vec![10]);
472
473        assert!(selector.select_features(&features).is_err());
474    }
475}