Skip to main content

u_analytics/detection/
spectral_residual.rs

1//! Spectral residual saliency for one-shot anomaly scoring of a series.
2//!
3//! The spectral residual transform (Hou & Zhang 2007, brought to time series
4//! by Ren et al. 2019) takes the log amplitude spectrum of the series,
5//! subtracts its local average, and transforms back with the original phase.
6//! What is left — the saliency map — is large exactly where the series has a
7//! feature its spectrum cannot explain as regular structure: a spike, a
8//! step, a dropout. No model is trained and no period is assumed.
9//!
10//! Each point is scored relative to the saliency of the points before it,
11//! `score = (S_i − mean(S over the judgement window)) / mean(...)`; a point
12//! is an anomaly when its score exceeds the threshold (Ren et al. use τ = 3)
13//! and, so that a bump of a few thousandths on a flat series is not one, its
14//! value stands at least `min_zscore` standard deviations from the level of
15//! the window before it.
16//!
17//! The result also carries an **expected value** — the series with the
18//! anomalies replaced by their neighbours, reconstructed from its
19//! low-frequency components — and a band around it whose half-width is the
20//! normal quantile for `sensitivity` percent coverage times the robust
21//! spread of the residuals. The band is information for a chart, not the
22//! anomaly decision, which is the saliency score.
23//!
24//! # References
25//! - Ren, H., Xu, B., Wang, Y., Yi, C., Huang, C., Kou, X., Xing, T., Yang,
26//!   M., Tong, J. & Zhang, Q. (2019). "Time-Series Anomaly Detection Service
27//!   at Microsoft." *KDD 2019*, 3009–3017.
28//! - Hou, X. & Zhang, L. (2007). "Saliency Detection: A Spectral Residual
29//!   Approach." *CVPR 2007*.
30
31use u_numflow::fourier::{fft, ifft, Complex};
32use u_numflow::special::inverse_normal_cdf;
33use u_numflow::stats::median;
34
35/// Fewest observations the transform is applied to at once.
36pub const MIN_OBSERVATIONS: usize = 12;
37
38/// Points appended before the transform to soften its boundary effect, and
39/// the number of preceding points their slope is estimated from (κ = m = 5
40/// in Ren et al.).
41const EXTENSION: usize = 5;
42
43/// Spectral residual anomaly scorer.
44///
45/// Built with [`SpectralResidual::new`] (the defaults of Ren et al. 2019) and
46/// adjusted with the `with_*` methods; run with [`SpectralResidual::analyze`].
47#[derive(Debug, Clone, PartialEq)]
48pub struct SpectralResidual {
49    /// Width of the moving average applied to the log amplitude spectrum
50    /// (`q` in Ren et al.; default 3).
51    pub averaging_window: usize,
52    /// How many preceding saliency values a point is compared against
53    /// (`z`; default 40).
54    pub judgement_window: usize,
55    /// Score above which a point is an anomaly (`τ`; default 3).
56    pub threshold: f64,
57    /// Minimum `|value − mean| / std` of the point against the judgement
58    /// window before it, for it to be an anomaly (default 1.5), so that
59    /// saliency alone does not flag noise on a flat series. A window with no
60    /// spread passes any departure from its level.
61    pub min_zscore: f64,
62    /// Coverage of the band around the expected value, in percent (default
63    /// 70): its half-width is the normal quantile for that two-sided coverage
64    /// times the robust spread of the non-anomalous residuals.
65    pub sensitivity: f64,
66    /// Process the series in consecutive batches of this many points instead
67    /// of at once (default `None`). Long series with slow drift are scored
68    /// against their local context this way; the last batch absorbs a short
69    /// remainder.
70    pub batch_size: Option<usize>,
71}
72
73impl Default for SpectralResidual {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl SpectralResidual {
80    /// The defaults of Ren et al. (2019): q = 3, z = 40, τ = 3, z-score gate
81    /// 1.5, 70% band, no batching.
82    pub fn new() -> Self {
83        Self {
84            averaging_window: 3,
85            judgement_window: 40,
86            threshold: 3.0,
87            min_zscore: 1.5,
88            sensitivity: 70.0,
89            batch_size: None,
90        }
91    }
92
93    /// Sets the spectral averaging window (≥ 1).
94    pub fn with_averaging_window(mut self, q: usize) -> Self {
95        self.averaging_window = q;
96        self
97    }
98
99    /// Sets the judgement window (≥ 1).
100    pub fn with_judgement_window(mut self, z: usize) -> Self {
101        self.judgement_window = z;
102        self
103    }
104
105    /// Sets the anomaly threshold on the score (> 0).
106    pub fn with_threshold(mut self, threshold: f64) -> Self {
107        self.threshold = threshold;
108        self
109    }
110
111    /// Sets the z-score gate (≥ 0; 0 disables it).
112    pub fn with_min_zscore(mut self, min_zscore: f64) -> Self {
113        self.min_zscore = min_zscore;
114        self
115    }
116
117    /// Sets the band coverage in percent (0 < s < 100).
118    pub fn with_sensitivity(mut self, sensitivity: f64) -> Self {
119        self.sensitivity = sensitivity;
120        self
121    }
122
123    /// Sets the batch size (≥ [`MIN_OBSERVATIONS`]), or `None` for one batch.
124    pub fn with_batch_size(mut self, batch_size: Option<usize>) -> Self {
125        self.batch_size = batch_size;
126        self
127    }
128
129    fn valid(&self) -> bool {
130        self.averaging_window >= 1
131            && self.judgement_window >= 1
132            && self.threshold.is_finite()
133            && self.threshold > 0.0
134            && self.min_zscore.is_finite()
135            && self.min_zscore >= 0.0
136            && self.sensitivity.is_finite()
137            && self.sensitivity > 0.0
138            && self.sensitivity < 100.0
139            && self.batch_size.is_none_or(|b| b >= MIN_OBSERVATIONS)
140    }
141
142    /// Scores every point of the series.
143    ///
144    /// Returns `None` when the configuration is invalid, the series has fewer
145    /// than [`MIN_OBSERVATIONS`] points, or a value is not finite. The
146    /// result has one [`SrPoint`] per input point, in order.
147    ///
148    /// # Examples
149    ///
150    /// ```
151    /// use u_analytics::detection::SpectralResidual;
152    ///
153    /// let mut series: Vec<f64> = (0..60).map(|t| (t as f64 * 0.3).sin()).collect();
154    /// series[40] += 4.0; // a spike
155    /// let points = SpectralResidual::new().analyze(&series).unwrap();
156    /// assert!(points[40].is_anomaly);
157    /// assert!(points[40].score > points[20].score);
158    /// ```
159    pub fn analyze(&self, series: &[f64]) -> Option<Vec<SrPoint>> {
160        if !self.valid() || series.len() < MIN_OBSERVATIONS || series.iter().any(|v| !v.is_finite())
161        {
162            return None;
163        }
164        let n = series.len();
165        let batch = self.batch_size.unwrap_or(n).min(n);
166        let mut points = Vec::with_capacity(n);
167        let mut start = 0;
168        while start < n {
169            // The last batch absorbs a remainder shorter than a batch.
170            let end = if n - start < 2 * batch {
171                n
172            } else {
173                start + batch
174            };
175            points.extend(self.analyze_batch(&series[start..end], start));
176            start = end;
177        }
178        Some(points)
179    }
180
181    fn analyze_batch(&self, data: &[f64], offset: usize) -> Vec<SrPoint> {
182        let n = data.len();
183        let saliency = saliency_map(data, self.averaging_window);
184
185        // Score against the preceding judgement window (the point included,
186        // as in Ren et al.'s implementation), relative to that average.
187        let scores: Vec<f64> = (0..n)
188            .map(|i| {
189                let from = i.saturating_sub(self.judgement_window - 1);
190                let avg = saliency[from..=i].iter().sum::<f64>() / (i + 1 - from) as f64;
191                let avg = avg.max(1e-8);
192                ((saliency[i] - avg) / avg).max(0.0)
193            })
194            .collect();
195
196        // The z-score gate is local: the point against the mean and spread of
197        // the judgement window before it, so a step is judged against the
198        // level it departs from, not against a mean that already straddles it.
199        let flagged: Vec<bool> = (0..n)
200            .map(|i| {
201                if scores[i] <= self.threshold {
202                    return false;
203                }
204                if self.min_zscore == 0.0 || i == 0 {
205                    return true;
206                }
207                let from = i.saturating_sub(self.judgement_window);
208                let window = &data[from..i];
209                let mean = window.iter().sum::<f64>() / window.len() as f64;
210                let std = (window.iter().map(|v| (v - mean).powi(2)).sum::<f64>()
211                    / window.len() as f64)
212                    .sqrt();
213                if std > 0.0 {
214                    (data[i] - mean).abs() / std >= self.min_zscore
215                } else {
216                    data[i] != mean
217                }
218            })
219            .collect();
220
221        let expected = expected_values(data, &flagged);
222        let residuals: Vec<f64> = (0..n)
223            .filter(|&i| !flagged[i])
224            .map(|i| (data[i] - expected[i]).abs())
225            .collect();
226        // Robust spread: 1.4826 · median absolute residual (the MAD of a
227        // centred residual), then the two-sided normal quantile for the
228        // requested coverage.
229        let spread = median(&residuals).unwrap_or(0.0) * 1.4826;
230        let z = inverse_normal_cdf(0.5 + self.sensitivity / 200.0);
231        let margin = z * spread;
232
233        (0..n)
234            .map(|i| SrPoint {
235                index: offset + i,
236                value: data[i],
237                saliency: saliency[i],
238                score: scores[i],
239                expected: expected[i],
240                lower: expected[i] - margin,
241                upper: expected[i] + margin,
242                is_anomaly: flagged[i],
243            })
244            .collect()
245    }
246}
247
248/// One scored point.
249#[derive(Debug, Clone, PartialEq)]
250pub struct SrPoint {
251    /// Position in the input series.
252    pub index: usize,
253    /// The observed value.
254    pub value: f64,
255    /// Spectral residual saliency of the point (≥ 0).
256    pub saliency: f64,
257    /// Saliency relative to the preceding judgement window (≥ 0).
258    pub score: f64,
259    /// Low-frequency reconstruction of the series with anomalies removed.
260    pub expected: f64,
261    /// `expected − margin`.
262    pub lower: f64,
263    /// `expected + margin`.
264    pub upper: f64,
265    /// `score > threshold` and the value clears the z-score gate.
266    pub is_anomaly: bool,
267}
268
269/// The saliency map of Ren et al. (2019): extend the series by κ = 5 points
270/// along the slope of its last m = 5, take the FFT, average the log
271/// amplitude over `q` bins, keep the residual with the original phase,
272/// transform back, and drop the extension.
273fn saliency_map(data: &[f64], q: usize) -> Vec<f64> {
274    let n = data.len();
275    let mut extended = data.to_vec();
276    let m = EXTENSION.min(n - 1);
277    let last = data[n - 1];
278    let slope = (1..=m)
279        .map(|i| (last - data[n - 1 - i]) / i as f64)
280        .sum::<f64>()
281        / m as f64;
282    let predicted = data[n - 1 - m] + slope * m as f64;
283    extended.extend(std::iter::repeat_n(predicted, EXTENSION));
284
285    let spectrum = fft(&extended
286        .iter()
287        .map(|&x| Complex::real(x))
288        .collect::<Vec<_>>());
289    let len = spectrum.len();
290    let log_amp: Vec<f64> = spectrum.iter().map(|z| (z.norm() + 1e-8).ln()).collect();
291    // Moving average of width q over the preceding bins (cumulative form).
292    let mut avg = vec![0.0; len];
293    let mut cum = 0.0;
294    let mut cums = Vec::with_capacity(len + 1);
295    cums.push(0.0);
296    for &v in &log_amp {
297        cum += v;
298        cums.push(cum);
299    }
300    for i in 0..len {
301        let from = (i + 1).saturating_sub(q);
302        avg[i] = (cums[i + 1] - cums[from]) / (i + 1 - from) as f64;
303    }
304    let residual_spectrum: Vec<Complex> = spectrum
305        .iter()
306        .enumerate()
307        .map(|(i, z)| {
308            let r = (log_amp[i] - avg[i]).exp();
309            let phase = z.arg();
310            Complex::new(r * phase.cos(), r * phase.sin())
311        })
312        .collect();
313    ifft(&residual_spectrum)
314        .into_iter()
315        .take(n)
316        .map(|z| z.norm())
317        .collect()
318}
319
320/// The series with each flagged point replaced by the mean of its nearest
321/// unflagged neighbours, reconstructed from its low-frequency components:
322/// the middle band of the spectrum (bins from 3/8 to 5/8 of the length) is
323/// zeroed, as in the Microsoft service's expected value.
324fn expected_values(data: &[f64], flagged: &[bool]) -> Vec<f64> {
325    let n = data.len();
326    let mut cleaned = data.to_vec();
327    for i in 0..n {
328        if !flagged[i] {
329            continue;
330        }
331        let left = (0..i).rev().find(|&j| !flagged[j]).map(|j| data[j]);
332        let right = (i + 1..n).find(|&j| !flagged[j]).map(|j| data[j]);
333        cleaned[i] = match (left, right) {
334            (Some(a), Some(b)) => (a + b) / 2.0,
335            (Some(a), None) | (None, Some(a)) => a,
336            (None, None) => data[i],
337        };
338    }
339    let mut spectrum = fft(&cleaned
340        .iter()
341        .map(|&x| Complex::real(x))
342        .collect::<Vec<_>>());
343    let lo = n * 3 / 8;
344    let hi = n * 5 / 8;
345    for (i, z) in spectrum.iter_mut().enumerate() {
346        if i > lo && i < hi {
347            *z = Complex::default();
348        }
349    }
350    ifft(&spectrum).into_iter().map(|z| z.re).collect()
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    fn noise(seed: u64, n: usize) -> Vec<f64> {
358        let mut state = seed;
359        (0..n)
360            .map(|_| {
361                state = state
362                    .wrapping_mul(6364136223846793005)
363                    .wrapping_add(1442695040888963407);
364                ((state >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
365            })
366            .collect()
367    }
368
369    fn check_shape(points: &[SrPoint], n: usize) {
370        assert_eq!(points.len(), n);
371        for (i, p) in points.iter().enumerate() {
372            assert_eq!(p.index, i);
373            assert!(p.saliency >= 0.0 && p.saliency.is_finite(), "{p:?}");
374            assert!(p.score >= 0.0 && p.score.is_finite(), "{p:?}");
375            assert!(
376                p.expected.is_finite() && p.lower <= p.expected && p.expected <= p.upper,
377                "{p:?}"
378            );
379        }
380    }
381
382    #[test]
383    fn a_spike_on_a_sine_is_the_anomaly() {
384        let mut series: Vec<f64> = noise(3, 120)
385            .iter()
386            .enumerate()
387            .map(|(t, e)| (t as f64 * 0.25).sin() + 0.1 * e)
388            .collect();
389        series[80] += 5.0;
390        let points = SpectralResidual::new().analyze(&series).unwrap();
391        check_shape(&points, 120);
392        assert!(points[80].is_anomaly, "{:?}", points[80]);
393        let flagged: Vec<usize> = points
394            .iter()
395            .filter(|p| p.is_anomaly)
396            .map(|p| p.index)
397            .collect();
398        assert!(flagged.len() <= 3, "flagged {flagged:?}");
399        assert!(points[80].value > points[80].upper);
400        // The expected value is not dragged up by the spike.
401        assert!(
402            (points[80].expected - (80.0f64 * 0.25).sin()).abs() < 0.6,
403            "{:?}",
404            points[80]
405        );
406    }
407
408    #[test]
409    fn a_step_is_flagged_where_it_happens() {
410        let series: Vec<f64> = (0..100).map(|t| if t < 60 { 1.0 } else { 6.0 }).collect();
411        let points = SpectralResidual::new().analyze(&series).unwrap();
412        check_shape(&points, 100);
413        let first = points.iter().position(|p| p.is_anomaly);
414        assert_eq!(
415            first,
416            Some(60),
417            "{:?}",
418            points
419                .iter()
420                .filter(|p| p.is_anomaly)
421                .map(|p| p.index)
422                .collect::<Vec<_>>()
423        );
424    }
425
426    #[test]
427    fn a_constant_and_plain_noise_raise_no_anomaly() {
428        let constant = vec![3.0; 50];
429        let points = SpectralResidual::new().analyze(&constant).unwrap();
430        check_shape(&points, 50);
431        assert!(points.iter().all(|p| !p.is_anomaly));
432        assert!(points.iter().all(|p| (p.expected - 3.0).abs() < 1e-9));
433
434        let series = noise(9, 200);
435        let points = SpectralResidual::new().analyze(&series).unwrap();
436        check_shape(&points, 200);
437        let flagged = points.iter().filter(|p| p.is_anomaly).count();
438        assert!(flagged <= 4, "{flagged} of 200 noise points flagged");
439    }
440
441    #[test]
442    fn scoring_is_deterministic_and_batching_keeps_the_shape() {
443        let mut series: Vec<f64> = noise(5, 300).iter().map(|e| 10.0 + e).collect();
444        series[150] = 20.0;
445        series[260] = -5.0;
446        let sr = SpectralResidual::new();
447        let a = sr.analyze(&series).unwrap();
448        let b = sr.analyze(&series).unwrap();
449        assert_eq!(a, b);
450        let batched = sr.with_batch_size(Some(100)).analyze(&series).unwrap();
451        check_shape(&batched, 300);
452        assert!(batched[150].is_anomaly && batched[260].is_anomaly);
453        // Each batch is scored on its own: the first point of a batch has no
454        // history but the shape is unchanged and indices are global.
455        assert_eq!(batched[200].index, 200);
456    }
457
458    #[test]
459    fn the_band_widens_with_sensitivity() {
460        let series: Vec<f64> = noise(1, 80).iter().map(|e| 5.0 + e).collect();
461        let narrow = SpectralResidual::new()
462            .with_sensitivity(50.0)
463            .analyze(&series)
464            .unwrap();
465        let wide = SpectralResidual::new()
466            .with_sensitivity(99.0)
467            .analyze(&series)
468            .unwrap();
469        for (a, b) in narrow.iter().zip(&wide) {
470            assert!(b.upper - b.lower > a.upper - a.lower);
471            assert_eq!(a.expected, b.expected);
472        }
473    }
474
475    #[test]
476    fn invalid_input_and_configuration_are_refused() {
477        assert!(SpectralResidual::new().analyze(&[1.0; 11]).is_none());
478        let mut series = vec![1.0; 20];
479        series[7] = f64::INFINITY;
480        assert!(SpectralResidual::new().analyze(&series).is_none());
481        let ok = vec![1.0; 20];
482        assert!(SpectralResidual::new()
483            .with_threshold(0.0)
484            .analyze(&ok)
485            .is_none());
486        assert!(SpectralResidual::new()
487            .with_sensitivity(100.0)
488            .analyze(&ok)
489            .is_none());
490        assert!(SpectralResidual::new()
491            .with_averaging_window(0)
492            .analyze(&ok)
493            .is_none());
494        assert!(SpectralResidual::new()
495            .with_batch_size(Some(5))
496            .analyze(&ok)
497            .is_none());
498        assert!(SpectralResidual::new()
499            .with_min_zscore(0.0)
500            .analyze(&ok)
501            .is_some());
502    }
503}