Skip to main content

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}