u_analytics/seasonality.rs
1//! Seasonality: the periodogram of a univariate series and the estimation of
2//! its dominant period.
3//!
4//! Smoothing with [`crate::smoothing::HoltWinters`] and any seasonal
5//! decomposition need the period as an input. This module estimates it from
6//! the data by the two-stage procedure of AutoPeriod (Vlachos, Yu & Castelli
7//! 2005):
8//!
9//! 1. **Periodogram** — the series is linearly detrended, zero-padded to a
10//! power of two at least four times its length, and transformed with the
11//! FFT; the power at each frequency bin is `|X_k|² / n`. Padding
12//! interpolates the spectrum so that a period which does not divide the
13//! length still shows one peak at its true frequency rather than two
14//! half-height neighbours. A local maximum is a candidate when its power
15//! exceeds what the same
16//! values in random order produce: the series is permuted 100 times and
17//! the 99th percentile of the largest periodogram ordinate is the
18//! threshold (AutoPeriod's permutation test, deterministic here). A
19//! periodogram bin names a *frequency band*, not an integer period: bin
20//! `k` of a padded length `m` covers periods between `m/(k+1)` and
21//! `m/(k−1)`.
22//! 2. **Autocorrelation refinement** — within each candidate's band the
23//! autocorrelation function is searched for its hill top: the lag with
24//! the largest ACF that is a local maximum and, after correcting the
25//! biased estimator's `(n − lag)/n` shrinkage, exceeds the 95% white-noise
26//! bound `1.96/√n`. That lag is the integer period. A candidate whose band
27//! has no such hill is discarded — it was spectral leakage or a harmonic
28//! without its own structure.
29//!
30//! The dominant period is the validated candidate with the largest ACF; every
31//! validated candidate is returned so a caller can see harmonics and
32//! competing cycles. The ACF is the biased estimator (see
33//! [`crate::correlation::acf`]), which slightly favours shorter lags — the
34//! fundamental over its multiples.
35//!
36//! # References
37//! - Vlachos, M., Yu, P. & Castelli, V. (2005). "On periodicity detection and
38//! structural periodic similarity." *SIAM International Conference on Data
39//! Mining*, 449–460.
40//! - Box, G. E. P. & Jenkins, G. M. (1976). *Time Series Analysis: Forecasting
41//! and Control*, ch. 2 (autocorrelation).
42
43use u_numflow::fourier::rfft;
44use u_numflow::random::{create_rng, shuffle};
45
46use crate::correlation::acf;
47
48/// Fewest observations for which a period is estimated: two cycles of the
49/// longest admissible period (`n/2`) with a few bins to test against.
50pub const MIN_OBSERVATIONS: usize = 8;
51
52/// Permutations used to set the periodogram threshold.
53const PERMUTATIONS: usize = 100;
54
55/// The threshold is this quantile of the largest ordinate over permutations.
56const PERMUTATION_QUANTILE: f64 = 0.99;
57
58/// Seed of the permutation stream: the estimate is a deterministic function
59/// of the series.
60const PERMUTATION_SEED: u64 = 0x5EA5_0A11;
61
62/// How many periodogram peaks are carried into the autocorrelation stage.
63const MAX_CANDIDATES: usize = 3;
64
65/// Periodogram of a linearly detrended, zero-padded series.
66#[derive(Debug, Clone, PartialEq)]
67pub struct Periodogram {
68 /// Number of observations.
69 pub n: usize,
70 /// Padded transform length: the power of two at least `4 n`.
71 pub padded_len: usize,
72 /// Power `|X_k|² / n` at bins `k = 1..=padded_len/2`; `power[i]` is bin
73 /// `i + 1`.
74 pub power: Vec<f64>,
75 /// Frequency of each bin in cycles per observation, `k / padded_len`.
76 pub frequency: Vec<f64>,
77}
78
79impl Periodogram {
80 /// Period, in observations, of bin `k` (1-based): `padded_len / k`.
81 pub fn period_of_bin(&self, k: usize) -> f64 {
82 self.padded_len as f64 / k as f64
83 }
84
85 /// Bin (1-based) of the largest ordinate, or `None` for an all-zero
86 /// periodogram.
87 pub fn peak_bin(&self) -> Option<usize> {
88 let (i, &p) = self
89 .power
90 .iter()
91 .enumerate()
92 .max_by(|a, b| a.1.partial_cmp(b.1).expect("finite power"))?;
93 (p > 0.0).then_some(i + 1)
94 }
95}
96
97/// One periodicity that passed both stages.
98#[derive(Debug, Clone, PartialEq)]
99pub struct PeriodCandidate {
100 /// Integer period, in observations.
101 pub period: usize,
102 /// Autocorrelation at that lag, with the `(n − lag)/n` bias of the
103 /// estimator undone — the same value this candidate was selected and
104 /// accepted on, so it is comparable against
105 /// [`SeasonalityResult::acf_threshold`] and against other candidates.
106 pub acf: f64,
107 /// Periodogram bin (1-based) whose band produced this candidate.
108 pub bin: usize,
109 /// Periodogram power of that bin.
110 pub power: f64,
111 /// That bin's share of the total periodogram power.
112 pub power_share: f64,
113}
114
115/// Result of period estimation.
116#[derive(Debug, Clone, PartialEq)]
117pub struct SeasonalityResult {
118 /// The dominant period, or `None` when no periodicity passed both
119 /// stages — a constant, a pure trend, white noise.
120 pub period: Option<usize>,
121 /// Every validated periodicity, strongest first. Empty when `period` is
122 /// `None`.
123 pub candidates: Vec<PeriodCandidate>,
124 /// Number of observations.
125 pub n: usize,
126 /// The 95% white-noise bound on the ACF, `1.96 / √n`.
127 pub acf_threshold: f64,
128 /// Periodogram power a bin had to exceed to become a candidate: the 99th
129 /// percentile of the largest ordinate over 100 permutations of the series.
130 pub power_threshold: f64,
131}
132
133/// Periodogram of the series after linear detrending.
134///
135/// Returns `None` when the series has fewer than [`MIN_OBSERVATIONS`] points
136/// or a non-finite value. A constant series returns a periodogram of zeros.
137///
138/// # Examples
139///
140/// ```
141/// use u_analytics::seasonality::periodogram;
142///
143/// let series: Vec<f64> = (0..48).map(|t| (2.0 * std::f64::consts::PI * t as f64 / 12.0).sin()).collect();
144/// let p = periodogram(&series).unwrap();
145/// // Padded to 256: bin 256 / 12 ≈ 21.3, so the peak sits at bin 21.
146/// let peak = p.peak_bin().unwrap();
147/// assert_eq!(peak, 21);
148/// assert!((p.period_of_bin(peak) - 12.0).abs() < 0.3);
149/// ```
150pub fn periodogram(series: &[f64]) -> Option<Periodogram> {
151 let n = series.len();
152 if n < MIN_OBSERVATIONS || series.iter().any(|v| !v.is_finite()) {
153 return None;
154 }
155 let detrended = detrend(series);
156 let padded_len = padded_len(n);
157 let power = padded_power(&detrended, padded_len);
158 let frequency: Vec<f64> = (1..=padded_len / 2)
159 .map(|k| k as f64 / padded_len as f64)
160 .collect();
161 Some(Periodogram {
162 n,
163 padded_len,
164 power,
165 frequency,
166 })
167}
168
169/// Estimate the dominant period of the series.
170///
171/// Returns `None` when the input cannot be analysed (fewer than
172/// [`MIN_OBSERVATIONS`] points, or a non-finite value). Otherwise the result
173/// says explicitly whether a period was found: [`SeasonalityResult::period`]
174/// is `None` for a series with no periodicity that survives both the
175/// periodogram and the autocorrelation stage.
176///
177/// Only periods from 2 to `n / 2` are admissible — anything longer has not
178/// been observed twice.
179///
180/// # Examples
181///
182/// ```
183/// use u_analytics::seasonality::estimate_period;
184///
185/// // A sawtooth of period 7 over 40 observations.
186/// let series: Vec<f64> = (0..40).map(|i| (i % 7) as f64).collect();
187/// let result = estimate_period(&series).unwrap();
188/// assert_eq!(result.period, Some(7));
189///
190/// // A straight line has no period.
191/// let line: Vec<f64> = (0..40).map(|i| 2.0 * i as f64).collect();
192/// assert_eq!(estimate_period(&line).unwrap().period, None);
193/// ```
194pub fn estimate_period(series: &[f64]) -> Option<SeasonalityResult> {
195 let pg = periodogram(series)?;
196 let n = pg.n;
197 let acf_threshold = 1.96 / (n as f64).sqrt();
198 let detrended = detrend(series);
199 let total: f64 = pg.power.iter().sum();
200 let power_threshold = if total > 0.0 && total.is_finite() {
201 permutation_threshold(&detrended)
202 } else {
203 f64::INFINITY
204 };
205 let no_period = || SeasonalityResult {
206 period: None,
207 candidates: Vec::new(),
208 n,
209 acf_threshold,
210 power_threshold,
211 };
212 if !power_threshold.is_finite() {
213 return Some(no_period());
214 }
215
216 // Stage 1: local maxima of the periodogram above the permutation
217 // threshold, strongest first, at most MAX_CANDIDATES.
218 let m = pg.padded_len;
219 let mut peaks: Vec<(usize, f64)> = pg
220 .power
221 .iter()
222 .enumerate()
223 .filter(|&(i, &p)| {
224 p > power_threshold
225 && (i == 0 || p >= pg.power[i - 1])
226 && pg.power.get(i + 1).is_none_or(|&next| p >= next)
227 })
228 .map(|(i, &p)| (i + 1, p))
229 .collect();
230 peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).expect("finite power"));
231 peaks.truncate(MAX_CANDIDATES);
232 if peaks.is_empty() {
233 return Some(no_period());
234 }
235
236 // Stage 2: the ACF hill inside each candidate's frequency band.
237 let Some(acf_result) = acf(&detrended, n / 2) else {
238 return Some(no_period());
239 };
240 let r = &acf_result.acf;
241
242 let mut candidates: Vec<PeriodCandidate> = Vec::new();
243 for (bin, power) in peaks {
244 let lo = (m / (bin + 1)).max(2);
245 let hi = if bin == 1 {
246 n / 2
247 } else {
248 (m as f64 / (bin - 1) as f64).ceil() as usize
249 }
250 .min(n / 2);
251 if lo > hi {
252 continue;
253 }
254 // The biased ACF shrinks by (n − lag)/n, and that shrinkage is
255 // monotone in the lag -- so on a hill spanning several lags it tilts
256 // every comparison toward the shorter one, and the estimate lands
257 // below the true period whenever the series is not a whole number of
258 // cycles long. Undoing the bias is exactly what removes that tilt, so
259 // the corrected value decides all three questions here: which lag in
260 // the band, whether it is a hill top, and whether it clears the bound.
261 let corrected_at = |lag: usize| r[lag] * n as f64 / (n - lag) as f64;
262 let Some(lag) = (lo..=hi).filter(|&lag| lag < r.len()).max_by(|&a, &b| {
263 corrected_at(a)
264 .partial_cmp(&corrected_at(b))
265 .expect("finite acf")
266 }) else {
267 continue;
268 };
269 let corrected = corrected_at(lag);
270 let is_hill_top = corrected > acf_threshold
271 && corrected >= corrected_at(lag - 1)
272 && (lag + 1 >= r.len() || corrected >= corrected_at(lag + 1));
273 if !is_hill_top {
274 continue;
275 }
276 if let Some(existing) = candidates.iter_mut().find(|c| c.period == lag) {
277 // Two bins straddling one period: keep the stronger bin's numbers.
278 if power > existing.power {
279 existing.bin = bin;
280 existing.power = power;
281 existing.power_share = power / total;
282 }
283 continue;
284 }
285 candidates.push(PeriodCandidate {
286 period: lag,
287 acf: corrected,
288 bin,
289 power,
290 power_share: power / total,
291 });
292 }
293
294 candidates.sort_by(|a, b| b.acf.partial_cmp(&a.acf).expect("finite acf"));
295 let period = candidates.first().map(|c| c.period);
296 Some(SeasonalityResult {
297 period,
298 candidates,
299 n,
300 acf_threshold,
301 power_threshold,
302 })
303}
304
305/// Residuals of the least-squares line through the series.
306fn detrend(series: &[f64]) -> Vec<f64> {
307 let n = series.len() as f64;
308 let t_mean = (n - 1.0) / 2.0;
309 let y_mean = series.iter().sum::<f64>() / n;
310 let (mut sxy, mut sxx) = (0.0, 0.0);
311 for (i, &y) in series.iter().enumerate() {
312 let dt = i as f64 - t_mean;
313 sxy += dt * (y - y_mean);
314 sxx += dt * dt;
315 }
316 let slope = if sxx > 0.0 { sxy / sxx } else { 0.0 };
317 series
318 .iter()
319 .enumerate()
320 .map(|(i, &y)| y - y_mean - slope * (i as f64 - t_mean))
321 .collect()
322}
323
324/// Transform length: the power of two at least four times `n`.
325fn padded_len(n: usize) -> usize {
326 (4 * n).next_power_of_two()
327}
328
329/// `|X_k|² / n` for bins `1..=m/2` of the series zero-padded to length `m`.
330fn padded_power(detrended: &[f64], m: usize) -> Vec<f64> {
331 let n = detrended.len();
332 let mut padded = vec![0.0; m];
333 padded[..n].copy_from_slice(detrended);
334 rfft(&padded)[1..=m / 2]
335 .iter()
336 .map(|z| z.norm_sqr() / n as f64)
337 .collect()
338}
339
340/// Largest periodogram ordinate the values produce in random order, at the
341/// 99th percentile over 100 seeded permutations. Power above this is
342/// structure the ordering carries, not the marginal distribution.
343fn permutation_threshold(detrended: &[f64]) -> f64 {
344 let m = padded_len(detrended.len());
345 let mut rng = create_rng(PERMUTATION_SEED);
346 let mut shuffled = detrended.to_vec();
347 let mut maxima: Vec<f64> = (0..PERMUTATIONS)
348 .map(|_| {
349 shuffle(&mut shuffled, &mut rng);
350 padded_power(&shuffled, m)
351 .into_iter()
352 .fold(0.0f64, f64::max)
353 })
354 .collect();
355 maxima.sort_by(|a, b| a.partial_cmp(b).expect("finite power"));
356 let idx =
357 ((PERMUTATIONS as f64 * PERMUTATION_QUANTILE).ceil() as usize).clamp(1, PERMUTATIONS) - 1;
358 maxima[idx]
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use std::f64::consts::PI;
365
366 /// Deterministic uniform noise in [-1, 1) from a linear congruential
367 /// generator, so the tests do not depend on a random crate.
368 fn noise(seed: u64, n: usize) -> Vec<f64> {
369 let mut state = seed;
370 (0..n)
371 .map(|_| {
372 state = state
373 .wrapping_mul(6364136223846793005)
374 .wrapping_add(1442695040888963407);
375 ((state >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
376 })
377 .collect()
378 }
379
380 #[test]
381 fn sawtooth_of_period_seven_is_found() {
382 // The case a well-known library answers with "none": 40 rows of i % 7.
383 let series: Vec<f64> = (0..40).map(|i| (i % 7) as f64).collect();
384 let r = estimate_period(&series).unwrap();
385 assert_eq!(r.period, Some(7), "{r:?}");
386 assert!(r.candidates[0].acf > 0.5);
387 assert!(r.candidates[0].power > r.power_threshold);
388 }
389
390 #[test]
391 fn sine_with_noise_is_found_and_the_period_is_exact() {
392 let n = 120;
393 let series: Vec<f64> = noise(7, n)
394 .iter()
395 .enumerate()
396 .map(|(t, e)| (2.0 * PI * t as f64 / 12.0).sin() + 0.3 * e)
397 .collect();
398 let r = estimate_period(&series).unwrap();
399 assert_eq!(r.period, Some(12), "{r:?}");
400 }
401
402 #[test]
403 fn square_wave_reports_the_fundamental_first() {
404 let series: Vec<f64> = (0..100)
405 .map(|t| if (t / 5) % 2 == 0 { 1.0 } else { -1.0 })
406 .collect();
407 let r = estimate_period(&series).unwrap();
408 assert_eq!(r.period, Some(10), "{r:?}");
409 }
410
411 #[test]
412 fn period_survives_a_linear_trend() {
413 let series: Vec<f64> = (0..60)
414 .map(|t| 0.5 * t as f64 + 3.0 * (2.0 * PI * t as f64 / 6.0).cos())
415 .collect();
416 let r = estimate_period(&series).unwrap();
417 assert_eq!(r.period, Some(6), "{r:?}");
418 }
419
420 #[test]
421 fn line_constant_and_white_noise_have_no_period() {
422 let line: Vec<f64> = (0..50).map(|t| 3.0 - 0.25 * t as f64).collect();
423 assert_eq!(estimate_period(&line).unwrap().period, None);
424
425 let constant = vec![4.2; 30];
426 let r = estimate_period(&constant).unwrap();
427 assert_eq!(r.period, None);
428 assert!(r.candidates.is_empty());
429
430 // The permutation threshold has a 1% false-positive rate per series;
431 // with fixed seeds the answer is a fixed fact.
432 let r = estimate_period(&noise(11, 200)).unwrap();
433 assert_eq!(r.period, None, "{r:?}");
434 }
435
436 #[test]
437 fn every_period_from_2_to_20_is_recovered_from_sines_and_sawtooths() {
438 for period in 2..=20usize {
439 for cycles in [4usize, 7, 13] {
440 let n = period * cycles;
441 if n < 16 {
442 // Eight or twelve points rarely beat the permutation
443 // threshold: too few orderings differ from the observed.
444 continue;
445 }
446 let sine: Vec<f64> = noise(period as u64 * 31 + cycles as u64, n)
447 .iter()
448 .enumerate()
449 .map(|(t, e)| (2.0 * PI * t as f64 / period as f64 + 0.7).sin() + 0.2 * e)
450 .collect();
451 let r = estimate_period(&sine).unwrap();
452 assert_eq!(r.period, Some(period), "sine period {period}, n {n}: {r:?}");
453
454 let saw: Vec<f64> = (0..n + 3).map(|t| (t % period) as f64).collect();
455 let r = estimate_period(&saw).unwrap();
456 assert_eq!(
457 r.period,
458 Some(period),
459 "sawtooth period {period}, n {}: {r:?}",
460 n + 3
461 );
462 }
463 }
464 }
465
466 /// Reported by a consumer: a 63-point period over 300 observations --
467 /// 4.76 cycles -- came back as 60.
468 ///
469 /// The estimate has to hold when the series is not a whole number of
470 /// cycles long, which is the ordinary case for real data. The sweep below
471 /// is the general statement; this is the series as reported.
472 #[test]
473 fn a_period_is_exact_when_the_series_is_not_a_whole_number_of_cycles() {
474 fn jitter(i: usize) -> f64 {
475 (((i as f64) * 12.9898).sin() * 43758.5453) % 1.0
476 }
477 let spikes = [50usize, 120, 200, 250];
478 let reported: Vec<f64> = (0..300)
479 .map(|i| {
480 25.0 + 2.2 * (2.0 * PI * i as f64 / 63.0).sin()
481 + 0.4 * jitter(i)
482 + if spikes.contains(&i) { 12.0 } else { 0.0 }
483 })
484 .collect();
485 let r = estimate_period(&reported).unwrap();
486 assert_eq!(r.period, Some(63), "{r:?}");
487
488 // The same series at whole cycles was never in doubt; the two together
489 // say the fractional tail is what used to move the answer.
490 let whole: Vec<f64> = (0..315)
491 .map(|i| 25.0 + 2.2 * (2.0 * PI * i as f64 / 63.0).sin() + 0.4 * jitter(i))
492 .collect();
493 assert_eq!(estimate_period(&whole).unwrap().period, Some(63));
494
495 for period in [11usize, 17, 23, 31, 47, 63] {
496 let n = ((period as f64) * 4.76).round() as usize;
497 let sine: Vec<f64> = noise(period as u64 * 17 + 3, n)
498 .iter()
499 .enumerate()
500 .map(|(t, e)| (2.0 * PI * t as f64 / period as f64 + 0.7).sin() + 0.2 * e)
501 .collect();
502 let r = estimate_period(&sine).unwrap();
503 assert_eq!(r.period, Some(period), "period {period}, n {n}: {r:?}");
504 }
505 }
506
507 /// A candidate's `acf` is the value it was judged on, so a consumer
508 /// comparing it against `acf_threshold` reaches the crate's own verdict.
509 #[test]
510 fn a_candidate_reports_the_autocorrelation_it_was_accepted_on() {
511 let series: Vec<f64> = (0..137)
512 .map(|t| (2.0 * PI * t as f64 / 19.0).sin())
513 .collect();
514 let r = estimate_period(&series).unwrap();
515 assert_eq!(r.period, Some(19));
516 for c in &r.candidates {
517 assert!(
518 c.acf > r.acf_threshold,
519 "candidate {} reports {} against a bound of {}",
520 c.period,
521 c.acf,
522 r.acf_threshold
523 );
524 }
525 }
526
527 #[test]
528 fn short_or_non_finite_input_cannot_be_analysed() {
529 assert!(estimate_period(&[1.0; 7]).is_none());
530 assert!(periodogram(&[1.0; 7]).is_none());
531 let mut series: Vec<f64> = (0..20).map(|i| (i % 4) as f64).collect();
532 series[3] = f64::NAN;
533 assert!(estimate_period(&series).is_none());
534 }
535
536 #[test]
537 fn periodogram_bins_and_frequencies_line_up() {
538 let series: Vec<f64> = (0..48)
539 .map(|t| (2.0 * PI * t as f64 / 12.0).sin())
540 .collect();
541 let p = periodogram(&series).unwrap();
542 assert_eq!(p.padded_len, 256);
543 assert_eq!(p.power.len(), 128);
544 assert_eq!(p.frequency.len(), 128);
545 assert!((p.frequency[20] - 21.0 / 256.0).abs() < 1e-15);
546 let peak = p.peak_bin().unwrap();
547 assert_eq!(peak, 21);
548 assert!((p.period_of_bin(peak) - 12.0).abs() < 0.3);
549 // The mainlobe peak carries the sine's energy: n/4 · amplitude² = 12
550 // before detrending, a little less after it.
551 assert!(
552 p.power[peak - 1] > 10.5 && p.power[peak - 1] <= 12.0,
553 "{}",
554 p.power[peak - 1]
555 );
556 }
557}