sklears_preprocessing/temporal/
seasonal.rs

1//! Seasonal decomposition for time series analysis
2//!
3//! This module provides seasonal decomposition capabilities to extract
4//! trend, seasonal, and residual components from time series data.
5
6use scirs2_core::ndarray::{s, Array1, Array2};
7use sklears_core::{
8    error::{Result, SklearsError},
9    traits::{Fit, Trained, Transform, Untrained},
10    types::Float,
11};
12use std::marker::PhantomData;
13
14/// Configuration for SeasonalDecomposer
15#[derive(Debug, Clone)]
16pub struct SeasonalDecomposerConfig {
17    /// Season length (e.g., 12 for monthly data, 7 for daily data)
18    pub season_length: usize,
19    /// Decomposition method (additive or multiplicative)
20    pub method: DecompositionMethod,
21    /// Whether to include trend component as feature
22    pub include_trend: bool,
23    /// Whether to include seasonal component as feature
24    pub include_seasonal: bool,
25    /// Whether to include residual component as feature
26    pub include_residual: bool,
27    /// Whether to include seasonal strength (seasonality measure)
28    pub include_seasonal_strength: bool,
29    /// Whether to include trend strength (trend measure)
30    pub include_trend_strength: bool,
31}
32
33/// Decomposition method for seasonal decomposition
34#[derive(Debug, Clone, Copy)]
35pub enum DecompositionMethod {
36    /// Additive decomposition: y = trend + seasonal + residual
37    Additive,
38    /// Multiplicative decomposition: y = trend * seasonal * residual
39    Multiplicative,
40}
41
42impl Default for SeasonalDecomposerConfig {
43    fn default() -> Self {
44        Self {
45            season_length: 12, // Default to monthly seasonality
46            method: DecompositionMethod::Additive,
47            include_trend: true,
48            include_seasonal: true,
49            include_residual: false,
50            include_seasonal_strength: true,
51            include_trend_strength: true,
52        }
53    }
54}
55
56/// SeasonalDecomposer for extracting seasonal patterns and trend from time series
57#[derive(Debug, Clone)]
58pub struct SeasonalDecomposer<S> {
59    config: SeasonalDecomposerConfig,
60    n_features_out_: Option<usize>,
61    _phantom: PhantomData<S>,
62}
63
64impl SeasonalDecomposer<Untrained> {
65    /// Create a new SeasonalDecomposer
66    pub fn new() -> Self {
67        Self {
68            config: SeasonalDecomposerConfig::default(),
69            n_features_out_: None,
70            _phantom: PhantomData,
71        }
72    }
73
74    /// Set the season length
75    pub fn season_length(mut self, season_length: usize) -> Self {
76        self.config.season_length = season_length;
77        self
78    }
79
80    /// Set the decomposition method
81    pub fn method(mut self, method: DecompositionMethod) -> Self {
82        self.config.method = method;
83        self
84    }
85
86    /// Set whether to include trend component
87    pub fn include_trend(mut self, include_trend: bool) -> Self {
88        self.config.include_trend = include_trend;
89        self
90    }
91
92    /// Set whether to include seasonal component
93    pub fn include_seasonal(mut self, include_seasonal: bool) -> Self {
94        self.config.include_seasonal = include_seasonal;
95        self
96    }
97
98    /// Set whether to include residual component
99    pub fn include_residual(mut self, include_residual: bool) -> Self {
100        self.config.include_residual = include_residual;
101        self
102    }
103
104    /// Set whether to include seasonal strength
105    pub fn include_seasonal_strength(mut self, include_seasonal_strength: bool) -> Self {
106        self.config.include_seasonal_strength = include_seasonal_strength;
107        self
108    }
109
110    /// Set whether to include trend strength
111    pub fn include_trend_strength(mut self, include_trend_strength: bool) -> Self {
112        self.config.include_trend_strength = include_trend_strength;
113        self
114    }
115
116    /// Calculate number of output features
117    fn calculate_n_features_out(&self) -> usize {
118        let mut count = 0;
119        if self.config.include_trend {
120            count += 1;
121        }
122        if self.config.include_seasonal {
123            count += 1;
124        }
125        if self.config.include_residual {
126            count += 1;
127        }
128        if self.config.include_seasonal_strength {
129            count += 1;
130        }
131        if self.config.include_trend_strength {
132            count += 1;
133        }
134        count
135    }
136}
137
138impl SeasonalDecomposer<Trained> {
139    /// Get the number of output features
140    pub fn n_features_out(&self) -> usize {
141        self.n_features_out_.expect("Decomposer should be fitted")
142    }
143
144    /// Perform classical seasonal decomposition
145    fn decompose(
146        &self,
147        data: &Array1<Float>,
148    ) -> Result<(Array1<Float>, Array1<Float>, Array1<Float>)> {
149        let n = data.len();
150        let season_length = self.config.season_length;
151
152        if n < 2 * season_length {
153            return Err(SklearsError::InvalidInput(format!(
154                "Data length {} is too short for season length {}",
155                n, season_length
156            )));
157        }
158
159        // Calculate seasonal component using centered moving averages
160        let mut trend = Array1::<Float>::zeros(n);
161        let mut seasonal = Array1::<Float>::zeros(n);
162
163        // Calculate trend using centered moving average
164        let half_season = season_length / 2;
165        for i in half_season..(n - half_season) {
166            let start = i - half_season;
167            let end = i + half_season + 1;
168            let sum: Float = data.slice(s![start..end]).sum();
169            trend[i] = sum / season_length as Float;
170        }
171
172        // Fill trend endpoints using linear extrapolation
173        if half_season > 0 {
174            let start_slope = (trend[half_season + 1] - trend[half_season]) / 1.0;
175            let end_slope = (trend[n - half_season - 1] - trend[n - half_season - 2]) / 1.0;
176
177            for i in 0..half_season {
178                trend[i] = trend[half_season] - start_slope * (half_season - i) as Float;
179            }
180            for i in (n - half_season)..n {
181                trend[i] =
182                    trend[n - half_season - 1] + end_slope * (i - (n - half_season - 1)) as Float;
183            }
184        }
185
186        // Calculate seasonal component
187        let mut seasonal_averages = vec![0.0; season_length];
188        let mut seasonal_counts = vec![0; season_length];
189
190        match self.config.method {
191            DecompositionMethod::Additive => {
192                // Calculate detrended series
193                let detrended: Array1<Float> = data - &trend;
194
195                // Average seasonal effects
196                for (i, &value) in detrended.iter().enumerate() {
197                    let season_idx = i % season_length;
198                    seasonal_averages[season_idx] += value;
199                    seasonal_counts[season_idx] += 1;
200                }
201
202                for i in 0..season_length {
203                    if seasonal_counts[i] > 0 {
204                        seasonal_averages[i] /= seasonal_counts[i] as Float;
205                    }
206                }
207
208                // Center seasonal component (sum should be 0)
209                let seasonal_mean =
210                    seasonal_averages.iter().sum::<Float>() / season_length as Float;
211                for avg in &mut seasonal_averages {
212                    *avg -= seasonal_mean;
213                }
214
215                // Fill seasonal array
216                for i in 0..n {
217                    seasonal[i] = seasonal_averages[i % season_length];
218                }
219            }
220            DecompositionMethod::Multiplicative => {
221                // Calculate detrended series
222                let detrended: Array1<Float> =
223                    data.mapv(|x| x.max(1e-10)) / trend.mapv(|x| x.max(1e-10));
224
225                // Average seasonal effects
226                for (i, &value) in detrended.iter().enumerate() {
227                    let season_idx = i % season_length;
228                    seasonal_averages[season_idx] += value;
229                    seasonal_counts[season_idx] += 1;
230                }
231
232                for i in 0..season_length {
233                    if seasonal_counts[i] > 0 {
234                        seasonal_averages[i] /= seasonal_counts[i] as Float;
235                    }
236                }
237
238                // Center seasonal component (geometric mean should be 1)
239                let geometric_mean = seasonal_averages.iter().map(|&x| x.ln()).sum::<Float>()
240                    / season_length as Float;
241                let geometric_mean = geometric_mean.exp();
242
243                for avg in &mut seasonal_averages {
244                    *avg /= geometric_mean;
245                }
246
247                // Fill seasonal array
248                for i in 0..n {
249                    seasonal[i] = seasonal_averages[i % season_length];
250                }
251            }
252        }
253
254        // Calculate residual component
255        let residual = match self.config.method {
256            DecompositionMethod::Additive => data - &trend - &seasonal,
257            DecompositionMethod::Multiplicative => {
258                let trend_seasonal = &trend * &seasonal;
259                data.mapv(|x| x.max(1e-10)) / trend_seasonal.mapv(|x| x.max(1e-10))
260            }
261        };
262
263        Ok((trend, seasonal, residual))
264    }
265
266    /// Calculate seasonal strength (F-statistic based measure)
267    fn calculate_seasonal_strength(&self, data: &Array1<Float>, seasonal: &Array1<Float>) -> Float {
268        let seasonal_var = seasonal.var(0.0);
269        let residual = match self.config.method {
270            DecompositionMethod::Additive => data - seasonal,
271            DecompositionMethod::Multiplicative => {
272                data.mapv(|x| x.max(1e-10)) / seasonal.mapv(|x| x.max(1e-10))
273            }
274        };
275        let residual_var = residual.var(0.0);
276
277        if residual_var > 1e-10 {
278            (seasonal_var / residual_var).clamp(0.0, 1.0)
279        } else {
280            1.0
281        }
282    }
283
284    /// Calculate trend strength (coefficient of variation of trend)
285    fn calculate_trend_strength(&self, trend: &Array1<Float>) -> Float {
286        let trend_mean = trend.mean().unwrap_or(0.0);
287        let trend_std = trend.std(0.0);
288
289        if trend_mean.abs() > 1e-10 {
290            (trend_std / trend_mean.abs()).min(1.0)
291        } else {
292            0.0
293        }
294    }
295}
296
297impl Default for SeasonalDecomposer<Untrained> {
298    fn default() -> Self {
299        Self::new()
300    }
301}
302
303impl Fit<Array1<Float>, ()> for SeasonalDecomposer<Untrained> {
304    type Fitted = SeasonalDecomposer<Trained>;
305
306    fn fit(self, x: &Array1<Float>, _y: &()) -> Result<Self::Fitted> {
307        let n_features_out = self.calculate_n_features_out();
308
309        if n_features_out == 0 {
310            return Err(SklearsError::InvalidParameter {
311                name: "feature_selection".to_string(),
312                reason: "No features selected for extraction".to_string(),
313            });
314        }
315
316        if x.len() < 2 * self.config.season_length {
317            return Err(SklearsError::InvalidInput(format!(
318                "Data length {} is too short for season length {}",
319                x.len(),
320                self.config.season_length
321            )));
322        }
323
324        Ok(SeasonalDecomposer {
325            config: self.config,
326            n_features_out_: Some(n_features_out),
327            _phantom: PhantomData,
328        })
329    }
330}
331
332impl Transform<Array1<Float>, Array2<Float>> for SeasonalDecomposer<Trained> {
333    fn transform(&self, x: &Array1<Float>) -> Result<Array2<Float>> {
334        let n_samples = x.len();
335        let n_features_out = self.n_features_out();
336
337        let (trend, seasonal, residual) = self.decompose(x)?;
338
339        let mut result = Array2::<Float>::zeros((n_samples, n_features_out));
340        let mut feature_idx = 0;
341
342        if self.config.include_trend {
343            for (i, &value) in trend.iter().enumerate() {
344                result[[i, feature_idx]] = value;
345            }
346            feature_idx += 1;
347        }
348
349        if self.config.include_seasonal {
350            for (i, &value) in seasonal.iter().enumerate() {
351                result[[i, feature_idx]] = value;
352            }
353            feature_idx += 1;
354        }
355
356        if self.config.include_residual {
357            for (i, &value) in residual.iter().enumerate() {
358                result[[i, feature_idx]] = value;
359            }
360            feature_idx += 1;
361        }
362
363        if self.config.include_seasonal_strength {
364            let seasonal_strength = self.calculate_seasonal_strength(x, &seasonal);
365            for i in 0..n_samples {
366                result[[i, feature_idx]] = seasonal_strength;
367            }
368            feature_idx += 1;
369        }
370
371        if self.config.include_trend_strength {
372            let trend_strength = self.calculate_trend_strength(&trend);
373            for i in 0..n_samples {
374                result[[i, feature_idx]] = trend_strength;
375            }
376        }
377
378        Ok(result)
379    }
380}