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 — the strength of the periodicity.
103 pub acf: f64,
104 /// Periodogram bin (1-based) whose band produced this candidate.
105 pub bin: usize,
106 /// Periodogram power of that bin.
107 pub power: f64,
108 /// That bin's share of the total periodogram power.
109 pub power_share: f64,
110}
111
112/// Result of period estimation.
113#[derive(Debug, Clone, PartialEq)]
114pub struct SeasonalityResult {
115 /// The dominant period, or `None` when no periodicity passed both
116 /// stages — a constant, a pure trend, white noise.
117 pub period: Option<usize>,
118 /// Every validated periodicity, strongest first. Empty when `period` is
119 /// `None`.
120 pub candidates: Vec<PeriodCandidate>,
121 /// Number of observations.
122 pub n: usize,
123 /// The 95% white-noise bound on the ACF, `1.96 / √n`.
124 pub acf_threshold: f64,
125 /// Periodogram power a bin had to exceed to become a candidate: the 99th
126 /// percentile of the largest ordinate over 100 permutations of the series.
127 pub power_threshold: f64,
128}
129
130/// Periodogram of the series after linear detrending.
131///
132/// Returns `None` when the series has fewer than [`MIN_OBSERVATIONS`] points
133/// or a non-finite value. A constant series returns a periodogram of zeros.
134///
135/// # Examples
136///
137/// ```
138/// use u_analytics::seasonality::periodogram;
139///
140/// let series: Vec<f64> = (0..48).map(|t| (2.0 * std::f64::consts::PI * t as f64 / 12.0).sin()).collect();
141/// let p = periodogram(&series).unwrap();
142/// // Padded to 256: bin 256 / 12 ≈ 21.3, so the peak sits at bin 21.
143/// let peak = p.peak_bin().unwrap();
144/// assert_eq!(peak, 21);
145/// assert!((p.period_of_bin(peak) - 12.0).abs() < 0.3);
146/// ```
147pub fn periodogram(series: &[f64]) -> Option<Periodogram> {
148 let n = series.len();
149 if n < MIN_OBSERVATIONS || series.iter().any(|v| !v.is_finite()) {
150 return None;
151 }
152 let detrended = detrend(series);
153 let padded_len = padded_len(n);
154 let power = padded_power(&detrended, padded_len);
155 let frequency: Vec<f64> = (1..=padded_len / 2)
156 .map(|k| k as f64 / padded_len as f64)
157 .collect();
158 Some(Periodogram {
159 n,
160 padded_len,
161 power,
162 frequency,
163 })
164}
165
166/// Estimate the dominant period of the series.
167///
168/// Returns `None` when the input cannot be analysed (fewer than
169/// [`MIN_OBSERVATIONS`] points, or a non-finite value). Otherwise the result
170/// says explicitly whether a period was found: [`SeasonalityResult::period`]
171/// is `None` for a series with no periodicity that survives both the
172/// periodogram and the autocorrelation stage.
173///
174/// Only periods from 2 to `n / 2` are admissible — anything longer has not
175/// been observed twice.
176///
177/// # Examples
178///
179/// ```
180/// use u_analytics::seasonality::estimate_period;
181///
182/// // A sawtooth of period 7 over 40 observations.
183/// let series: Vec<f64> = (0..40).map(|i| (i % 7) as f64).collect();
184/// let result = estimate_period(&series).unwrap();
185/// assert_eq!(result.period, Some(7));
186///
187/// // A straight line has no period.
188/// let line: Vec<f64> = (0..40).map(|i| 2.0 * i as f64).collect();
189/// assert_eq!(estimate_period(&line).unwrap().period, None);
190/// ```
191pub fn estimate_period(series: &[f64]) -> Option<SeasonalityResult> {
192 let pg = periodogram(series)?;
193 let n = pg.n;
194 let acf_threshold = 1.96 / (n as f64).sqrt();
195 let detrended = detrend(series);
196 let total: f64 = pg.power.iter().sum();
197 let power_threshold = if total > 0.0 && total.is_finite() {
198 permutation_threshold(&detrended)
199 } else {
200 f64::INFINITY
201 };
202 let no_period = || SeasonalityResult {
203 period: None,
204 candidates: Vec::new(),
205 n,
206 acf_threshold,
207 power_threshold,
208 };
209 if !power_threshold.is_finite() {
210 return Some(no_period());
211 }
212
213 // Stage 1: local maxima of the periodogram above the permutation
214 // threshold, strongest first, at most MAX_CANDIDATES.
215 let m = pg.padded_len;
216 let mut peaks: Vec<(usize, f64)> = pg
217 .power
218 .iter()
219 .enumerate()
220 .filter(|&(i, &p)| {
221 p > power_threshold
222 && (i == 0 || p >= pg.power[i - 1])
223 && pg.power.get(i + 1).is_none_or(|&next| p >= next)
224 })
225 .map(|(i, &p)| (i + 1, p))
226 .collect();
227 peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).expect("finite power"));
228 peaks.truncate(MAX_CANDIDATES);
229 if peaks.is_empty() {
230 return Some(no_period());
231 }
232
233 // Stage 2: the ACF hill inside each candidate's frequency band.
234 let Some(acf_result) = acf(&detrended, n / 2) else {
235 return Some(no_period());
236 };
237 let r = &acf_result.acf;
238
239 let mut candidates: Vec<PeriodCandidate> = Vec::new();
240 for (bin, power) in peaks {
241 let lo = (m / (bin + 1)).max(2);
242 let hi = if bin == 1 {
243 n / 2
244 } else {
245 (m as f64 / (bin - 1) as f64).ceil() as usize
246 }
247 .min(n / 2);
248 if lo > hi {
249 continue;
250 }
251 let Some(lag) = (lo..=hi)
252 .filter(|&lag| lag < r.len())
253 .max_by(|&a, &b| r[a].partial_cmp(&r[b]).expect("finite acf"))
254 else {
255 continue;
256 };
257 // The biased ACF shrinks by (n − lag)/n, which at short lengths keeps
258 // even a perfect periodicity under the bound; the bound is compared
259 // against the bias-corrected value, the hill is judged on the biased.
260 let corrected = r[lag] * n as f64 / (n - lag) as f64;
261 let is_hill_top = corrected > acf_threshold
262 && r[lag] >= r[lag - 1]
263 && r.get(lag + 1).is_none_or(|&next| r[lag] >= next);
264 if !is_hill_top {
265 continue;
266 }
267 if let Some(existing) = candidates.iter_mut().find(|c| c.period == lag) {
268 // Two bins straddling one period: keep the stronger bin's numbers.
269 if power > existing.power {
270 existing.bin = bin;
271 existing.power = power;
272 existing.power_share = power / total;
273 }
274 continue;
275 }
276 candidates.push(PeriodCandidate {
277 period: lag,
278 acf: r[lag],
279 bin,
280 power,
281 power_share: power / total,
282 });
283 }
284
285 candidates.sort_by(|a, b| b.acf.partial_cmp(&a.acf).expect("finite acf"));
286 let period = candidates.first().map(|c| c.period);
287 Some(SeasonalityResult {
288 period,
289 candidates,
290 n,
291 acf_threshold,
292 power_threshold,
293 })
294}
295
296/// Residuals of the least-squares line through the series.
297fn detrend(series: &[f64]) -> Vec<f64> {
298 let n = series.len() as f64;
299 let t_mean = (n - 1.0) / 2.0;
300 let y_mean = series.iter().sum::<f64>() / n;
301 let (mut sxy, mut sxx) = (0.0, 0.0);
302 for (i, &y) in series.iter().enumerate() {
303 let dt = i as f64 - t_mean;
304 sxy += dt * (y - y_mean);
305 sxx += dt * dt;
306 }
307 let slope = if sxx > 0.0 { sxy / sxx } else { 0.0 };
308 series
309 .iter()
310 .enumerate()
311 .map(|(i, &y)| y - y_mean - slope * (i as f64 - t_mean))
312 .collect()
313}
314
315/// Transform length: the power of two at least four times `n`.
316fn padded_len(n: usize) -> usize {
317 (4 * n).next_power_of_two()
318}
319
320/// `|X_k|² / n` for bins `1..=m/2` of the series zero-padded to length `m`.
321fn padded_power(detrended: &[f64], m: usize) -> Vec<f64> {
322 let n = detrended.len();
323 let mut padded = vec![0.0; m];
324 padded[..n].copy_from_slice(detrended);
325 rfft(&padded)[1..=m / 2]
326 .iter()
327 .map(|z| z.norm_sqr() / n as f64)
328 .collect()
329}
330
331/// Largest periodogram ordinate the values produce in random order, at the
332/// 99th percentile over 100 seeded permutations. Power above this is
333/// structure the ordering carries, not the marginal distribution.
334fn permutation_threshold(detrended: &[f64]) -> f64 {
335 let m = padded_len(detrended.len());
336 let mut rng = create_rng(PERMUTATION_SEED);
337 let mut shuffled = detrended.to_vec();
338 let mut maxima: Vec<f64> = (0..PERMUTATIONS)
339 .map(|_| {
340 shuffle(&mut shuffled, &mut rng);
341 padded_power(&shuffled, m)
342 .into_iter()
343 .fold(0.0f64, f64::max)
344 })
345 .collect();
346 maxima.sort_by(|a, b| a.partial_cmp(b).expect("finite power"));
347 let idx =
348 ((PERMUTATIONS as f64 * PERMUTATION_QUANTILE).ceil() as usize).clamp(1, PERMUTATIONS) - 1;
349 maxima[idx]
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use std::f64::consts::PI;
356
357 /// Deterministic uniform noise in [-1, 1) from a linear congruential
358 /// generator, so the tests do not depend on a random crate.
359 fn noise(seed: u64, n: usize) -> Vec<f64> {
360 let mut state = seed;
361 (0..n)
362 .map(|_| {
363 state = state
364 .wrapping_mul(6364136223846793005)
365 .wrapping_add(1442695040888963407);
366 ((state >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
367 })
368 .collect()
369 }
370
371 #[test]
372 fn sawtooth_of_period_seven_is_found() {
373 // The case a well-known library answers with "none": 40 rows of i % 7.
374 let series: Vec<f64> = (0..40).map(|i| (i % 7) as f64).collect();
375 let r = estimate_period(&series).unwrap();
376 assert_eq!(r.period, Some(7), "{r:?}");
377 assert!(r.candidates[0].acf > 0.5);
378 assert!(r.candidates[0].power > r.power_threshold);
379 }
380
381 #[test]
382 fn sine_with_noise_is_found_and_the_period_is_exact() {
383 let n = 120;
384 let series: Vec<f64> = noise(7, n)
385 .iter()
386 .enumerate()
387 .map(|(t, e)| (2.0 * PI * t as f64 / 12.0).sin() + 0.3 * e)
388 .collect();
389 let r = estimate_period(&series).unwrap();
390 assert_eq!(r.period, Some(12), "{r:?}");
391 }
392
393 #[test]
394 fn square_wave_reports_the_fundamental_first() {
395 let series: Vec<f64> = (0..100)
396 .map(|t| if (t / 5) % 2 == 0 { 1.0 } else { -1.0 })
397 .collect();
398 let r = estimate_period(&series).unwrap();
399 assert_eq!(r.period, Some(10), "{r:?}");
400 }
401
402 #[test]
403 fn period_survives_a_linear_trend() {
404 let series: Vec<f64> = (0..60)
405 .map(|t| 0.5 * t as f64 + 3.0 * (2.0 * PI * t as f64 / 6.0).cos())
406 .collect();
407 let r = estimate_period(&series).unwrap();
408 assert_eq!(r.period, Some(6), "{r:?}");
409 }
410
411 #[test]
412 fn line_constant_and_white_noise_have_no_period() {
413 let line: Vec<f64> = (0..50).map(|t| 3.0 - 0.25 * t as f64).collect();
414 assert_eq!(estimate_period(&line).unwrap().period, None);
415
416 let constant = vec![4.2; 30];
417 let r = estimate_period(&constant).unwrap();
418 assert_eq!(r.period, None);
419 assert!(r.candidates.is_empty());
420
421 // The permutation threshold has a 1% false-positive rate per series;
422 // with fixed seeds the answer is a fixed fact.
423 let r = estimate_period(&noise(11, 200)).unwrap();
424 assert_eq!(r.period, None, "{r:?}");
425 }
426
427 #[test]
428 fn every_period_from_2_to_20_is_recovered_from_sines_and_sawtooths() {
429 for period in 2..=20usize {
430 for cycles in [4usize, 7, 13] {
431 let n = period * cycles;
432 if n < 16 {
433 // Eight or twelve points rarely beat the permutation
434 // threshold: too few orderings differ from the observed.
435 continue;
436 }
437 let sine: Vec<f64> = noise(period as u64 * 31 + cycles as u64, n)
438 .iter()
439 .enumerate()
440 .map(|(t, e)| (2.0 * PI * t as f64 / period as f64 + 0.7).sin() + 0.2 * e)
441 .collect();
442 let r = estimate_period(&sine).unwrap();
443 assert_eq!(r.period, Some(period), "sine period {period}, n {n}: {r:?}");
444
445 let saw: Vec<f64> = (0..n + 3).map(|t| (t % period) as f64).collect();
446 let r = estimate_period(&saw).unwrap();
447 assert_eq!(
448 r.period,
449 Some(period),
450 "sawtooth period {period}, n {}: {r:?}",
451 n + 3
452 );
453 }
454 }
455 }
456
457 #[test]
458 fn short_or_non_finite_input_cannot_be_analysed() {
459 assert!(estimate_period(&[1.0; 7]).is_none());
460 assert!(periodogram(&[1.0; 7]).is_none());
461 let mut series: Vec<f64> = (0..20).map(|i| (i % 4) as f64).collect();
462 series[3] = f64::NAN;
463 assert!(estimate_period(&series).is_none());
464 }
465
466 #[test]
467 fn periodogram_bins_and_frequencies_line_up() {
468 let series: Vec<f64> = (0..48)
469 .map(|t| (2.0 * PI * t as f64 / 12.0).sin())
470 .collect();
471 let p = periodogram(&series).unwrap();
472 assert_eq!(p.padded_len, 256);
473 assert_eq!(p.power.len(), 128);
474 assert_eq!(p.frequency.len(), 128);
475 assert!((p.frequency[20] - 21.0 / 256.0).abs() < 1e-15);
476 let peak = p.peak_bin().unwrap();
477 assert_eq!(peak, 21);
478 assert!((p.period_of_bin(peak) - 12.0).abs() < 0.3);
479 // The mainlobe peak carries the sine's energy: n/4 · amplitude² = 12
480 // before detrending, a little less after it.
481 assert!(
482 p.power[peak - 1] > 10.5 && p.power[peak - 1] <= 12.0,
483 "{}",
484 p.power[peak - 1]
485 );
486 }
487}