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