|
30 | 30 |
|
31 | 31 | using Numerics.Data.Statistics; |
32 | 32 | using Numerics.MachineLearning; |
| 33 | +using Numerics.Mathematics; |
33 | 34 | using Numerics.Sampling; |
34 | 35 | using System; |
35 | 36 | using System.Collections.Generic; |
@@ -1019,6 +1020,101 @@ public TimeSeries MovingSum(int period) |
1019 | 1020 | return timeSeries; |
1020 | 1021 | } |
1021 | 1022 |
|
| 1023 | + /// <summary> |
| 1024 | + /// Performs classical additive seasonal decomposition using FFT-based seasonal extraction. |
| 1025 | + /// </summary> |
| 1026 | + /// <param name="period">The seasonal period (e.g., 12 for monthly data with annual seasonality).</param> |
| 1027 | + /// <returns> |
| 1028 | + /// A tuple containing: |
| 1029 | + /// <list type="bullet"> |
| 1030 | + /// <item><description>Trend: The trend component as a TimeSeries (moving average with the given period).</description></item> |
| 1031 | + /// <item><description>Seasonal: The seasonal component as a double array of length equal to the original series.</description></item> |
| 1032 | + /// <item><description>Residual: The residual component as a TimeSeries (defined where the trend is defined).</description></item> |
| 1033 | + /// </list> |
| 1034 | + /// </returns> |
| 1035 | + /// <exception cref="ArgumentException">Thrown when the period is less than 2 or the series contains fewer than 2 complete periods.</exception> |
| 1036 | + public (TimeSeries Trend, double[] Seasonal, TimeSeries Residual) SeasonalDecompose(int period) |
| 1037 | + { |
| 1038 | + if (period < 2) |
| 1039 | + throw new ArgumentException("Period must be at least 2.", nameof(period)); |
| 1040 | + if (Count < 2 * period) |
| 1041 | + throw new ArgumentException("Time series must contain at least 2 complete periods.", nameof(period)); |
| 1042 | + |
| 1043 | + SortByTime(); |
| 1044 | + int n = Count; |
| 1045 | + |
| 1046 | + // Step 1: Compute trend via moving average |
| 1047 | + var trend = MovingAverage(period); |
| 1048 | + |
| 1049 | + // Step 2: Build full-length arrays and detrend |
| 1050 | + double[] values = new double[n]; |
| 1051 | + double[] trendFull = new double[n]; |
| 1052 | + bool[] hasTrend = new bool[n]; |
| 1053 | + |
| 1054 | + for (int i = 0; i < n; i++) |
| 1055 | + values[i] = this[i].Value; |
| 1056 | + |
| 1057 | + // Map trend values to original indices |
| 1058 | + // MovingAverage outputs (Count - period + 1) values starting at index (period - 1) |
| 1059 | + int trendStart = period - 1; |
| 1060 | + for (int i = 0; i < trend.Count; i++) |
| 1061 | + { |
| 1062 | + trendFull[trendStart + i] = trend[i].Value; |
| 1063 | + hasTrend[trendStart + i] = true; |
| 1064 | + } |
| 1065 | + |
| 1066 | + // Create detrended series |
| 1067 | + double[] detrended = new double[n]; |
| 1068 | + for (int i = 0; i < n; i++) |
| 1069 | + detrended[i] = hasTrend[i] ? values[i] - trendFull[i] : 0.0; |
| 1070 | + |
| 1071 | + // Step 3: FFT-based seasonal extraction |
| 1072 | + // Pad to power of 2 for FFT |
| 1073 | + int fftLength = (int)Math.Pow(2, Math.Ceiling(Math.Log(n, 2))); |
| 1074 | + if (fftLength < n) fftLength *= 2; |
| 1075 | + double[] fftData = new double[fftLength]; |
| 1076 | + Array.Copy(detrended, fftData, n); |
| 1077 | + |
| 1078 | + // Forward FFT |
| 1079 | + Fourier.RealFFT(fftData); |
| 1080 | + |
| 1081 | + // Identify harmonic bins of the seasonal frequency |
| 1082 | + // Harmonic h corresponds to frequency bin k = round(h * fftLength / period) |
| 1083 | + var harmonicBins = new HashSet<int>(); |
| 1084 | + for (int h = 1; ; h++) |
| 1085 | + { |
| 1086 | + int k = (int)Math.Round((double)h * fftLength / period); |
| 1087 | + if (k <= 0 || k >= fftLength / 2) break; |
| 1088 | + harmonicBins.Add(k); |
| 1089 | + } |
| 1090 | + |
| 1091 | + // Keep only harmonic frequencies |
| 1092 | + double[] filtered = new double[fftLength]; |
| 1093 | + foreach (int k in harmonicBins) |
| 1094 | + { |
| 1095 | + filtered[2 * k] = fftData[2 * k]; |
| 1096 | + filtered[2 * k + 1] = fftData[2 * k + 1]; |
| 1097 | + } |
| 1098 | + |
| 1099 | + // Inverse FFT |
| 1100 | + Fourier.RealFFT(filtered, true); |
| 1101 | + |
| 1102 | + // Scale by 2/fftLength as required by the RealFFT inverse transform |
| 1103 | + double[] seasonal = new double[n]; |
| 1104 | + for (int i = 0; i < n; i++) |
| 1105 | + seasonal[i] = filtered[i] * 2.0 / fftLength; |
| 1106 | + |
| 1107 | + // Step 4: Compute residual |
| 1108 | + var residual = new TimeSeries(TimeInterval); |
| 1109 | + for (int i = 0; i < n; i++) |
| 1110 | + { |
| 1111 | + if (hasTrend[i]) |
| 1112 | + residual.Add(new SeriesOrdinate<DateTime, double>(this[i].Index, values[i] - trendFull[i] - seasonal[i])); |
| 1113 | + } |
| 1114 | + |
| 1115 | + return (trend, seasonal, residual); |
| 1116 | + } |
| 1117 | + |
1022 | 1118 | /// <summary> |
1023 | 1119 | /// Shift all of the dates to match the new start date. |
1024 | 1120 | /// </summary> |
|
0 commit comments