1use super::timeseries::TimeSeries;
16use ndarray::{Array1, Array2};
17use so_core::error::{Error, Result};
18use so_linalg;
19
20#[derive(Debug, Clone, Copy, PartialEq)]
22pub enum DecompositionMethod {
23 Additive,
25 Multiplicative,
27}
28
29#[derive(Debug, Clone)]
31pub struct DecompositionResults {
32 pub original: TimeSeries,
34 pub trend: TimeSeries,
36 pub seasonal: TimeSeries,
38 pub residual: TimeSeries,
40 pub method: DecompositionMethod,
42 pub period: usize,
44}
45
46impl DecompositionResults {
47 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 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 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 pub fn plot(&self) -> String {
93 "Decomposition plot would be shown here".to_string()
94 }
95
96 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
118pub struct MovingAverageDecomposition {
120 method: DecompositionMethod,
121 period: usize,
122 trend_window: Option<usize>,
123}
124
125impl MovingAverageDecomposition {
126 pub fn new(method: DecompositionMethod, period: usize) -> Self {
128 Self {
129 method,
130 period,
131 trend_window: None,
132 }
133 }
134
135 pub fn trend_window(mut self, window: Option<usize>) -> Self {
137 self.trend_window = window;
138 self
139 }
140
141 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 let trend_window =
156 self.trend_window
157 .unwrap_or(if period % 2 == 0 { period + 1 } else { period });
158
159 let trend = self.estimate_trend(ts.values(), trend_window)?;
161
162 let detrended = self.detrend(ts.values(), &trend);
164
165 let seasonal = self.estimate_seasonal(&detrended, period);
167
168 let residual = self.calculate_residuals(ts.values(), &trend, &seasonal);
170
171 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 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 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 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 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 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 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 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 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 for i in 0..n {
307 seasonal[i] = seasonal_indices[i % period];
308 }
309
310 seasonal
311 }
312
313 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
327pub 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, 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 pub fn new(period: usize) -> Self {
353 Self {
354 period,
355 ..Default::default()
356 }
357 }
358
359 pub fn seasonal_window(mut self, window: usize) -> Self {
361 self.seasonal_window = window;
362 self
363 }
364
365 pub fn trend_window(mut self, window: usize) -> Self {
367 self.trend_window = window;
368 self
369 }
370
371 pub fn low_pass_window(mut self, window: usize) -> Self {
373 self.low_pass_window = window;
374 self
375 }
376
377 pub fn robust(mut self, robust: bool) -> Self {
379 self.robust = robust;
380 self
381 }
382
383 pub fn n_iter(mut self, n_iter: usize) -> Self {
385 self.n_iter = n_iter;
386 self
387 }
388
389 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 let mut weights = Array1::ones(n);
409
410 for _ in 0..self.n_iter {
411 for _ in 0..self.n_iter {
413 let detrended = &residual - &trend;
415
416 let seasonal_smoothed = self.smooth_seasonal(&detrended, &weights);
418
419 let seasonal_lowpass = self.low_pass_filter(&seasonal_smoothed);
421
422 seasonal = &seasonal_smoothed - &seasonal_lowpass;
424
425 let deseasonalized = y - &seasonal;
427
428 trend = self.smooth_trend(&deseasonalized, &weights);
430
431 residual = y - &trend - &seasonal;
433 }
434
435 if self.robust {
437 weights = self.update_weights(&residual);
438 }
439 }
440
441 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, period,
485 })
486 }
487
488 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 pos in 0..period {
496 let mut subseries = Vec::new();
497 let mut subweights = Vec::new();
498 let mut indices = Vec::new();
499
500 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 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 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 for (idx, &i) in indices.iter().enumerate() {
537 smoothed[i] = subseries[idx];
538 }
539 }
540 }
541
542 smoothed
543 }
544
545 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 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 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 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 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 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
651pub struct HodrickPrescottFilter {
653 lambda: f64,
656}
657
658impl Default for HodrickPrescottFilter {
659 fn default() -> Self {
660 Self { lambda: 1600.0 } }
662}
663
664impl HodrickPrescottFilter {
665 pub fn new(lambda: f64) -> Self {
667 Self { lambda }
668 }
669
670 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 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 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 let trend_values = so_linalg::solve(&a, y)
698 .map_err(|e| Error::DataError(format!("HP filter solve failed: {}", e)))?;
699
700 let cycle_values = y - &trend_values;
702
703 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
732pub struct X12ARIMA {
734 period: usize,
735}
736
737impl X12ARIMA {
738 pub fn new(period: usize) -> Self {
740 Self { period }
741 }
742
743 pub fn adjust(&self, ts: &TimeSeries) -> Result<TimeSeries> {
745 let decomposition =
747 MovingAverageDecomposition::new(DecompositionMethod::Additive, self.period)
748 .decompose(ts)?;
749
750 Ok(decomposition.seasonally_adjusted())
751 }
752}
753
754pub trait DecompositionExt {
756 fn decompose_ma(
758 &self,
759 method: DecompositionMethod,
760 period: usize,
761 ) -> Result<DecompositionResults>;
762
763 fn decompose_stl(&self, period: usize) -> Result<DecompositionResults>;
765
766 fn hp_filter(&self, lambda: f64) -> Result<(TimeSeries, TimeSeries)>;
768
769 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}