Skip to main content

sklears_feature_extraction/
temporal_features.rs

1//! Temporal Feature Extraction
2//!
3//! This module provides comprehensive time series feature extraction capabilities:
4//!
5//! - `TemporalFeatureExtractor`: Lag features, rolling statistics, trend, and seasonality
6//! - `SlidingWindowFeatures`: Statistical measures from sliding windows
7//! - `FourierTransformFeatures`: Frequency domain features using DFT
8//!
9//! These extractors enable rich temporal pattern recognition for time series analysis.
10
11use crate::{Float, SklResult, SklearsError};
12use scirs2_core::ndarray::{s, Array1, Array2, ArrayView1};
13
14/// Temporal Feature Extractor
15///
16/// Extracts temporal features from time series data including lag features,
17/// rolling statistics, trend, and seasonality components.
18///
19/// # Parameters
20///
21/// * `n_lags` - Number of lag features to generate
22/// * `window_sizes` - Window sizes for rolling statistics
23/// * `extract_trend` - Whether to extract trend features
24/// * `extract_seasonality` - Whether to extract seasonality features
25/// * `seasonal_periods` - Periods for seasonality extraction
26///
27/// # Examples
28///
29/// ```
30/// use sklears_feature_extraction::temporal_features::TemporalFeatureExtractor;
31/// use scirs2_core::ndarray::{array, Array1, ArrayView1, ArrayView2};
32///
33/// let ts_data = array![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
34/// let extractor = TemporalFeatureExtractor::new()
35///     .n_lags(3)
36///     .window_sizes(vec![3, 5])
37///     .extract_trend(true);
38///
39/// let features = extractor.extract_features(&ts_data.view()).expect("valid time series produces features");
40/// ```
41#[derive(Debug, Clone)]
42pub struct TemporalFeatureExtractor {
43    n_lags: usize,
44    window_sizes: Vec<usize>,
45    extract_trend: bool,
46    extract_seasonality: bool,
47    seasonal_periods: Vec<usize>,
48    fill_method: String, // "forward", "backward", "mean", "zero"
49}
50
51impl TemporalFeatureExtractor {
52    /// Create a new TemporalFeatureExtractor
53    pub fn new() -> Self {
54        Self {
55            n_lags: 5,
56            window_sizes: vec![3, 7, 14],
57            extract_trend: true,
58            extract_seasonality: true,
59            seasonal_periods: vec![7, 12, 24],
60            fill_method: "forward".to_string(),
61        }
62    }
63
64    /// Set the number of lag features
65    pub fn n_lags(mut self, n_lags: usize) -> Self {
66        self.n_lags = n_lags;
67        self
68    }
69
70    /// Set the window sizes for rolling statistics
71    pub fn window_sizes(mut self, window_sizes: Vec<usize>) -> Self {
72        self.window_sizes = window_sizes;
73        self
74    }
75
76    /// Set whether to extract trend features
77    pub fn extract_trend(mut self, extract_trend: bool) -> Self {
78        self.extract_trend = extract_trend;
79        self
80    }
81
82    /// Set whether to extract seasonality features
83    pub fn extract_seasonality(mut self, extract_seasonality: bool) -> Self {
84        self.extract_seasonality = extract_seasonality;
85        self
86    }
87
88    /// Set the seasonal periods
89    pub fn seasonal_periods(mut self, seasonal_periods: Vec<usize>) -> Self {
90        self.seasonal_periods = seasonal_periods;
91        self
92    }
93
94    /// Set the fill method for missing values
95    pub fn fill_method(mut self, fill_method: String) -> Self {
96        self.fill_method = fill_method;
97        self
98    }
99
100    /// Extract temporal features from a time series
101    pub fn extract_features(&self, ts: &ArrayView1<Float>) -> SklResult<Array1<Float>> {
102        let n = ts.len();
103        if n == 0 {
104            return Err(SklearsError::InvalidInput(
105                "Time series cannot be empty".to_string(),
106            ));
107        }
108
109        let mut features = Vec::new();
110
111        // Lag features
112        for lag in 1..=self.n_lags {
113            if lag >= n {
114                features.push(0.0); // Use zero for unavailable lags
115            } else {
116                features.push(ts[n - 1 - lag]);
117            }
118        }
119
120        // Rolling statistics
121        for &window_size in &self.window_sizes {
122            if window_size <= n {
123                let start_idx = n.saturating_sub(window_size);
124                let window = ts.slice(s![start_idx..]);
125
126                // Rolling mean
127                let mean = window.sum() / window_size as Float;
128                features.push(mean);
129
130                // Rolling std
131                let std = (window.mapv(|x| (x - mean).powi(2)).sum() / window_size as Float).sqrt();
132                features.push(std);
133
134                // Rolling min/max
135                features.push(
136                    *window
137                        .iter()
138                        .min_by(|a, b| a.partial_cmp(b).expect("operation should succeed"))
139                        .expect("operation should succeed"),
140                );
141                features.push(
142                    *window
143                        .iter()
144                        .max_by(|a, b| a.partial_cmp(b).expect("operation should succeed"))
145                        .expect("operation should succeed"),
146                );
147            } else {
148                // Use entire series if window is larger than series
149                let mean = ts.sum() / n as Float;
150                let std = (ts.mapv(|x| (x - mean).powi(2)).sum() / n as Float).sqrt();
151                let min_val = *ts
152                    .iter()
153                    .min_by(|a, b| a.partial_cmp(b).expect("operation should succeed"))
154                    .expect("operation should succeed");
155                let max_val = *ts
156                    .iter()
157                    .max_by(|a, b| a.partial_cmp(b).expect("operation should succeed"))
158                    .expect("operation should succeed");
159
160                features.extend_from_slice(&[mean, std, min_val, max_val]);
161            }
162        }
163
164        // Trend features
165        if self.extract_trend {
166            let trend_features = self.extract_trend_features(ts)?;
167            features.extend_from_slice(&trend_features);
168        }
169
170        // Seasonality features
171        if self.extract_seasonality {
172            for &period in &self.seasonal_periods {
173                let seasonality_features = self.extract_seasonality_features(ts, period)?;
174                features.extend_from_slice(&seasonality_features);
175            }
176        }
177
178        Ok(Array1::from_vec(features))
179    }
180
181    fn extract_trend_features(&self, ts: &ArrayView1<Float>) -> SklResult<Vec<Float>> {
182        let n = ts.len();
183        if n < 2 {
184            return Ok(vec![0.0, 0.0]); // slope, intercept
185        }
186
187        // Simple linear trend estimation using least squares
188        let x: Vec<Float> = (0..n).map(|i| i as Float).collect();
189        let y: Vec<Float> = ts.to_vec();
190
191        let x_mean = x.iter().sum::<Float>() / n as Float;
192        let y_mean = y.iter().sum::<Float>() / n as Float;
193
194        let numerator: Float = x
195            .iter()
196            .zip(y.iter())
197            .map(|(&xi, &yi)| (xi - x_mean) * (yi - y_mean))
198            .sum();
199        let denominator: Float = x.iter().map(|&xi| (xi - x_mean).powi(2)).sum();
200
201        let slope = if denominator.abs() < 1e-10 {
202            0.0
203        } else {
204            numerator / denominator
205        };
206        let intercept = y_mean - slope * x_mean;
207
208        Ok(vec![slope, intercept])
209    }
210
211    fn extract_seasonality_features(
212        &self,
213        ts: &ArrayView1<Float>,
214        period: usize,
215    ) -> SklResult<Vec<Float>> {
216        let n = ts.len();
217        if n < period {
218            return Ok(vec![0.0, 0.0]); // mean, amplitude
219        }
220
221        // Simple seasonality extraction: compute mean and amplitude within each period
222        let mut seasonal_means = vec![0.0; period];
223        let mut seasonal_counts = vec![0; period];
224
225        for (i, &val) in ts.iter().enumerate() {
226            let season_idx = i % period;
227            seasonal_means[season_idx] += val;
228            seasonal_counts[season_idx] += 1;
229        }
230
231        // Normalize means
232        for i in 0..period {
233            if seasonal_counts[i] > 0 {
234                seasonal_means[i] /= seasonal_counts[i] as Float;
235            }
236        }
237
238        let overall_mean = seasonal_means.iter().sum::<Float>() / period as Float;
239        let amplitude = seasonal_means
240            .iter()
241            .map(|&x| (x - overall_mean).abs())
242            .fold(0.0_f64, |acc, x| acc.max(x));
243
244        Ok(vec![overall_mean, amplitude])
245    }
246}
247
248impl Default for TemporalFeatureExtractor {
249    fn default() -> Self {
250        Self::new()
251    }
252}
253
254/// Sliding Window Feature Extractor
255///
256/// Extracts features from sliding windows of time series data including
257/// various statistical measures, moving averages, and volatility metrics.
258///
259/// # Parameters
260///
261/// * `window_size` - Size of the sliding window
262/// * `step_size` - Step size for window sliding
263/// * `features` - List of features to extract ("mean", "std", "min", "max", "median", "volatility", "skewness", "kurtosis")
264///
265/// # Examples
266///
267/// ```
268/// use sklears_feature_extraction::temporal_features::SlidingWindowFeatures;
269/// use scirs2_core::ndarray::{array, Array1, ArrayView1, ArrayView2};
270///
271/// let ts_data = array![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
272/// let extractor = SlidingWindowFeatures::new()
273///     .window_size(5)
274///     .step_size(1)
275///     .features(vec!["mean".to_string(), "std".to_string(), "volatility".to_string()]);
276///
277/// let features = extractor.extract_features(&ts_data.view()).expect("valid time series produces features");
278/// ```
279#[derive(Debug, Clone)]
280pub struct SlidingWindowFeatures {
281    window_size: usize,
282    step_size: usize,
283    features: Vec<String>,
284}
285
286impl SlidingWindowFeatures {
287    /// Create a new SlidingWindowFeatures extractor
288    pub fn new() -> Self {
289        Self {
290            window_size: 10,
291            step_size: 1,
292            features: vec![
293                "mean".to_string(),
294                "std".to_string(),
295                "min".to_string(),
296                "max".to_string(),
297                "volatility".to_string(),
298            ],
299        }
300    }
301
302    /// Set the window size
303    pub fn window_size(mut self, window_size: usize) -> Self {
304        self.window_size = window_size;
305        self
306    }
307
308    /// Set the step size
309    pub fn step_size(mut self, step_size: usize) -> Self {
310        self.step_size = step_size;
311        self
312    }
313
314    /// Set the features to extract
315    pub fn features(mut self, features: Vec<String>) -> Self {
316        self.features = features;
317        self
318    }
319
320    /// Extract sliding window features from a time series
321    pub fn extract_features(&self, ts: &ArrayView1<Float>) -> SklResult<Array2<Float>> {
322        let n = ts.len();
323        if n < self.window_size {
324            return Err(SklearsError::InvalidInput(format!(
325                "Time series length ({}) must be >= window_size ({})",
326                n, self.window_size
327            )));
328        }
329
330        let n_windows = (n - self.window_size) / self.step_size + 1;
331        let n_features_per_window = self.features.len();
332        let mut result = Array2::zeros((n_windows, n_features_per_window));
333
334        for (window_idx, start_idx) in (0..=n - self.window_size)
335            .step_by(self.step_size)
336            .enumerate()
337        {
338            let window = ts.slice(s![start_idx..start_idx + self.window_size]);
339
340            for (feat_idx, feature_name) in self.features.iter().enumerate() {
341                let feature_value = match feature_name.as_str() {
342                    "mean" => window.sum() / self.window_size as Float,
343                    "std" => {
344                        let mean = window.sum() / self.window_size as Float;
345                        (window.mapv(|x| (x - mean).powi(2)).sum() / self.window_size as Float)
346                            .sqrt()
347                    }
348                    "min" => *window
349                        .iter()
350                        .min_by(|a, b| a.partial_cmp(b).expect("operation should succeed"))
351                        .expect("operation should succeed"),
352                    "max" => *window
353                        .iter()
354                        .max_by(|a, b| a.partial_cmp(b).expect("operation should succeed"))
355                        .expect("operation should succeed"),
356                    "median" => {
357                        let mut sorted_window = window.to_vec();
358                        sorted_window
359                            .sort_by(|a, b| a.partial_cmp(b).expect("operation should succeed"));
360                        if self.window_size % 2 == 1 {
361                            sorted_window[self.window_size / 2]
362                        } else {
363                            (sorted_window[self.window_size / 2 - 1]
364                                + sorted_window[self.window_size / 2])
365                                / 2.0
366                        }
367                    }
368                    "volatility" if self.window_size > 1 => {
369                        let returns: Vec<Float> = window
370                            .windows(2)
371                            .into_iter()
372                            .map(|pair| (pair[1] - pair[0]) / pair[0].max(1e-10))
373                            .collect();
374                        let mean_return = returns.iter().sum::<Float>() / returns.len() as Float;
375                        (returns
376                            .iter()
377                            .map(|&r| (r - mean_return).powi(2))
378                            .sum::<Float>()
379                            / returns.len() as Float)
380                            .sqrt()
381                    }
382                    "volatility" => 0.0,
383                    "skewness" => self.compute_skewness(&window),
384                    "kurtosis" => self.compute_kurtosis(&window),
385                    _ => 0.0, // Unknown feature
386                };
387
388                result[[window_idx, feat_idx]] = feature_value;
389            }
390        }
391
392        Ok(result)
393    }
394
395    fn compute_skewness(&self, data: &ArrayView1<Float>) -> Float {
396        let n = data.len() as Float;
397        if n < 3.0 {
398            return 0.0;
399        }
400
401        let mean = data.sum() / n;
402        let variance = data.mapv(|x| (x - mean).powi(2)).sum() / n;
403        let std_dev = variance.sqrt();
404
405        if std_dev < 1e-10 {
406            return 0.0;
407        }
408
409        data.mapv(|x| ((x - mean) / std_dev).powi(3)).sum() / n
410    }
411
412    fn compute_kurtosis(&self, data: &ArrayView1<Float>) -> Float {
413        let n = data.len() as Float;
414        if n < 4.0 {
415            return 0.0;
416        }
417
418        let mean = data.sum() / n;
419        let variance = data.mapv(|x| (x - mean).powi(2)).sum() / n;
420        let std_dev = variance.sqrt();
421
422        if std_dev < 1e-10 {
423            return 0.0;
424        }
425
426        let kurtosis = data.mapv(|x| ((x - mean) / std_dev).powi(4)).sum() / n;
427        kurtosis - 3.0 // Excess kurtosis (subtract 3 for normal distribution)
428    }
429}
430
431impl Default for SlidingWindowFeatures {
432    fn default() -> Self {
433        Self::new()
434    }
435}
436
437/// Fourier Transform Feature Extractor
438///
439/// Extracts frequency domain features from time series data using Fast Fourier Transform.
440/// Provides power spectrum analysis, dominant frequencies, and spectral features.
441///
442/// # Parameters
443///
444/// * `n_frequencies` - Number of frequency components to extract
445/// * `include_power_spectrum` - Whether to include power spectrum features
446/// * `include_phase` - Whether to include phase information
447/// * `normalize` - Whether to normalize the features
448///
449/// # Examples
450///
451/// ```
452/// use sklears_feature_extraction::temporal_features::FourierTransformFeatures;
453/// use scirs2_core::ndarray::array;
454///
455/// let ts_data = array![1.0, 2.0, 1.0, -1.0, -2.0, -1.0, 1.0, 2.0];
456/// let extractor = FourierTransformFeatures::new()
457///     .n_frequencies(4)
458///     .include_power_spectrum(true)
459///     .normalize(true);
460///
461/// let features = extractor.extract_features(&ts_data.view()).expect("valid time series produces features");
462/// ```
463#[derive(Debug, Clone)]
464pub struct FourierTransformFeatures {
465    n_frequencies: usize,
466    include_power_spectrum: bool,
467    include_phase: bool,
468    normalize: bool,
469    window_function: String, // "hanning", "hamming", "blackman", "none"
470}
471
472impl FourierTransformFeatures {
473    /// Create a new FourierTransformFeatures extractor
474    pub fn new() -> Self {
475        Self {
476            n_frequencies: 10,
477            include_power_spectrum: true,
478            include_phase: false,
479            normalize: true,
480            window_function: "hanning".to_string(),
481        }
482    }
483
484    /// Set the number of frequency components to extract
485    pub fn n_frequencies(mut self, n_frequencies: usize) -> Self {
486        self.n_frequencies = n_frequencies;
487        self
488    }
489
490    /// Set whether to include power spectrum features
491    pub fn include_power_spectrum(mut self, include_power_spectrum: bool) -> Self {
492        self.include_power_spectrum = include_power_spectrum;
493        self
494    }
495
496    /// Set whether to include phase information
497    pub fn include_phase(mut self, include_phase: bool) -> Self {
498        self.include_phase = include_phase;
499        self
500    }
501
502    /// Set whether to normalize features
503    pub fn normalize(mut self, normalize: bool) -> Self {
504        self.normalize = normalize;
505        self
506    }
507
508    /// Set the window function
509    pub fn window_function(mut self, window_function: String) -> Self {
510        self.window_function = window_function;
511        self
512    }
513
514    /// Extract Fourier transform features from a time series
515    pub fn extract_features(&self, ts: &ArrayView1<Float>) -> SklResult<Array1<Float>> {
516        let n = ts.len();
517        if n == 0 {
518            return Err(SklearsError::InvalidInput(
519                "Time series cannot be empty".to_string(),
520            ));
521        }
522
523        // Apply window function
524        let windowed_ts = self.apply_window(ts)?;
525
526        // Compute DFT (simplified implementation - not optimized FFT)
527        let fft_result = self.compute_dft(&windowed_ts)?;
528
529        let mut features = Vec::new();
530
531        // Extract frequency components up to n_frequencies
532        let max_freq = (self.n_frequencies.min(n / 2)).max(1);
533
534        if self.include_power_spectrum {
535            for &(re, im) in fft_result.iter().take(max_freq) {
536                let power = re.powi(2) + im.powi(2); // |X[k]|^2
537                features.push(power);
538            }
539        }
540
541        if self.include_phase {
542            for &(re, im) in fft_result.iter().take(max_freq) {
543                let phase = im.atan2(re); // angle(X[k])
544                features.push(phase);
545            }
546        }
547
548        // Add spectral features
549        let spectral_features = self.compute_spectral_features(&fft_result, max_freq)?;
550        features.extend_from_slice(&spectral_features);
551
552        // Normalize if requested
553        if self.normalize && !features.is_empty() {
554            let max_val = features.iter().cloned().fold(0.0, Float::max);
555            if max_val > 1e-10 {
556                for feature in &mut features {
557                    *feature /= max_val;
558                }
559            }
560        }
561
562        Ok(Array1::from_vec(features))
563    }
564
565    fn apply_window(&self, ts: &ArrayView1<Float>) -> SklResult<Array1<Float>> {
566        let n = ts.len();
567        let mut windowed = Array1::zeros(n);
568
569        match self.window_function.as_str() {
570            "hanning" => {
571                for i in 0..n {
572                    let w = 0.5
573                        * (1.0
574                            - (2.0 * std::f64::consts::PI * i as Float / (n - 1) as Float).cos());
575                    windowed[i] = ts[i] * w;
576                }
577            }
578            "hamming" => {
579                for i in 0..n {
580                    let w = 0.54
581                        - 0.46 * (2.0 * std::f64::consts::PI * i as Float / (n - 1) as Float).cos();
582                    windowed[i] = ts[i] * w;
583                }
584            }
585            "blackman" => {
586                for i in 0..n {
587                    let w = 0.42
588                        - 0.5 * (2.0 * std::f64::consts::PI * i as Float / (n - 1) as Float).cos()
589                        + 0.08 * (4.0 * std::f64::consts::PI * i as Float / (n - 1) as Float).cos();
590                    windowed[i] = ts[i] * w;
591                }
592            }
593            _ => {
594                // No window applied
595                windowed.assign(ts);
596            }
597        }
598
599        Ok(windowed)
600    }
601
602    #[allow(clippy::needless_range_loop)] // k/j used in arithmetic angle computation, not just indexing
603    fn compute_dft(&self, ts: &Array1<Float>) -> SklResult<Vec<(Float, Float)>> {
604        let n = ts.len();
605        let mut result = vec![(0.0, 0.0); n];
606
607        // Simple DFT implementation (O(n^2) - for production use, implement FFT)
608        for k in 0..n {
609            let mut real_sum = 0.0;
610            let mut imag_sum = 0.0;
611
612            for j in 0..n {
613                let angle = -2.0 * std::f64::consts::PI * (k * j) as Float / n as Float;
614                real_sum += ts[j] * angle.cos();
615                imag_sum += ts[j] * angle.sin();
616            }
617
618            result[k] = (real_sum, imag_sum);
619        }
620
621        Ok(result)
622    }
623
624    fn compute_spectral_features(
625        &self,
626        fft_result: &[(Float, Float)],
627        max_freq: usize,
628    ) -> SklResult<Vec<Float>> {
629        let mut features = Vec::new();
630
631        // Spectral centroid (center of mass of spectrum)
632        let mut weighted_sum = 0.0;
633        let mut total_power = 0.0;
634
635        #[allow(clippy::needless_range_loop)]
636        // i used as frequency index in arithmetic, not just for indexing
637        for i in 0..max_freq {
638            let power = fft_result[i].0.powi(2) + fft_result[i].1.powi(2);
639            weighted_sum += i as Float * power;
640            total_power += power;
641        }
642
643        let spectral_centroid = if total_power > 1e-10 {
644            weighted_sum / total_power
645        } else {
646            0.0
647        };
648        features.push(spectral_centroid);
649
650        // Spectral bandwidth
651        let mut bandwidth_sum = 0.0;
652        #[allow(clippy::needless_range_loop)]
653        // i used as frequency index in arithmetic, not just for indexing
654        for i in 0..max_freq {
655            let power = fft_result[i].0.powi(2) + fft_result[i].1.powi(2);
656            bandwidth_sum += (i as Float - spectral_centroid).powi(2) * power;
657        }
658
659        let spectral_bandwidth = if total_power > 1e-10 {
660            (bandwidth_sum / total_power).sqrt()
661        } else {
662            0.0
663        };
664        features.push(spectral_bandwidth);
665
666        // Spectral rolloff (frequency below which 85% of energy is contained)
667        let target_energy = 0.85 * total_power;
668        let mut cumulative_energy = 0.0;
669        let mut rolloff_freq = 0.0;
670
671        #[allow(clippy::needless_range_loop)]
672        // i used as frequency index in arithmetic, not just for indexing
673        for i in 0..max_freq {
674            let power = fft_result[i].0.powi(2) + fft_result[i].1.powi(2);
675            cumulative_energy += power;
676            if cumulative_energy >= target_energy {
677                rolloff_freq = i as Float;
678                break;
679            }
680        }
681        features.push(rolloff_freq);
682
683        Ok(features)
684    }
685}
686
687impl Default for FourierTransformFeatures {
688    fn default() -> Self {
689        Self::new()
690    }
691}