Skip to main content

so_tsa/
decomposition.rs

1//! Time series decomposition methods
2//!
3//! This module provides methods for decomposing time series into
4//! trend, seasonal, and residual components.
5//!
6//! # Methods
7//!
8//! 1. **Moving Average Decomposition**: Classical decomposition using
9//!    moving averages to extract trend and seasonal components.
10//! 2. **STL (Seasonal-Trend decomposition using LOESS)**: Robust
11//!    decomposition that handles any type of seasonality.
12//! 3. **X-12-ARIMA**: Census Bureau's seasonal adjustment procedure.
13//! 4. **Hodrick-Prescott Filter**: Separates trend from cyclical components.
14
15use super::timeseries::TimeSeries;
16use ndarray::{Array1, Array2};
17use so_core::error::{Error, Result};
18use so_linalg;
19
20/// Decomposition method
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub enum DecompositionMethod {
23    /// Additive decomposition: yₜ = Tₜ + Sₜ + Rₜ
24    Additive,
25    /// Multiplicative decomposition: yₜ = Tₜ × Sₜ × Rₜ
26    Multiplicative,
27}
28
29/// Decomposition results
30#[derive(Debug, Clone)]
31pub struct DecompositionResults {
32    /// Original time series
33    pub original: TimeSeries,
34    /// Trend component
35    pub trend: TimeSeries,
36    /// Seasonal component
37    pub seasonal: TimeSeries,
38    /// Residual component
39    pub residual: TimeSeries,
40    /// Decomposition method
41    pub method: DecompositionMethod,
42    /// Seasonal period
43    pub period: usize,
44}
45
46impl DecompositionResults {
47    /// Create summary of decomposition
48    pub fn summary(&self) -> String {
49        let mut summary = String::new();
50        summary.push_str(&format!("Time Series Decomposition\n"));
51        summary.push_str(&format!("=========================\n"));
52        summary.push_str(&format!("Method: {:?}\n", self.method));
53        summary.push_str(&format!("Seasonal Period: {}\n", self.period));
54        summary.push_str(&format!("Observations: {}\n", self.original.len()));
55
56        // Variance explained by each component
57        let total_var = self.original.values().var(1.0);
58        let trend_var = self.trend.values().var(1.0);
59        let seasonal_var = self.seasonal.values().var(1.0);
60        let residual_var = self.residual.values().var(1.0);
61
62        if total_var > 0.0 {
63            summary.push_str(&format!("\nVariance Explained:\n"));
64            summary.push_str(&format!("  Trend: {:.1}%\n", 100.0 * trend_var / total_var));
65            summary.push_str(&format!(
66                "  Seasonal: {:.1}%\n",
67                100.0 * seasonal_var / total_var
68            ));
69            summary.push_str(&format!(
70                "  Residual: {:.1}%\n",
71                100.0 * residual_var / total_var
72            ));
73        }
74
75        // Residual statistics
76        let residuals = self.residual.values();
77        let mean = residuals.mean().unwrap_or(0.0);
78        let std = residuals.std(1.0);
79        let min = residuals.iter().fold(f64::INFINITY, |a, &b| a.min(b));
80        let max = residuals.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
81
82        summary.push_str(&format!("\nResidual Statistics:\n"));
83        summary.push_str(&format!("  Mean: {:.4}\n", mean));
84        summary.push_str(&format!("  Std: {:.4}\n", std));
85        summary.push_str(&format!("  Min: {:.4}\n", min));
86        summary.push_str(&format!("  Max: {:.4}\n", max));
87
88        summary
89    }
90
91    /// Plot decomposition (would integrate with plotting library)
92    pub fn plot(&self) -> String {
93        "Decomposition plot would be shown here".to_string()
94    }
95
96    /// Get seasonally adjusted series (original - seasonal)
97    pub fn seasonally_adjusted(&self) -> TimeSeries {
98        let values = match self.method {
99            DecompositionMethod::Additive => self.original.values() - self.seasonal.values(),
100            DecompositionMethod::Multiplicative => self.original.values() / self.seasonal.values(),
101        };
102
103        TimeSeries {
104            name: format!("{}_sa", self.original.name()),
105            timestamps: self.original.timestamps().to_vec(),
106            values,
107            frequency: self.original.frequency(),
108            metadata: self
109                .original
110                .get_metadata("")
111                .map(|_| "seasonally_adjusted".to_string())
112                .map(|v| vec![("adjustment".to_string(), v)].into_iter().collect())
113                .unwrap_or_default(),
114        }
115    }
116}
117
118/// Classical decomposition using moving averages
119pub struct MovingAverageDecomposition {
120    method: DecompositionMethod,
121    period: usize,
122    trend_window: Option<usize>,
123}
124
125impl MovingAverageDecomposition {
126    /// Create new moving average decomposition
127    pub fn new(method: DecompositionMethod, period: usize) -> Self {
128        Self {
129            method,
130            period,
131            trend_window: None,
132        }
133    }
134
135    /// Set trend window size (if None, uses period)
136    pub fn trend_window(mut self, window: Option<usize>) -> Self {
137        self.trend_window = window;
138        self
139    }
140
141    /// Perform decomposition
142    pub fn decompose(&self, ts: &TimeSeries) -> Result<DecompositionResults> {
143        let n = ts.len();
144        let period = self.period;
145
146        if n < 2 * period {
147            return Err(Error::DataError(format!(
148                "Need at least {} observations for period {}",
149                2 * period,
150                period
151            )));
152        }
153
154        // Determine trend window
155        let trend_window =
156            self.trend_window
157                .unwrap_or(if period % 2 == 0 { period + 1 } else { period });
158
159        // Step 1: Estimate trend using centered moving average
160        let trend = self.estimate_trend(ts.values(), trend_window)?;
161
162        // Step 2: Detrend series
163        let detrended = self.detrend(ts.values(), &trend);
164
165        // Step 3: Estimate seasonal component
166        let seasonal = self.estimate_seasonal(&detrended, period);
167
168        // Step 4: Calculate residuals
169        let residual = self.calculate_residuals(ts.values(), &trend, &seasonal);
170
171        // Create time series for components
172        let trend_ts = TimeSeries {
173            name: format!("{}_trend", ts.name()),
174            timestamps: ts.timestamps().to_vec(),
175            values: trend,
176            frequency: ts.frequency(),
177            metadata: ts
178                .get_metadata("")
179                .map(|_| "trend".to_string())
180                .map(|v| vec![("component".to_string(), v)].into_iter().collect())
181                .unwrap_or_default(),
182        };
183
184        let seasonal_ts = TimeSeries {
185            name: format!("{}_seasonal", ts.name()),
186            timestamps: ts.timestamps().to_vec(),
187            values: seasonal,
188            frequency: ts.frequency(),
189            metadata: ts
190                .get_metadata("")
191                .map(|_| "seasonal".to_string())
192                .map(|v| vec![("component".to_string(), v)].into_iter().collect())
193                .unwrap_or_default(),
194        };
195
196        let residual_ts = TimeSeries {
197            name: format!("{}_residual", ts.name()),
198            timestamps: ts.timestamps().to_vec(),
199            values: residual,
200            frequency: ts.frequency(),
201            metadata: ts
202                .get_metadata("")
203                .map(|_| "residual".to_string())
204                .map(|v| vec![("component".to_string(), v)].into_iter().collect())
205                .unwrap_or_default(),
206        };
207
208        Ok(DecompositionResults {
209            original: ts.clone(),
210            trend: trend_ts,
211            seasonal: seasonal_ts,
212            residual: residual_ts,
213            method: self.method,
214            period,
215        })
216    }
217
218    /// Estimate trend using centered moving average
219    fn estimate_trend(&self, y: &Array1<f64>, window: usize) -> Result<Array1<f64>> {
220        let n = y.len();
221        let half_window = window / 2;
222
223        let mut trend = Array1::zeros(n);
224
225        // Use centered moving average for interior points
226        for i in half_window..(n - half_window) {
227            let mut sum = 0.0;
228            for j in (i - half_window)..=(i + half_window) {
229                sum += y[j];
230            }
231            trend[i] = sum / window as f64;
232        }
233
234        // Use asymmetric moving averages for edges
235        for i in 0..half_window {
236            let window_size = half_window + i + 1;
237            let mut sum = 0.0;
238            for j in 0..window_size {
239                sum += y[j];
240            }
241            trend[i] = sum / window_size as f64;
242        }
243
244        for i in (n - half_window)..n {
245            let window_size = half_window + (n - i);
246            let mut sum = 0.0;
247            for j in (n - window_size)..n {
248                sum += y[j];
249            }
250            trend[i] = sum / window_size as f64;
251        }
252
253        Ok(trend)
254    }
255
256    /// Detrend series
257    fn detrend(&self, y: &Array1<f64>, trend: &Array1<f64>) -> Array1<f64> {
258        match self.method {
259            DecompositionMethod::Additive => y - trend,
260            DecompositionMethod::Multiplicative => y / trend,
261        }
262    }
263
264    /// Estimate seasonal component
265    fn estimate_seasonal(&self, detrended: &Array1<f64>, period: usize) -> Array1<f64> {
266        let n = detrended.len();
267        let mut seasonal = Array1::zeros(n);
268
269        // Calculate seasonal indices for each period position
270        let _n_seasons = (n + period - 1) / period;
271        let mut seasonal_sums = vec![0.0; period];
272        let mut seasonal_counts = vec![0; period];
273
274        for i in 0..n {
275            let pos = i % period;
276            seasonal_sums[pos] += detrended[i];
277            seasonal_counts[pos] += 1;
278        }
279
280        // Average seasonal indices
281        let mut seasonal_indices = vec![0.0; period];
282        for i in 0..period {
283            if seasonal_counts[i] > 0 {
284                seasonal_indices[i] = seasonal_sums[i] / seasonal_counts[i] as f64;
285            }
286        }
287
288        // Center seasonal indices (sum to zero for additive, average to 1 for multiplicative)
289        match self.method {
290            DecompositionMethod::Additive => {
291                let mean: f64 = seasonal_indices.iter().sum::<f64>() / period as f64;
292                for i in 0..period {
293                    seasonal_indices[i] -= mean;
294                }
295            }
296            DecompositionMethod::Multiplicative => {
297                let product: f64 = seasonal_indices.iter().product();
298                let geometric_mean = product.powf(1.0 / period as f64);
299                for i in 0..period {
300                    seasonal_indices[i] /= geometric_mean;
301                }
302            }
303        }
304
305        // Apply seasonal pattern
306        for i in 0..n {
307            seasonal[i] = seasonal_indices[i % period];
308        }
309
310        seasonal
311    }
312
313    /// Calculate residuals
314    fn calculate_residuals(
315        &self,
316        y: &Array1<f64>,
317        trend: &Array1<f64>,
318        seasonal: &Array1<f64>,
319    ) -> Array1<f64> {
320        match self.method {
321            DecompositionMethod::Additive => y - trend - seasonal,
322            DecompositionMethod::Multiplicative => y / (trend * seasonal),
323        }
324    }
325}
326
327/// STL (Seasonal-Trend decomposition using LOESS)
328pub struct STLDecomposition {
329    period: usize,
330    seasonal_window: usize,
331    trend_window: usize,
332    low_pass_window: usize,
333    robust: bool,
334    n_iter: usize,
335}
336
337impl Default for STLDecomposition {
338    fn default() -> Self {
339        Self {
340            period: 12, // Monthly data
341            seasonal_window: 7,
342            trend_window: 13,
343            low_pass_window: 13,
344            robust: true,
345            n_iter: 2,
346        }
347    }
348}
349
350impl STLDecomposition {
351    /// Create new STL decomposition
352    pub fn new(period: usize) -> Self {
353        Self {
354            period,
355            ..Default::default()
356        }
357    }
358
359    /// Set seasonal window size
360    pub fn seasonal_window(mut self, window: usize) -> Self {
361        self.seasonal_window = window;
362        self
363    }
364
365    /// Set trend window size
366    pub fn trend_window(mut self, window: usize) -> Self {
367        self.trend_window = window;
368        self
369    }
370
371    /// Set low-pass window size
372    pub fn low_pass_window(mut self, window: usize) -> Self {
373        self.low_pass_window = window;
374        self
375    }
376
377    /// Enable/disable robust decomposition
378    pub fn robust(mut self, robust: bool) -> Self {
379        self.robust = robust;
380        self
381    }
382
383    /// Set number of iterations
384    pub fn n_iter(mut self, n_iter: usize) -> Self {
385        self.n_iter = n_iter;
386        self
387    }
388
389    /// Perform STL decomposition
390    pub fn decompose(&self, ts: &TimeSeries) -> Result<DecompositionResults> {
391        let n = ts.len();
392        let period = self.period;
393
394        if n < 2 * period {
395            return Err(Error::DataError(format!(
396                "Need at least {} observations for STL with period {}",
397                2 * period,
398                period
399            )));
400        }
401
402        let y = ts.values();
403        let mut trend = Array1::zeros(n);
404        let mut seasonal = Array1::zeros(n);
405        let mut residual = y.clone();
406
407        // Robustness weights (initially all ones)
408        let mut weights = Array1::ones(n);
409
410        for _ in 0..self.n_iter {
411            // Inner loop
412            for _ in 0..self.n_iter {
413                // Step 1: Detrend
414                let detrended = &residual - &trend;
415
416                // Step 2: Cycle-subseries smoothing
417                let seasonal_smoothed = self.smooth_seasonal(&detrended, &weights);
418
419                // Step 3: Low-pass filtering of seasonal component
420                let seasonal_lowpass = self.low_pass_filter(&seasonal_smoothed);
421
422                // Step 4: Detrended seasonal
423                seasonal = &seasonal_smoothed - &seasonal_lowpass;
424
425                // Step 5: Deseasonalize
426                let deseasonalized = y - &seasonal;
427
428                // Step 6: Trend smoothing
429                trend = self.smooth_trend(&deseasonalized, &weights);
430
431                // Update residuals
432                residual = y - &trend - &seasonal;
433            }
434
435            // Update robustness weights if robust estimation
436            if self.robust {
437                weights = self.update_weights(&residual);
438            }
439        }
440
441        // Create time series for components
442        let trend_ts = TimeSeries {
443            name: format!("{}_trend", ts.name()),
444            timestamps: ts.timestamps().to_vec(),
445            values: trend,
446            frequency: ts.frequency(),
447            metadata: ts
448                .get_metadata("")
449                .map(|_| "trend".to_string())
450                .map(|v| vec![("component".to_string(), v)].into_iter().collect())
451                .unwrap_or_default(),
452        };
453
454        let seasonal_ts = TimeSeries {
455            name: format!("{}_seasonal", ts.name()),
456            timestamps: ts.timestamps().to_vec(),
457            values: seasonal,
458            frequency: ts.frequency(),
459            metadata: ts
460                .get_metadata("")
461                .map(|_| "seasonal".to_string())
462                .map(|v| vec![("component".to_string(), v)].into_iter().collect())
463                .unwrap_or_default(),
464        };
465
466        let residual_ts = TimeSeries {
467            name: format!("{}_residual", ts.name()),
468            timestamps: ts.timestamps().to_vec(),
469            values: residual,
470            frequency: ts.frequency(),
471            metadata: ts
472                .get_metadata("")
473                .map(|_| "residual".to_string())
474                .map(|v| vec![("component".to_string(), v)].into_iter().collect())
475                .unwrap_or_default(),
476        };
477
478        Ok(DecompositionResults {
479            original: ts.clone(),
480            trend: trend_ts,
481            seasonal: seasonal_ts,
482            residual: residual_ts,
483            method: DecompositionMethod::Additive, // STL is additive by design
484            period,
485        })
486    }
487
488    /// Smooth seasonal component using LOESS on cycle-subseries
489    fn smooth_seasonal(&self, detrended: &Array1<f64>, weights: &Array1<f64>) -> Array1<f64> {
490        let n = detrended.len();
491        let period = self.period;
492        let mut smoothed = Array1::zeros(n);
493
494        // For each position in the seasonal cycle
495        for pos in 0..period {
496            let mut subseries = Vec::new();
497            let mut subweights = Vec::new();
498            let mut indices = Vec::new();
499
500            // Collect all values at this seasonal position
501            for i in (pos..n).step_by(period) {
502                subseries.push(detrended[i]);
503                subweights.push(weights[i]);
504                indices.push(i);
505            }
506
507            if subseries.len() >= 3 {
508                // Apply LOESS smoothing (simplified: use weighted moving average)
509                let window = self.seasonal_window.min(subseries.len());
510
511                for (idx, &i) in indices.iter().enumerate() {
512                    let start = idx.saturating_sub(window / 2);
513                    let end = (idx + window / 2 + 1).min(subseries.len());
514
515                    let mut sum = 0.0;
516                    let mut weight_sum = 0.0;
517
518                    for j in start..end {
519                        // Triangular kernel weights
520                        let distance = (j as isize - idx as isize).abs() as f64;
521                        let kernel_weight = 1.0 - distance / (window as f64 / 2.0);
522                        let weight = kernel_weight.max(0.0) * subweights[j];
523
524                        sum += weight * subseries[j];
525                        weight_sum += weight;
526                    }
527
528                    if weight_sum > 0.0 {
529                        smoothed[i] = sum / weight_sum;
530                    } else {
531                        smoothed[i] = subseries[idx];
532                    }
533                }
534            } else {
535                // Not enough points, use original values
536                for (idx, &i) in indices.iter().enumerate() {
537                    smoothed[i] = subseries[idx];
538                }
539            }
540        }
541
542        smoothed
543    }
544
545    /// Low-pass filter for seasonal component
546    fn low_pass_filter(&self, seasonal: &Array1<f64>) -> Array1<f64> {
547        let n = seasonal.len();
548        let window = self.low_pass_window;
549        let mut filtered = Array1::zeros(n);
550
551        for i in 0..n {
552            let start = i.saturating_sub(window / 2);
553            let end = (i + window / 2 + 1).min(n);
554
555            let mut sum = 0.0;
556            for j in start..end {
557                sum += seasonal[j];
558            }
559            filtered[i] = sum / (end - start) as f64;
560        }
561
562        // Additional moving average for smoothing
563        let mut double_filtered = Array1::zeros(n);
564        for i in 0..n {
565            let start = i.saturating_sub(window / 2);
566            let end = (i + window / 2 + 1).min(n);
567
568            let mut sum = 0.0;
569            for j in start..end {
570                sum += filtered[j];
571            }
572            double_filtered[i] = sum / (end - start) as f64;
573        }
574
575        double_filtered
576    }
577
578    /// Smooth trend using LOESS
579    fn smooth_trend(&self, deseasonalized: &Array1<f64>, weights: &Array1<f64>) -> Array1<f64> {
580        let n = deseasonalized.len();
581        let window = self.trend_window;
582        let mut smoothed = Array1::zeros(n);
583
584        for i in 0..n {
585            let start = i.saturating_sub(window / 2);
586            let end = (i + window / 2 + 1).min(n);
587
588            let mut sum = 0.0;
589            let mut weight_sum = 0.0;
590
591            for j in start..end {
592                // Quadratic kernel weights
593                let distance = (j as isize - i as isize).abs() as f64;
594                let normalized = distance / (window as f64 / 2.0);
595                let kernel_weight = if normalized <= 1.0 {
596                    (1.0 - normalized * normalized).powi(2)
597                } else {
598                    0.0
599                };
600
601                let weight = kernel_weight * weights[j];
602                sum += weight * deseasonalized[j];
603                weight_sum += weight;
604            }
605
606            if weight_sum > 0.0 {
607                smoothed[i] = sum / weight_sum;
608            } else {
609                smoothed[i] = deseasonalized[i];
610            }
611        }
612
613        smoothed
614    }
615
616    /// Update robustness weights based on residuals
617    fn update_weights(&self, residuals: &Array1<f64>) -> Array1<f64> {
618        let n = residuals.len();
619        let median = self.median_abs(residuals);
620
621        if median > 0.0 {
622            let mut weights = Array1::ones(n);
623            for i in 0..n {
624                let u = residuals[i].abs() / (6.0 * median);
625                if u < 1.0 {
626                    weights[i] = (1.0 - u * u).powi(2);
627                } else {
628                    weights[i] = 0.0;
629                }
630            }
631            weights
632        } else {
633            Array1::ones(n)
634        }
635    }
636
637    /// Compute median absolute deviation
638    fn median_abs(&self, values: &Array1<f64>) -> f64 {
639        let n = values.len();
640        let mut abs_values: Vec<f64> = values.iter().map(|x| x.abs()).collect();
641        abs_values.sort_by(|a, b| a.partial_cmp(b).unwrap());
642
643        if n % 2 == 0 {
644            (abs_values[n / 2 - 1] + abs_values[n / 2]) / 2.0
645        } else {
646            abs_values[n / 2]
647        }
648    }
649}
650
651/// Hodrick-Prescott filter for separating trend from cycle
652pub struct HodrickPrescottFilter {
653    /// Smoothing parameter (λ)
654    /// Common values: 100 for annual, 1600 for quarterly, 14400 for monthly
655    lambda: f64,
656}
657
658impl Default for HodrickPrescottFilter {
659    fn default() -> Self {
660        Self { lambda: 1600.0 } // Quarterly data
661    }
662}
663
664impl HodrickPrescottFilter {
665    /// Create new HP filter
666    pub fn new(lambda: f64) -> Self {
667        Self { lambda }
668    }
669
670    /// Apply HP filter to time series
671    pub fn filter(&self, ts: &TimeSeries) -> Result<(TimeSeries, TimeSeries)> {
672        let y = ts.values();
673        let n = y.len();
674
675        if n < 3 {
676            return Err(Error::DataError(
677                "Need at least 3 observations for HP filter".to_string(),
678            ));
679        }
680
681        // Build the second difference matrix D
682        let mut d = Array2::zeros((n - 2, n));
683        for i in 0..(n - 2) {
684            d[(i, i)] = 1.0;
685            d[(i, i + 1)] = -2.0;
686            d[(i, i + 2)] = 1.0;
687        }
688
689        // Compute trend: τ = (I + λD'D)^{-1} y
690        let identity = Array2::eye(n);
691        let d_t = d.t();
692        let dtd = d_t.dot(&d);
693        let mut a = identity.clone();
694        a += &(self.lambda * &dtd);
695
696        // Solve linear system Aτ = y
697        let trend_values = so_linalg::solve(&a, y)
698            .map_err(|e| Error::DataError(format!("HP filter solve failed: {}", e)))?;
699
700        // Cyclical component: c = y - τ
701        let cycle_values = y - &trend_values;
702
703        // Create time series
704        let trend_ts = TimeSeries {
705            name: format!("{}_trend", ts.name()),
706            timestamps: ts.timestamps().to_vec(),
707            values: trend_values,
708            frequency: ts.frequency(),
709            metadata: ts
710                .get_metadata("")
711                .map(|_| "trend".to_string())
712                .map(|v| vec![("component".to_string(), v)].into_iter().collect())
713                .unwrap_or_default(),
714        };
715
716        let cycle_ts = TimeSeries {
717            name: format!("{}_cycle", ts.name()),
718            timestamps: ts.timestamps().to_vec(),
719            values: cycle_values,
720            frequency: ts.frequency(),
721            metadata: ts
722                .get_metadata("")
723                .map(|_| "cycle".to_string())
724                .map(|v| vec![("component".to_string(), v)].into_iter().collect())
725                .unwrap_or_default(),
726        };
727
728        Ok((trend_ts, cycle_ts))
729    }
730}
731
732/// X-12-ARIMA seasonal adjustment (simplified implementation)
733pub struct X12ARIMA {
734    period: usize,
735}
736
737impl X12ARIMA {
738    /// Create new X-12-ARIMA adjuster
739    pub fn new(period: usize) -> Self {
740        Self { period }
741    }
742
743    /// Apply seasonal adjustment
744    pub fn adjust(&self, ts: &TimeSeries) -> Result<TimeSeries> {
745        // Simplified implementation: use moving average decomposition
746        let decomposition =
747            MovingAverageDecomposition::new(DecompositionMethod::Additive, self.period)
748                .decompose(ts)?;
749
750        Ok(decomposition.seasonally_adjusted())
751    }
752}
753
754/// Extension trait for TimeSeries decomposition methods
755pub trait DecompositionExt {
756    /// Moving average decomposition
757    fn decompose_ma(
758        &self,
759        method: DecompositionMethod,
760        period: usize,
761    ) -> Result<DecompositionResults>;
762
763    /// STL decomposition
764    fn decompose_stl(&self, period: usize) -> Result<DecompositionResults>;
765
766    /// Hodrick-Prescott filter
767    fn hp_filter(&self, lambda: f64) -> Result<(TimeSeries, TimeSeries)>;
768
769    /// X-12-ARIMA seasonal adjustment
770    fn x12_adjust(&self, period: usize) -> Result<TimeSeries>;
771}
772
773impl DecompositionExt for TimeSeries {
774    fn decompose_ma(
775        &self,
776        method: DecompositionMethod,
777        period: usize,
778    ) -> Result<DecompositionResults> {
779        MovingAverageDecomposition::new(method, period).decompose(self)
780    }
781
782    fn decompose_stl(&self, period: usize) -> Result<DecompositionResults> {
783        STLDecomposition::new(period).decompose(self)
784    }
785
786    fn hp_filter(&self, lambda: f64) -> Result<(TimeSeries, TimeSeries)> {
787        HodrickPrescottFilter::new(lambda).filter(self)
788    }
789
790    fn x12_adjust(&self, period: usize) -> Result<TimeSeries> {
791        X12ARIMA::new(period).adjust(self)
792    }
793}