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 — 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}