chronix_analytics/preprocess/decomposition.rs
1//! Seasonal-Trend decomposition using Loess (STL).
2//!
3//! Splits a time series into **trend**, **seasonal**, and **residual**
4//! components: `y = trend + seasonal + residual`.
5//!
6//! The implementation follows the Cleveland et al. (1990) STL algorithm:
7//!
8//! 1. Start with `seasonal = 0`.
9//! 2. Detrend: `detrended = y - trend`.
10//! 3. Subseries smoothing: extract each season's slice, apply degree-1 Loess
11//! (locally weighted polynomial regression with tricube kernel).
12//! 4. Trend update: `y - seasonal`, then Loess with `trend_window`.
13//! 5. Repeat for `n_iter` iterations.
14
15use thiserror::Error;
16
17/// Decomposition result: `y[i] = trend[i] + seasonal[i] + residual[i]`.
18#[derive(Debug, Clone)]
19pub struct Decomposition {
20 /// Trend component.
21 pub trend: Vec<f64>,
22 /// Seasonal component.
23 pub seasonal: Vec<f64>,
24 /// Residual (remainder) component.
25 pub residual: Vec<f64>,
26}
27
28/// Trait for seasonal decomposition strategies.
29pub trait SeasonalDecomposer: Send + Sync {
30 /// Decompose a time series into trend, seasonal, and residual components.
31 fn decompose(
32 &self,
33 timestamps: &[i64],
34 values: &[f64],
35 period: usize,
36 ) -> Result<Decomposition, DecompositionError>;
37}
38
39/// STL decomposer implementing [`SeasonalDecomposer`].
40pub struct StlDecomposer {
41 /// Number of outer-loop iterations.
42 pub n_iter: usize,
43 /// Trend smoother window length (None = auto).
44 pub trend_window: Option<usize>,
45}
46
47impl Default for StlDecomposer {
48 fn default() -> Self {
49 Self {
50 n_iter: 2,
51 trend_window: None,
52 }
53 }
54}
55
56impl SeasonalDecomposer for StlDecomposer {
57 #[tracing::instrument(skip_all, level = "debug")]
58 fn decompose(
59 &self,
60 _timestamps: &[i64],
61 values: &[f64],
62 period: usize,
63 ) -> Result<Decomposition, DecompositionError> {
64 let config = StlConfig {
65 period,
66 n_iter: self.n_iter,
67 trend_window: self.trend_window,
68 seasonal_window: None,
69 low_pass_window: None,
70 outer_iter: 0,
71 };
72 stl_decompose(values, &config)
73 }
74}
75
76/// Errors that can occur during decomposition.
77#[derive(Debug, Error)]
78pub enum DecompositionError {
79 /// The series is too short for the requested seasonal period.
80 #[error("series length ({len}) must be at least 2× the period ({period})")]
81 TooShort {
82 /// Actual series length.
83 len: usize,
84 /// Requested seasonal period.
85 period: usize,
86 },
87
88 /// The seasonal period is invalid (must be ≥ 2).
89 #[error("period must be ≥ 2, got {0}")]
90 InvalidPeriod(usize),
91}
92
93/// Configuration for the STL decomposer.
94#[derive(Debug, Clone)]
95pub struct StlConfig {
96 /// Seasonal period (e.g., 24 for hourly data with daily seasonality).
97 pub period: usize,
98 /// Number of outer-loop iterations (default: 2).
99 pub n_iter: usize,
100 /// Trend smoother window length — must be odd, ≥ 3.
101 /// Default: `period + 1` (rounded up to next odd).
102 pub trend_window: Option<usize>,
103 /// Seasonal subseries smoother window length, in **cycles** — must be
104 /// odd, ≥ 7. Default: 7, as in Cleveland et al. (1990), R's `stl` and
105 /// statsmodels' `STL`.
106 pub seasonal_window: Option<usize>,
107 /// Low-pass smoother window length — must be odd, ≥ 3.
108 /// Default: the smallest odd integer ≥ `period`.
109 pub low_pass_window: Option<usize>,
110 /// Number of **outer** iterations, which is what makes the fit robust to
111 /// outliers. `0` (the default) is the plain inner loop.
112 ///
113 /// Each outer pass reweights every point by Cleveland's bisquare of its
114 /// residual against six times the median absolute residual, so a point
115 /// far from the fit stops pulling on the next one. In a metrics database
116 /// the case for it is ordinary rather than exotic: a restart, a scrape
117 /// that timed out and was backfilled as a spike, a sensor returning its
118 /// error sentinel. Without it one such point is spread across the whole
119 /// seasonal component by the cycle-subseries smoother — it lands in the
120 /// same season of every cycle.
121 ///
122 /// Cleveland et al. (1990) §4.3 suggests 5–10 when robustness is wanted
123 /// and 0 when it is not; statsmodels' `STL(robust=True)` uses 15 with an
124 /// early exit, which is the same bargain. Default 0, so the cost is paid
125 /// only where it is asked for.
126 pub outer_iter: usize,
127}
128
129impl StlConfig {
130 /// Creates a new STL configuration with the given seasonal period.
131 pub fn new(period: usize) -> Self {
132 Self {
133 period,
134 n_iter: 2,
135 trend_window: None,
136 seasonal_window: None,
137 low_pass_window: None,
138 outer_iter: 0,
139 }
140 }
141
142 /// Sets the number of STL iterations.
143 pub fn with_iterations(mut self, n: usize) -> Self {
144 self.n_iter = n.max(1);
145 self
146 }
147
148 /// Sets the trend smoother window length.
149 pub fn with_trend_window(mut self, w: usize) -> Self {
150 self.trend_window = Some(w);
151 self
152 }
153
154 /// Sets the seasonal subseries smoother window length, in **cycles**.
155 pub fn with_seasonal_window(mut self, w: usize) -> Self {
156 self.seasonal_window = Some(w);
157 self
158 }
159
160 /// Sets the low-pass smoother window length.
161 pub fn with_low_pass_window(mut self, w: usize) -> Self {
162 self.low_pass_window = Some(w);
163 self
164 }
165
166 /// Makes the fit robust to outliers, with Cleveland's suggested ten
167 /// outer iterations.
168 pub fn robust(mut self) -> Self {
169 self.outer_iter = 10;
170 self
171 }
172
173 /// Sets the number of outer (robustness) iterations explicitly.
174 pub fn with_outer_iterations(mut self, n: usize) -> Self {
175 self.outer_iter = n;
176 self
177 }
178
179 /// Cleveland et al. (1990) §3.4: `n_t` is the smallest odd integer at
180 /// least `1.5 * period / (1 - 1.5 / n_s)`. The ratio to `n_s` is the
181 /// point — a trend smoother chosen independently of the seasonal one
182 /// lets the two compete for the same variation, and the paper's rule is
183 /// what keeps the trend from absorbing the seasonal cycle.
184 fn effective_trend_window(&self) -> usize {
185 let w = self.trend_window.unwrap_or_else(|| {
186 let n_s = self.effective_seasonal_window() as f64;
187 let raw = 1.5 * self.period as f64 / (1.0 - 1.5 / n_s);
188 raw.ceil() as usize
189 });
190 next_odd_at_least(w, 3)
191 }
192
193 /// Cleveland et al. (1990) §3.3: `n_s` is in units of **cycles**, not of
194 /// samples — the seasonal smoother runs along a cycle-subseries, which
195 /// holds one point per cycle, so a series of `n` points offers it only
196 /// `n / period` of them. The paper's fixed default of 7 is what R's
197 /// `stl(s.window = "periodic")` and statsmodels' `STL(seasonal = 7)`
198 /// both use.
199 ///
200 /// This used to default to `max(7, period)`, which reads the window in
201 /// the *series'* units: for hourly data with a daily cycle it asked for
202 /// a 25-point smoother over a subseries holding one point per day, so
203 /// every deployment with fewer than `period` cycles of history got a
204 /// smoother wider than its own data.
205 fn effective_seasonal_window(&self) -> usize {
206 next_odd_at_least(self.seasonal_window.unwrap_or(7), 7)
207 }
208
209 /// Cleveland et al. (1990) §3.5: `n_l` is the smallest odd integer at
210 /// least `period`.
211 fn effective_low_pass_window(&self) -> usize {
212 next_odd_at_least(self.low_pass_window.unwrap_or(self.period), 3)
213 }
214}
215
216/// The smallest odd integer that is at least `w` and at least `floor`.
217fn next_odd_at_least(w: usize, floor: usize) -> usize {
218 let w = w.max(floor);
219 if w.is_multiple_of(2) {
220 w + 1
221 } else {
222 w
223 }
224}
225
226/// Perform STL decomposition.
227pub fn stl_decompose(
228 values: &[f64],
229 config: &StlConfig,
230) -> Result<Decomposition, DecompositionError> {
231 let n = values.len();
232 let period = config.period;
233
234 if period < 2 {
235 return Err(DecompositionError::InvalidPeriod(period));
236 }
237 if n < 2 * period {
238 return Err(DecompositionError::TooShort { len: n, period });
239 }
240
241 let trend_w = config.effective_trend_window();
242 let seasonal_w = config.effective_seasonal_window();
243 let low_pass_w = config.effective_low_pass_window();
244
245 let mut seasonal = vec![0.0; n];
246 let mut trend = vec![0.0; n];
247 // Robustness weights, all 1.0 until an outer pass computes them. `None`
248 // is the plain inner loop and costs nothing.
249 let mut weights: Option<Vec<f64>> = None;
250
251 // The inner loop of Cleveland et al. (1990), steps 1–6, wrapped in the
252 // outer loop of §4.3 when `outer_iter > 0`.
253 //
254 // Step 2 is the one that carries the algorithm's shape: each
255 // cycle-subseries is smoothed *and extended one cycle beyond either
256 // end*, so the low-pass filter in step 3 can run in valid mode and
257 // still produce a value for every input point. The extension is the
258 // loess fit evaluated at −1 and at `len`, which continues the local
259 // trend. Padding by repeating the first and last cycle instead — which
260 // is what stood here — is correct only for a series that is already
261 // periodic: on a ramp the copy meets the original at a discontinuity
262 // the moving averages then smear across the first and last period. A
263 // straight line, whose seasonal component is exactly zero, came out of
264 // it with a seasonal swing of 278 on values running 1_000 to 11_619,
265 // and `seasonal_strength` read that as a seasonal series.
266 for outer in 0..=config.outer_iter {
267 for _ in 0..config.n_iter {
268 // Step 1: detrend.
269 let detrended: Vec<f64> = values
270 .iter()
271 .zip(trend.iter())
272 .map(|(y, t)| y - t)
273 .collect();
274
275 // Step 2: cycle-subseries smoothing, extended by one cycle at
276 // each end. `cycle_ext[period + i]` is the smoothed value for
277 // `i`. A robustness weight travels with its point into the
278 // subseries, which is the whole mechanism: an outlier lands in
279 // the same season of one cycle, so down-weighting it there is
280 // what stops it being spread across every cycle.
281 let mut cycle_ext = vec![0.0; n + 2 * period];
282 for s in 0..period {
283 let idx: Vec<usize> = (s..n).step_by(period).collect();
284 let sub: Vec<f64> = idx.iter().map(|&i| detrended[i]).collect();
285 let sub_w = weights
286 .as_ref()
287 .map(|w| idx.iter().map(|&i| w[i]).collect::<Vec<f64>>());
288 // `extended[0]` is the fit at −1, `extended[k + 1]` at `k`.
289 let extended = loess_extended(&sub, seasonal_w, sub_w.as_deref());
290 for (k, &v) in extended.iter().enumerate() {
291 // Subseries position `k - 1` sits at series index
292 // `s + (k - 1) * period`, i.e. `s + k * period` once the
293 // frame is shifted by one cycle.
294 let at = s + k * period;
295 if at < cycle_ext.len() {
296 cycle_ext[at] = v;
297 }
298 }
299 }
300
301 // Step 3: low-pass filter — MA(period), MA(period), MA(3), then
302 // a loess of width `n_l`. Each moving average runs in valid
303 // mode, so the three together consume exactly the `2 * period`
304 // points the extension added and the result is `n` long by
305 // construction.
306 let low_pass = low_pass_filter(&cycle_ext, period, low_pass_w);
307 debug_assert_eq!(low_pass.len(), n);
308
309 // Step 4: the seasonal is what the low-pass filter removed.
310 for i in 0..n {
311 seasonal[i] = cycle_ext[period + i] - low_pass[i];
312 }
313
314 // Steps 5–6: deseasonalise and smooth the trend.
315 let deseasoned: Vec<f64> = values
316 .iter()
317 .zip(seasonal.iter())
318 .map(|(y, s)| y - s)
319 .collect();
320 trend = loess_smooth_weighted(&deseasoned, trend_w, weights.as_deref());
321 }
322
323 // The outer loop of §4.3: reweight by how far each point fell from
324 // the fit, then run the inner loop again. Skipped after the last
325 // pass, whose weights nothing would use.
326 if outer < config.outer_iter {
327 weights = Some(robustness_weights(values, &trend, &seasonal));
328 }
329 }
330
331 // Residual
332 let residual: Vec<f64> = values
333 .iter()
334 .zip(trend.iter())
335 .zip(seasonal.iter())
336 .map(|((y, t), s)| y - t - s)
337 .collect();
338
339 Ok(Decomposition {
340 trend,
341 seasonal,
342 residual,
343 })
344}
345
346/// Detect the dominant period using autocorrelation peak finding.
347///
348/// Returns `None` if no clear period is found or the series is too short.
349///
350/// # ACF Threshold
351///
352/// The series is **detrended** first, then the autocorrelation is searched
353/// for **local maxima** — the two together are what make this a period
354/// detector rather than a smoothness detector.
355///
356/// The minimum ACF peak value is hardcoded to **0.3** here, which suits the
357/// pronounced cycles of typical TSDB workloads. Use
358/// [`detect_period_with_threshold`] for a noisy signal (a lower threshold
359/// trades precision for recall) or a very clean one (a higher threshold
360/// rejects incidental peaks).
361pub fn detect_period(values: &[f64], max_period: usize) -> Option<usize> {
362 detect_period_with_threshold(values, max_period, 0.3)
363}
364
365/// Detect the dominant period using autocorrelation peak finding with
366/// a caller-specified minimum ACF threshold.
367///
368/// `min_acf_threshold` controls how strong the autocorrelation peak
369/// must be to accept a period:
370/// Lower values (e.g. 0.1) improve recall for weak/noisy signals
371/// at the cost of more false positives.
372/// Higher values (e.g. 0.5) require a strong seasonal pattern,
373/// avoiding spurious detections.
374///
375/// Returns `None` if no clear period is found or the series is too short.
376pub fn detect_period_with_threshold(
377 values: &[f64],
378 max_period: usize,
379 min_acf_threshold: f64,
380) -> Option<usize> {
381 let n = values.len();
382 if n < 4 {
383 return None;
384 }
385
386 // Detrend before correlating. A trended series' autocorrelation is
387 // dominated by the trend and decays from lag 1, so the largest value sits
388 // at the smallest lag and the seasonal peak is invisible underneath it.
389 // Removing the least-squares line leaves the periodic component.
390 let residual = detrend(values);
391
392 let mean = residual.iter().sum::<f64>() / n as f64;
393 let var: f64 = residual.iter().map(|v| (v - mean).powi(2)).sum();
394 if var < f64::EPSILON {
395 return None;
396 }
397
398 let max_lag = max_period.min(n / 2);
399 if max_lag < 3 {
400 return None;
401 }
402
403 // Biased ACF estimator (divide by the total variance rather than by
404 // `n - lag`): it tapers naturally at long lags, which is what stops a
405 // harmonic multiple 2P from outscoring the fundamental P.
406 let acf: Vec<f64> = (0..=max_lag)
407 .map(|lag| {
408 let s: f64 = (0..(n - lag))
409 .map(|i| (residual[i] - mean) * (residual[i + lag] - mean))
410 .sum();
411 s / var
412 })
413 .collect();
414
415 // A period is a **local maximum** of the autocorrelation, not its largest
416 // value: a smooth series is most correlated with its immediate neighbour,
417 // which is a statement about smoothness rather than seasonality.
418 let mut best: Option<(usize, f64)> = None;
419 for lag in 2..max_lag {
420 let (prev, here, next) = (acf[lag - 1], acf[lag], acf[lag + 1]);
421 if here > prev && here >= next && here > min_acf_threshold {
422 // Ties go to the shorter lag: the fundamental, not its harmonic.
423 if best.is_none_or(|(_, v)| here > v) {
424 best = Some((lag, here));
425 }
426 }
427 }
428
429 best.map(|(lag, _)| lag)
430}
431
432/// Remove the least-squares linear trend from a series.
433fn detrend(values: &[f64]) -> Vec<f64> {
434 let n = values.len();
435 if n < 2 {
436 return values.to_vec();
437 }
438 let n_f = n as f64;
439 let mean_x = (n_f - 1.0) / 2.0;
440 let mean_y = values.iter().sum::<f64>() / n_f;
441 let mut sxx = 0.0;
442 let mut sxy = 0.0;
443 for (i, &y) in values.iter().enumerate() {
444 let dx = i as f64 - mean_x;
445 sxx += dx * dx;
446 sxy += dx * (y - mean_y);
447 }
448 if sxx <= 0.0 {
449 return values.iter().map(|v| v - mean_y).collect();
450 }
451 let slope = sxy / sxx;
452 values
453 .iter()
454 .enumerate()
455 .map(|(i, &y)| y - (mean_y + slope * (i as f64 - mean_x)))
456 .collect()
457}
458
459/// Locally weighted regression (Loess) smoother with tricube
460/// kernel (Cleveland 1990).
461///
462/// Fits a degree-1 (linear) polynomial at each point using weighted
463/// least squares. The tricube kernel `w(u) = (1 - |u|³)³` for `|u| < 1`
464/// downweights points far from the center, producing better edge
465/// behaviour and outlier resistance than a uniform moving average.
466/// The low-pass filter of STL step 3: pad by one cycle at each end with the
467/// adjacent cycle, apply a `period`-point centred moving average (a 2×MA for
468/// The low-pass filter of STL step 3: `MA(period)`, `MA(period)`, `MA(3)`,
469/// then a degree-1 loess of width `n_l`.
470///
471/// Every moving average runs in **valid** mode — an output point only where
472/// the whole window lies inside the input — so the three of them consume
473/// exactly `(period - 1) + (period - 1) + 2 = 2 * period` points. That is
474/// precisely what the cycle-subseries extension added, which is why the
475/// result is `n` long without any edge special-casing. A partial window at
476/// the ends, which is what `centered_moving_average` used to supply, is an
477/// average of fewer points presented as if it were an average of the whole
478/// window.
479fn low_pass_filter(cycle_ext: &[f64], period: usize, low_pass_window: usize) -> Vec<f64> {
480 let a = moving_average_valid(cycle_ext, period);
481 let b = moving_average_valid(&a, period);
482 let c = moving_average_valid(&b, 3);
483 loess_smooth(&c, low_pass_window)
484}
485
486/// A moving average of `w` points, valid mode: the output is
487/// `x.len() - w + 1` long and each point averages a full window.
488fn moving_average_valid(x: &[f64], w: usize) -> Vec<f64> {
489 if w <= 1 {
490 return x.to_vec();
491 }
492 if x.len() < w {
493 return Vec::new();
494 }
495 let wf = w as f64;
496 let mut out = Vec::with_capacity(x.len() - w + 1);
497 let mut sum: f64 = x[..w].iter().sum();
498 out.push(sum / wf);
499 for i in w..x.len() {
500 sum += x[i] - x[i - w];
501 out.push(sum / wf);
502 }
503 out
504}
505
506/// Degree-1 loess evaluated at one point `x`, which may lie outside
507/// `0..values.len()`.
508///
509/// The neighbourhood is the `q = min(window, n)` nearest points and the
510/// bandwidth is the distance to the farthest of them, scaled by `window / n`
511/// when the window is wider than the data — Cleveland's rule for `q > n`,
512/// which is what lets a smoother wider than its series still express a
513/// slope instead of collapsing to the mean.
514fn loess_at(values: &[f64], window: usize, x: f64, robust: Option<&[f64]>) -> f64 {
515 let n = values.len();
516 if n == 0 {
517 return f64::NAN;
518 }
519 if n == 1 {
520 return values[0];
521 }
522 let q = window.max(2).min(n);
523 // On a regular grid the `q` nearest points are contiguous.
524 let lo = (x - (q as f64 - 1.0) / 2.0)
525 .round()
526 .clamp(0.0, (n - q) as f64) as usize;
527 let hi = lo + q - 1;
528
529 let mut lambda = ((lo as f64 - x).abs()).max((hi as f64 - x).abs());
530 if window > n {
531 lambda *= window as f64 / n as f64;
532 }
533
534 let (mut sw, mut swx, mut swy, mut swxx, mut swxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
535 for j in lo..=hi {
536 let d = (j as f64 - x).abs();
537 let mut w = if lambda <= 0.0 {
538 f64::from(u8::from(d == 0.0))
539 } else {
540 let u = (d / lambda).min(1.0);
541 let t = 1.0 - u * u * u;
542 t * t * t
543 };
544 // The robustness weight multiplies the neighbourhood weight, which
545 // is exactly how Cleveland et al. (1990) §4.3 fold the outer loop
546 // into the same smoother rather than adding a second one.
547 if let Some(rw) = robust {
548 w *= rw[j];
549 }
550 if w <= 0.0 {
551 continue;
552 }
553 let (xj, yj) = (j as f64, values[j]);
554 sw += w;
555 swx += w * xj;
556 swy += w * yj;
557 swxx += w * xj * xj;
558 swxy += w * xj * yj;
559 }
560 if sw <= 0.0 {
561 // Every weight in the neighbourhood vanished. With robustness
562 // weights that is a real situation — a run of points next to a spike
563 // can all be down-weighted at once — and the answer must not be the
564 // raw observation: at the outlier itself that returns the outlier,
565 // which is the least robust value available and made the fit
566 // oscillate between absorbing the spike and rejecting it. The
567 // unweighted fit is the best estimate that remains.
568 if robust.is_some() {
569 return loess_at(values, window, x, None);
570 }
571 return values[(x.round().clamp(0.0, (n - 1) as f64)) as usize];
572 }
573 let det = sw * swxx - swx * swx;
574 if det.abs() < 1e-12 * sw * sw.max(1.0) {
575 // A single distinct abscissa carries no slope; the weighted mean is
576 // the degree-0 fit, which is the right answer only here.
577 return swy / sw;
578 }
579 let a = (swxx * swy - swx * swxy) / det;
580 let b = (sw * swxy - swx * swy) / det;
581 a + b * x
582}
583
584/// Degree-1 loess at every point of `values`.
585fn loess_smooth(values: &[f64], window: usize) -> Vec<f64> {
586 loess_smooth_weighted(values, window, None)
587}
588
589/// [`loess_smooth`] with an additional per-point robustness weight.
590fn loess_smooth_weighted(values: &[f64], window: usize, robust: Option<&[f64]>) -> Vec<f64> {
591 if window == 0 {
592 return values.to_vec();
593 }
594 (0..values.len())
595 .map(|i| loess_at(values, window, i as f64, robust))
596 .collect()
597}
598
599/// Cleveland's bisquare weights: `(1 - (r / 6m)^2)^2` clamped to `[0, 1]`,
600/// where `r` is a point's absolute residual and `m` their median.
601///
602/// Six times the median absolute residual is the paper's cut-off, and it is
603/// the reason a single spike cannot survive an outer pass: its weight goes to
604/// zero while an ordinary point's stays near one.
605///
606/// The cut-off is **floored at a thousandth of the series' range**, which the
607/// paper does not need to say and an implementation does. `6m` describes the
608/// spread of the residuals and means nothing when they have none: a series
609/// the inner loop fits almost exactly leaves rounding error, so `m` is
610/// rounding error too and everything above it counts as an outlier — on a
611/// clean sine with one spike, a median of 0.0047 against a 99th percentile of
612/// 47 gave 101 of 240 points weight zero.
613///
614/// The floor asks what the median cannot: is this residual large *relative to
615/// the series*? It sits far below any real noise level, so `6m` stays in
616/// charge wherever the residuals carry noise — measured at noise 0.5, 2 and 5
617/// on a series of range 260, where it never binds.
618fn robustness_weights(values: &[f64], trend: &[f64], seasonal: &[f64]) -> Vec<f64> {
619 let resid: Vec<f64> = values
620 .iter()
621 .zip(trend)
622 .zip(seasonal)
623 .map(|((y, t), s)| (y - t - s).abs())
624 .collect();
625
626 let mut sorted = resid.clone();
627 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
628 let mid = sorted.len() / 2;
629 let median = if sorted.is_empty() {
630 0.0
631 } else if sorted.len().is_multiple_of(2) {
632 f64::midpoint(sorted[mid - 1], sorted[mid])
633 } else {
634 sorted[mid]
635 };
636
637 let (lo, hi) = values
638 .iter()
639 .fold((f64::MAX, f64::MIN), |(l, h), &v| (l.min(v), h.max(v)));
640 let scale_floor = if hi > lo { (hi - lo) * 1e-3 } else { 0.0 };
641 let cutoff = (6.0 * median).max(scale_floor);
642 if cutoff <= 0.0 {
643 return vec![1.0; resid.len()];
644 }
645 resid
646 .iter()
647 .map(|r| {
648 let u = (r / cutoff).min(1.0);
649 let t = 1.0 - u * u;
650 t * t
651 })
652 .collect()
653}
654
655/// Degree-1 loess at `-1 ..= values.len()`, so the result is `len + 2` long.
656///
657/// STL step 2 needs the smoother to say what the cycle before the first and
658/// the cycle after the last would have been; a loess can answer that because
659/// it is a local *fit*, and evaluating it one step outside its data is the
660/// extension Cleveland et al. (1990) specify.
661fn loess_extended(values: &[f64], window: usize, robust: Option<&[f64]>) -> Vec<f64> {
662 let n = values.len();
663 (0..n + 2)
664 .map(|k| loess_at(values, window, k as f64 - 1.0, robust))
665 .collect()
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671
672 fn approx(a: f64, b: f64, eps: f64) -> bool {
673 (a - b).abs() < eps
674 }
675
676 /// `y = 0.5·i + 10·sin(2πi/12)`: the seasonal component must *be* the
677 /// sine and the trend must be the line. The previous version of this
678 /// test asserted only that the three components summed to the input,
679 /// which the residual guarantees by definition — and the seasonal
680 /// component it accepted was identically zero.
681 #[test]
682 fn stl_recovers_the_trend_and_the_seasonal_pattern() {
683 let n = 120;
684 let period = 12;
685 let seasonal_of = |i: usize| 10.0 * (2.0 * std::f64::consts::PI * i as f64 / 12.0).sin();
686 let values: Vec<f64> = (0..n).map(|i| 0.5 * i as f64 + seasonal_of(i)).collect();
687
688 let dec = stl_decompose(&values, &StlConfig::new(period).with_iterations(5)).unwrap();
689
690 let amplitude = dec.seasonal.iter().copied().fold(f64::MIN, f64::max)
691 - dec.seasonal.iter().copied().fold(f64::MAX, f64::min);
692 assert!(
693 amplitude > 18.0,
694 "seasonal amplitude collapsed: {amplitude}"
695 );
696
697 // Away from the ends the recovery is tight; the ends see a shorter
698 // smoother window and are allowed a little more.
699 for i in period..n - period {
700 assert!(
701 approx(dec.seasonal[i], seasonal_of(i), 0.6),
702 "seasonal at {i}: {} vs {}",
703 dec.seasonal[i],
704 seasonal_of(i)
705 );
706 assert!(
707 approx(dec.trend[i], 0.5 * i as f64, 0.6),
708 "trend at {i}: {} vs {}",
709 dec.trend[i],
710 0.5 * i as f64
711 );
712 assert!(
713 dec.residual[i].abs() < 0.6,
714 "residual at {i}: {}",
715 dec.residual[i]
716 );
717 }
718 for i in 0..n {
719 let recon = dec.trend[i] + dec.seasonal[i] + dec.residual[i];
720 assert!(approx(recon, values[i], 1e-9));
721 }
722 }
723
724 /// An even period is centred with the classic 2×m moving average.
725 #[test]
726 fn stl_handles_an_even_period_without_bias() {
727 let n = 96;
728 let period = 24;
729 let seasonal_of = |i: usize| 5.0 * (2.0 * std::f64::consts::PI * i as f64 / 24.0).sin();
730 let values: Vec<f64> = (0..n).map(|i| 100.0 + seasonal_of(i)).collect();
731
732 let dec = stl_decompose(&values, &StlConfig::new(period).with_iterations(3)).unwrap();
733 for i in period..n - period {
734 assert!(
735 approx(dec.seasonal[i], seasonal_of(i), 0.4),
736 "seasonal at {i}"
737 );
738 assert!(
739 approx(dec.trend[i], 100.0, 0.4),
740 "trend at {i}: {}",
741 dec.trend[i]
742 );
743 }
744 let max_residual = dec.residual.iter().map(|r| r.abs()).fold(0.0_f64, f64::max);
745 assert!(max_residual < 0.8, "residual too large: {max_residual}");
746 }
747
748 /// A straight line has no seasonal component, and STL has to say so.
749 ///
750 /// This is the cheapest test in the file and the one that would have
751 /// caught the padding defect: the old low-pass filter padded by copying
752 /// the first and last cycle, which on a ramp butts the copy against the
753 /// original at a discontinuity, and the moving averages smeared it into
754 /// the first and last period. The seasonal component of `1000 + 37 i`
755 /// swung by 278.
756 ///
757 /// A monotonic counter is the commonest series in a metrics database, so
758 /// this is not a corner: `seasonal_strength` read that swing as 0.81
759 /// against a 0.64 threshold and auto-ARIMA seasonally differenced every
760 /// counter it was shown.
761 #[test]
762 fn a_straight_line_has_no_seasonal_component() {
763 for period in [4usize, 7, 12, 24] {
764 for cycles in [2usize, 3, 7, 12, 30] {
765 let n = period * cycles;
766 let y: Vec<f64> = (0..n).map(|i| 1000.0 + 37.0 * i as f64).collect();
767 let d = stl_decompose(&y, &StlConfig::new(period)).unwrap();
768 let swing = d.seasonal.iter().fold(0.0f64, |a, b| a.max(b.abs()));
769 assert!(
770 swing < 1e-6,
771 "period {period}, {cycles} cycles: seasonal swing {swing} on a straight line"
772 );
773 let resid = d.residual.iter().fold(0.0f64, |a, b| a.max(b.abs()));
774 assert!(
775 resid < 1e-6,
776 "period {period}, {cycles} cycles: residual {resid}"
777 );
778 }
779 }
780 }
781
782 /// The seasonal amplitude that comes out is the one that went in.
783 ///
784 /// Checked across the number of *cycles*, which is the axis that broke:
785 /// the seasonal smoother runs along a cycle-subseries holding one point
786 /// per cycle, and its window used to default to `max(7, period)` — read
787 /// in the series' units rather than in cycles. Seven days of hourly data
788 /// with a daily cycle therefore asked for a 25-point smoother over a
789 /// 7-point subseries, whereupon `loess_smooth` returned the subseries
790 /// mean and 22 % of the amplitude went into the trend.
791 ///
792 /// The values match statsmodels' `STL` to three decimals on every row of
793 /// this table; where a genuine edge effect remains — `period = 7` over
794 /// 10 cycles — statsmodels reports the same 11.699.
795 #[test]
796 fn stl_recovers_the_seasonal_amplitude_from_few_cycles() {
797 use std::f64::consts::TAU;
798 for &(period, n, expected) in &[
799 (12usize, 96usize, 12.000f64),
800 (12, 240, 12.000),
801 (24, 168, 12.000),
802 (24, 720, 12.000),
803 (7, 70, 11.699),
804 ] {
805 let y: Vec<f64> = (0..n)
806 .map(|i| 100.0 + 0.7 * i as f64 + 12.0 * (i as f64 * TAU / period as f64).sin())
807 .collect();
808 let d = stl_decompose(&y, &StlConfig::new(period)).unwrap();
809 let mid = &d.seasonal[n / 4..3 * n / 4];
810 let amp = (mid.iter().copied().fold(f64::MIN, f64::max)
811 - mid.iter().copied().fold(f64::MAX, f64::min))
812 / 2.0;
813 assert!(
814 (amp - expected).abs() < 0.01,
815 "period {period}, n {n}: amplitude {amp:.4}, expected {expected}"
816 );
817 }
818 }
819
820 /// A seasonal pattern whose amplitude grows is the reason to run STL
821 /// rather than subtract per-season means, so it is asserted rather than
822 /// assumed.
823 #[test]
824 fn stl_follows_a_seasonal_pattern_that_changes() {
825 use std::f64::consts::TAU;
826 let (period, n) = (12usize, 240usize);
827 let y: Vec<f64> = (0..n)
828 .map(|i| {
829 let amp = 5.0 + 15.0 * i as f64 / n as f64;
830 100.0 + amp * (i as f64 * TAU / period as f64).sin()
831 })
832 .collect();
833 let d = stl_decompose(&y, &StlConfig::new(period)).unwrap();
834 let amp_of = |s: &[f64]| {
835 (s.iter().copied().fold(f64::MIN, f64::max)
836 - s.iter().copied().fold(f64::MAX, f64::min))
837 / 2.0
838 };
839 let early = amp_of(&d.seasonal[12..36]);
840 let late = amp_of(&d.seasonal[n - 36..n - 12]);
841 assert!(
842 (early - 6.5).abs() < 0.6,
843 "early amplitude {early}, expected ~6.5"
844 );
845 assert!(
846 (late - 18.5).abs() < 0.6,
847 "late amplitude {late}, expected ~18.5"
848 );
849 assert!(
850 late > early * 2.0,
851 "the growth was not followed: {early} → {late}"
852 );
853 }
854
855 /// An outlier is left in the residual instead of being spread across the
856 /// seasonal component.
857 ///
858 /// This is the case for having the outer loop at all, and it is ordinary
859 /// rather than exotic in a metrics database: a restart, a scrape that
860 /// timed out and was backfilled as one enormous sample, a sensor
861 /// returning its error sentinel. Without robustness weights the
862 /// cycle-subseries smoother spreads that one point across **every** cycle
863 /// — it lands in the same season of each — and the seasonal amplitude of
864 /// a 10-unit sine came back as 33.7.
865 ///
866 /// Checked at three noise levels and at none, because the two regimes
867 /// exercise different halves of the weighting: with noise the median
868 /// absolute residual is a real scale and Cleveland's `6m` governs; with
869 /// none it is rounding error and the floor in `robustness_weights` does.
870 #[test]
871 fn a_robust_fit_leaves_a_spike_in_the_residual() {
872 use std::f64::consts::TAU;
873 let (period, n) = (12usize, 240usize);
874 let mut seed = 4u64;
875 let mut gauss = || {
876 let mut u = || {
877 seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
878 ((seed >> 11) as f64 / (1u64 << 53) as f64).max(1e-12)
879 };
880 let (a, b) = (u(), u());
881 (-2.0 * a.ln()).sqrt() * (TAU * b).cos()
882 };
883
884 for noise in [0.0f64, 0.5, 2.0, 5.0] {
885 let clean: Vec<f64> = (0..n)
886 .map(|i| {
887 100.0
888 + 0.5 * i as f64
889 + 10.0 * (i as f64 * TAU / period as f64).sin()
890 + gauss() * noise
891 })
892 .collect();
893 let base = stl_decompose(&clean, &StlConfig::new(period)).unwrap();
894
895 let mut spiked = clean.clone();
896 spiked[120] += 200.0;
897 let plain = stl_decompose(&spiked, &StlConfig::new(period)).unwrap();
898 let robust = stl_decompose(&spiked, &StlConfig::new(period).robust()).unwrap();
899
900 let deviation = |d: &Decomposition| {
901 (0..n)
902 .filter(|&i| i != 120)
903 .map(|i| (d.seasonal[i] - base.seasonal[i]).abs())
904 .fold(0.0f64, f64::max)
905 };
906 let (p, r) = (deviation(&plain), deviation(&robust));
907 assert!(
908 r < p / 4.0,
909 "noise {noise}: robust deviation {r:.3} is not much better than plain {p:.3}"
910 );
911 // The spike belongs to the residual, which is where an
912 // unexplained observation goes.
913 assert!(
914 robust.residual[120] > 150.0,
915 "noise {noise}: the spike was absorbed — residual {:.3} of 200",
916 robust.residual[120]
917 );
918 }
919 }
920
921 /// With no outliers to down-weight, the robust fit is the plain fit.
922 ///
923 /// Not a formality: the first version of the weighting zeroed 101 of 240
924 /// points on a series it fitted exactly, because the median absolute
925 /// residual was rounding error and everything above it counted as an
926 /// outlier, and the seasonal component moved by 3.7 on an amplitude of
927 /// 10.
928 ///
929 /// The tolerance is not zero because the outer loop runs the inner loop
930 /// eleven times rather than once, so an all-ones weighting still
931 /// converges a little further; the difference is at 1e-13 on values near
932 /// 200, which is the arithmetic and not the weights.
933 #[test]
934 fn robustness_changes_nothing_without_outliers() {
935 use std::f64::consts::TAU;
936 let (period, n) = (12usize, 240usize);
937 let y: Vec<f64> = (0..n)
938 .map(|i| 100.0 + 0.5 * i as f64 + 10.0 * (i as f64 * TAU / period as f64).sin())
939 .collect();
940 let plain = stl_decompose(&y, &StlConfig::new(period)).unwrap();
941 let robust = stl_decompose(&y, &StlConfig::new(period).robust()).unwrap();
942 let worst = (0..n)
943 .map(|i| (plain.seasonal[i] - robust.seasonal[i]).abs())
944 .fold(0.0f64, f64::max);
945 assert!(
946 worst < 1e-9,
947 "robustness moved the seasonal component by {worst:e} with nothing to down-weight"
948 );
949 }
950
951 #[test]
952 fn test_stl_sum_equals_original() {
953 let n = 200;
954 let period = 10;
955 let values: Vec<f64> = (0..n)
956 .map(|i| (i as f64 * 0.1).sin() + 0.01 * i as f64)
957 .collect();
958
959 let dec = stl_decompose(&values, &StlConfig::new(period)).unwrap();
960 for i in 0..n {
961 let recon = dec.trend[i] + dec.seasonal[i] + dec.residual[i];
962 assert!(approx(recon, values[i], 1e-12));
963 }
964 }
965
966 #[test]
967 fn test_stl_too_short() {
968 let values = vec![1.0; 10];
969 let err = stl_decompose(&values, &StlConfig::new(8)).unwrap_err();
970 assert!(matches!(err, DecompositionError::TooShort { .. }));
971 }
972
973 #[test]
974 fn test_stl_invalid_period() {
975 let values = vec![1.0; 100];
976 let err = stl_decompose(&values, &StlConfig::new(1)).unwrap_err();
977 assert!(matches!(err, DecompositionError::InvalidPeriod(1)));
978 }
979
980 #[test]
981 fn test_detect_period_sinusoidal() {
982 let n = 240;
983 let true_period = 24;
984 let values: Vec<f64> = (0..n)
985 .map(|i| (2.0 * std::f64::consts::PI * i as f64 / true_period as f64).sin())
986 .collect();
987
988 let detected = detect_period(&values, 48);
989 assert_eq!(detected, Some(true_period));
990 }
991
992 #[test]
993 fn test_detect_period_constant_returns_none() {
994 let values = vec![42.0; 100];
995 assert_eq!(detect_period(&values, 50), None);
996 }
997
998 #[test]
999 fn test_loess_smooth_identity() {
1000 // Single-point bandwidth: each point is its own neighborhood → output ≈ input
1001 let v = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1002 let smoothed = loess_smooth(&v, 1);
1003 for (a, b) in smoothed.iter().zip(v.iter()) {
1004 assert!(approx(*a, *b, 1e-9));
1005 }
1006 }
1007
1008 #[test]
1009 fn test_loess_smooth_smoothing() {
1010 // With a wider bandwidth the output should be smoother
1011 let v = vec![1.0, 3.0, 5.0, 7.0, 9.0];
1012 let smoothed = loess_smooth(&v, 3);
1013 // For a perfectly linear sequence, Loess (degree 1) should recover
1014 // the exact values regardless of bandwidth.
1015 for (a, b) in smoothed.iter().zip(v.iter()) {
1016 assert!(approx(*a, *b, 1e-6));
1017 }
1018 }
1019
1020 #[test]
1021 fn test_seasonal_decomposer_trait() {
1022 let decomposer = StlDecomposer::default();
1023 let n = 200;
1024 let period = 10;
1025 let timestamps: Vec<i64> = (0..n as i64).collect();
1026 let values: Vec<f64> = (0..n)
1027 .map(|i| (i as f64 * 0.1).sin() + 0.01 * i as f64)
1028 .collect();
1029
1030 let dec = decomposer.decompose(×tamps, &values, period).unwrap();
1031 assert_eq!(dec.trend.len(), n);
1032 assert_eq!(dec.seasonal.len(), n);
1033 assert_eq!(dec.residual.len(), n);
1034 // Components should sum to original
1035 for i in 0..n {
1036 let recon = dec.trend[i] + dec.seasonal[i] + dec.residual[i];
1037 assert!(approx(recon, values[i], 1e-12));
1038 }
1039 }
1040
1041 #[test]
1042 fn test_seasonal_decomposer_trait_too_short() {
1043 let decomposer = StlDecomposer::default();
1044 let err = decomposer.decompose(&[0, 1], &[1.0, 2.0], 5).unwrap_err();
1045 assert!(matches!(err, DecompositionError::TooShort { .. }));
1046 }
1047
1048 #[test]
1049 fn test_detect_period_with_threshold_controls_sensitivity() {
1050 // Build a noisy signal with a buried periodic component.
1051 // Signal amplitude tuned so ACF peak falls between the two
1052 // thresholds (0.1 and 0.95).
1053 let n = 120;
1054 let true_period = 24;
1055 let values: Vec<f64> = (0..n)
1056 .map(|i: usize| {
1057 let t = i as f64;
1058 let signal = (2.0 * std::f64::consts::PI * t / true_period as f64).sin();
1059 // splitmix64-style deterministic hash → uniform in [-1, 1]
1060 let mut h = i as u64;
1061 h = h.wrapping_mul(0x9E3779B97F4A7C15);
1062 h = (h ^ (h >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
1063 h = (h ^ (h >> 27)).wrapping_mul(0x94D049BB133111EB);
1064 h = h ^ (h >> 31);
1065 let noise = (h % 10000) as f64 / 5000.0 - 1.0;
1066 signal + noise * 3.0
1067 })
1068 .collect();
1069
1070 // With a low threshold the weak period should be detected.
1071 let detected_low = detect_period_with_threshold(&values, 48, 0.1);
1072 assert!(
1073 detected_low.is_some(),
1074 "low threshold (0.1) should detect the weak periodicity",
1075 );
1076
1077 // With a high threshold the same signal should be rejected.
1078 let detected_high = detect_period_with_threshold(&values, 48, 0.95);
1079 assert_eq!(
1080 detected_high, None,
1081 "high threshold (0.95) should reject the weak periodicity",
1082 );
1083 }
1084
1085 /// Why the series is detrended first: the autocorrelation of
1086 /// `200 + 0.5·t + 40·sin(2πt/24)` is dominated by the trend and decays
1087 /// from lag 1, so its largest value is 2 — smoothness, not seasonality.
1088 #[test]
1089 fn detect_period_sees_through_a_trend() {
1090 let n = 300;
1091 let period = 24;
1092 let values: Vec<f64> = (0..n)
1093 .map(|i| {
1094 let t = i as f64;
1095 200.0 + t * 0.5 + 40.0 * (2.0 * std::f64::consts::PI * t / period as f64).sin()
1096 })
1097 .collect();
1098 assert_eq!(detect_period(&values, 48), Some(period));
1099 }
1100
1101 /// A smooth series with no cycle must report no period rather than the
1102 /// shortest lag it can find.
1103 #[test]
1104 fn detect_period_rejects_a_pure_trend() {
1105 let values: Vec<f64> = (0..200).map(|i| 10.0 + i as f64 * 0.3).collect();
1106 assert_eq!(detect_period(&values, 48), None);
1107 }
1108
1109 /// The fundamental wins over its harmonics: a period-12 signal must not
1110 /// report 24, 36 or 48.
1111 #[test]
1112 fn detect_period_prefers_the_fundamental_over_a_harmonic() {
1113 let n = 480;
1114 let values: Vec<f64> = (0..n)
1115 .map(|i| {
1116 let t = i as f64;
1117 (2.0 * std::f64::consts::PI * t / 12.0).sin()
1118 + 0.4 * (2.0 * std::f64::consts::PI * t / 6.0).sin()
1119 })
1120 .collect();
1121 assert_eq!(detect_period(&values, 60), Some(12));
1122 }
1123
1124 #[test]
1125 fn test_detect_period_delegates_to_with_threshold() {
1126 // Verify that detect_period(v, m) == detect_period_with_threshold(v, m, 0.3).
1127 let n = 240;
1128 let values: Vec<f64> = (0..n)
1129 .map(|i| (2.0 * std::f64::consts::PI * i as f64 / 24.0).sin())
1130 .collect();
1131
1132 assert_eq!(
1133 detect_period(&values, 48),
1134 detect_period_with_threshold(&values, 48, 0.3),
1135 );
1136 }
1137}