1use u_numflow::fourier::{fft, ifft, Complex};
52use u_numflow::special::inverse_normal_cdf;
53use u_numflow::stats::median;
54
55pub const MIN_OBSERVATIONS: usize = 12;
57
58const _: () = assert!(
62 MIN_OBSERVATIONS == 12,
63 "the batch_size requirement message spells this number"
64);
65
66const EXTENSION: usize = 5;
70
71#[derive(Debug, Clone, Copy, PartialEq)]
78#[non_exhaustive]
79pub enum SpectralResidualError {
80 OptionOutOfRange {
86 option: &'static str,
89 requirement: &'static str,
91 got: f64,
93 min: Option<f64>,
95 exclusive_min: Option<f64>,
97 max: Option<f64>,
99 exclusive_max: Option<f64>,
101 },
102 TooFewObservations {
104 needed: usize,
106 got: usize,
108 },
109 ValueNotFinite {
111 index: usize,
113 },
114}
115
116impl core::fmt::Display for SpectralResidualError {
117 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
118 match self {
119 SpectralResidualError::OptionOutOfRange {
120 option,
121 requirement,
122 ..
123 } => write!(f, "{option} must be {requirement}"),
124 SpectralResidualError::TooFewObservations { needed, got } => {
125 write!(f, "needs at least {needed} observations, got {got}")
126 }
127 SpectralResidualError::ValueNotFinite { index } => {
128 write!(f, "data[{index}] is not a finite number")
129 }
130 }
131 }
132}
133
134impl std::error::Error for SpectralResidualError {}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub(crate) enum CountOption {
143 AveragingWindow,
144 JudgementWindow,
145 BatchSize,
146}
147
148impl CountOption {
149 pub(crate) fn check(self, got: f64) -> Result<(), SpectralResidualError> {
151 let (option, requirement, min) = match self {
152 CountOption::AveragingWindow => ("averaging_window", ">= 1", 1),
153 CountOption::JudgementWindow => ("judgement_window", ">= 1", 1),
154 CountOption::BatchSize => ("batch_size", "unset, or at least 12", MIN_OBSERVATIONS),
155 };
156 if got < min as f64 {
157 Err(SpectralResidualError::below(
158 option,
159 requirement,
160 got,
161 min as f64,
162 ))
163 } else {
164 Ok(())
165 }
166 }
167}
168
169impl SpectralResidualError {
170 fn below(option: &'static str, requirement: &'static str, got: f64, min: f64) -> Self {
172 Self::OptionOutOfRange {
173 option,
174 requirement,
175 got,
176 min: Some(min),
177 exclusive_min: None,
178 max: None,
179 exclusive_max: None,
180 }
181 }
182
183 fn not_above(
185 option: &'static str,
186 requirement: &'static str,
187 got: f64,
188 exclusive_min: f64,
189 ) -> Self {
190 Self::OptionOutOfRange {
191 option,
192 requirement,
193 got,
194 min: None,
195 exclusive_min: Some(exclusive_min),
196 max: None,
197 exclusive_max: None,
198 }
199 }
200
201 fn not_between(
203 option: &'static str,
204 requirement: &'static str,
205 got: f64,
206 exclusive_min: f64,
207 exclusive_max: f64,
208 ) -> Self {
209 Self::OptionOutOfRange {
210 option,
211 requirement,
212 got,
213 min: None,
214 exclusive_min: Some(exclusive_min),
215 max: None,
216 exclusive_max: Some(exclusive_max),
217 }
218 }
219}
220
221#[derive(Debug, Clone, PartialEq)]
226pub struct SpectralResidual {
227 pub averaging_window: usize,
230 pub judgement_window: usize,
233 pub threshold: f64,
235 pub min_zscore: f64,
240 pub sensitivity: f64,
244 pub batch_size: Option<usize>,
249}
250
251impl Default for SpectralResidual {
252 fn default() -> Self {
253 Self::new()
254 }
255}
256
257impl SpectralResidual {
258 pub fn new() -> Self {
261 Self {
262 averaging_window: 3,
263 judgement_window: 40,
264 threshold: 3.0,
265 min_zscore: 1.5,
266 sensitivity: 70.0,
267 batch_size: None,
268 }
269 }
270
271 pub fn with_averaging_window(mut self, q: usize) -> Self {
273 self.averaging_window = q;
274 self
275 }
276
277 pub fn with_judgement_window(mut self, z: usize) -> Self {
279 self.judgement_window = z;
280 self
281 }
282
283 pub fn with_threshold(mut self, threshold: f64) -> Self {
285 self.threshold = threshold;
286 self
287 }
288
289 pub fn with_min_zscore(mut self, min_zscore: f64) -> Self {
291 self.min_zscore = min_zscore;
292 self
293 }
294
295 pub fn with_sensitivity(mut self, sensitivity: f64) -> Self {
297 self.sensitivity = sensitivity;
298 self
299 }
300
301 pub fn with_batch_size(mut self, batch_size: Option<usize>) -> Self {
303 self.batch_size = batch_size;
304 self
305 }
306
307 fn check_options(&self) -> Result<(), SpectralResidualError> {
309 use SpectralResidualError as E;
310 CountOption::AveragingWindow.check(self.averaging_window as f64)?;
311 CountOption::JudgementWindow.check(self.judgement_window as f64)?;
312 if !self.threshold.is_finite() || self.threshold <= 0.0 {
313 return Err(E::not_above(
314 "threshold",
315 "a finite number > 0",
316 self.threshold,
317 0.0,
318 ));
319 }
320 if !self.min_zscore.is_finite() || self.min_zscore < 0.0 {
321 return Err(E::below(
322 "min_zscore",
323 "a finite number >= 0",
324 self.min_zscore,
325 0.0,
326 ));
327 }
328 if !self.sensitivity.is_finite() || self.sensitivity <= 0.0 || self.sensitivity >= 100.0 {
329 return Err(E::not_between(
330 "sensitivity",
331 "a finite number strictly between 0 and 100",
332 self.sensitivity,
333 0.0,
334 100.0,
335 ));
336 }
337 if let Some(b) = self.batch_size {
338 CountOption::BatchSize.check(b as f64)?;
339 }
340 Ok(())
341 }
342
343 pub fn analyze(&self, series: &[f64]) -> Result<Vec<SrPoint>, SpectralResidualError> {
381 self.check_options()?;
382 if series.len() < MIN_OBSERVATIONS {
383 return Err(SpectralResidualError::TooFewObservations {
384 needed: MIN_OBSERVATIONS,
385 got: series.len(),
386 });
387 }
388 if let Some(index) = series.iter().position(|v| !v.is_finite()) {
389 return Err(SpectralResidualError::ValueNotFinite { index });
390 }
391 let n = series.len();
392 let batch = self.batch_size.unwrap_or(n).min(n);
393 let mut points = Vec::with_capacity(n);
394 let mut start = 0;
395 while start < n {
396 let end = if n - start < 2 * batch {
398 n
399 } else {
400 start + batch
401 };
402 points.extend(self.analyze_batch(&series[start..end], start));
403 start = end;
404 }
405 Ok(points)
406 }
407
408 fn analyze_batch(&self, data: &[f64], offset: usize) -> Vec<SrPoint> {
409 let n = data.len();
410 let saliency = saliency_map(data, self.averaging_window);
411
412 let scores: Vec<f64> = (0..n)
415 .map(|i| {
416 let from = i.saturating_sub(self.judgement_window - 1);
417 let avg = saliency[from..=i].iter().sum::<f64>() / (i + 1 - from) as f64;
418 let avg = avg.max(1e-8);
419 ((saliency[i] - avg) / avg).max(0.0)
420 })
421 .collect();
422
423 let flagged: Vec<bool> = (0..n)
427 .map(|i| {
428 if scores[i] <= self.threshold {
429 return false;
430 }
431 if self.min_zscore == 0.0 || i == 0 {
432 return true;
433 }
434 let from = i.saturating_sub(self.judgement_window);
435 let window = &data[from..i];
436 let mean = window.iter().sum::<f64>() / window.len() as f64;
437 let std = (window.iter().map(|v| (v - mean).powi(2)).sum::<f64>()
438 / window.len() as f64)
439 .sqrt();
440 if std > 0.0 {
441 (data[i] - mean).abs() / std >= self.min_zscore
442 } else {
443 data[i] != mean
444 }
445 })
446 .collect();
447
448 let expected = expected_values(data, &flagged);
449 let residuals: Vec<f64> = (0..n)
450 .filter(|&i| !flagged[i])
451 .map(|i| (data[i] - expected[i]).abs())
452 .collect();
453 let spread = median(&residuals).unwrap_or(0.0) * 1.4826;
457 let z = inverse_normal_cdf(0.5 + self.sensitivity / 200.0);
458 let margin = z * spread;
459
460 (0..n)
461 .map(|i| SrPoint {
462 index: offset + i,
463 value: data[i],
464 saliency: saliency[i],
465 score: scores[i],
466 expected: expected[i],
467 lower: expected[i] - margin,
468 upper: expected[i] + margin,
469 is_anomaly: flagged[i],
470 near_edge: i < EXTENSION || i + EXTENSION >= n,
471 })
472 .collect()
473 }
474}
475
476#[derive(Debug, Clone, PartialEq)]
478pub struct SrPoint {
479 pub index: usize,
481 pub value: f64,
483 pub saliency: f64,
485 pub score: f64,
487 pub expected: f64,
489 pub lower: f64,
491 pub upper: f64,
493 pub is_anomaly: bool,
495 pub near_edge: bool,
511}
512
513fn saliency_map(data: &[f64], q: usize) -> Vec<f64> {
518 let n = data.len();
519 let mut extended = data.to_vec();
520 let m = EXTENSION.min(n - 1);
521 let last = data[n - 1];
522 let slope = (1..=m)
523 .map(|i| (last - data[n - 1 - i]) / i as f64)
524 .sum::<f64>()
525 / m as f64;
526 let predicted = data[n - 1 - m] + slope * m as f64;
527 extended.extend(std::iter::repeat_n(predicted, EXTENSION));
528
529 let spectrum = fft(&extended
530 .iter()
531 .map(|&x| Complex::real(x))
532 .collect::<Vec<_>>());
533 let len = spectrum.len();
534 let log_amp: Vec<f64> = spectrum.iter().map(|z| (z.norm() + 1e-8).ln()).collect();
535 let mut avg = vec![0.0; len];
537 let mut cum = 0.0;
538 let mut cums = Vec::with_capacity(len + 1);
539 cums.push(0.0);
540 for &v in &log_amp {
541 cum += v;
542 cums.push(cum);
543 }
544 for i in 0..len {
545 let from = (i + 1).saturating_sub(q);
546 avg[i] = (cums[i + 1] - cums[from]) / (i + 1 - from) as f64;
547 }
548 let residual_spectrum: Vec<Complex> = spectrum
549 .iter()
550 .enumerate()
551 .map(|(i, z)| {
552 let r = (log_amp[i] - avg[i]).exp();
553 let phase = z.arg();
554 Complex::new(r * phase.cos(), r * phase.sin())
555 })
556 .collect();
557 ifft(&residual_spectrum)
558 .into_iter()
559 .take(n)
560 .map(|z| z.norm())
561 .collect()
562}
563
564fn expected_values(data: &[f64], flagged: &[bool]) -> Vec<f64> {
569 let n = data.len();
570 let mut cleaned = data.to_vec();
571 for i in 0..n {
572 if !flagged[i] {
573 continue;
574 }
575 let left = (0..i).rev().find(|&j| !flagged[j]).map(|j| data[j]);
576 let right = (i + 1..n).find(|&j| !flagged[j]).map(|j| data[j]);
577 cleaned[i] = match (left, right) {
578 (Some(a), Some(b)) => (a + b) / 2.0,
579 (Some(a), None) | (None, Some(a)) => a,
580 (None, None) => data[i],
581 };
582 }
583 let mut spectrum = fft(&cleaned
584 .iter()
585 .map(|&x| Complex::real(x))
586 .collect::<Vec<_>>());
587 let lo = n * 3 / 8;
588 let hi = n * 5 / 8;
589 for (i, z) in spectrum.iter_mut().enumerate() {
590 if i > lo && i < hi {
591 *z = Complex::default();
592 }
593 }
594 ifft(&spectrum).into_iter().map(|z| z.re).collect()
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 fn noise(seed: u64, n: usize) -> Vec<f64> {
602 let mut state = seed;
603 (0..n)
604 .map(|_| {
605 state = state
606 .wrapping_mul(6364136223846793005)
607 .wrapping_add(1442695040888963407);
608 ((state >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
609 })
610 .collect()
611 }
612
613 fn check_shape(points: &[SrPoint], n: usize) {
614 assert_eq!(points.len(), n);
615 for (i, p) in points.iter().enumerate() {
616 assert_eq!(p.index, i);
617 assert!(p.saliency >= 0.0 && p.saliency.is_finite(), "{p:?}");
618 assert!(p.score >= 0.0 && p.score.is_finite(), "{p:?}");
619 assert!(
620 p.expected.is_finite() && p.lower <= p.expected && p.expected <= p.upper,
621 "{p:?}"
622 );
623 }
624 }
625
626 #[test]
627 fn a_spike_on_a_sine_is_the_anomaly() {
628 let mut series: Vec<f64> = noise(3, 120)
629 .iter()
630 .enumerate()
631 .map(|(t, e)| (t as f64 * 0.25).sin() + 0.1 * e)
632 .collect();
633 series[80] += 5.0;
634 let points = SpectralResidual::new().analyze(&series).unwrap();
635 check_shape(&points, 120);
636 assert!(points[80].is_anomaly, "{:?}", points[80]);
637 let flagged: Vec<usize> = points
638 .iter()
639 .filter(|p| p.is_anomaly)
640 .map(|p| p.index)
641 .collect();
642 assert!(flagged.len() <= 3, "flagged {flagged:?}");
643 assert!(points[80].value > points[80].upper);
644 assert!(
646 (points[80].expected - (80.0f64 * 0.25).sin()).abs() < 0.6,
647 "{:?}",
648 points[80]
649 );
650 }
651
652 #[test]
653 fn a_step_is_flagged_where_it_happens() {
654 let series: Vec<f64> = (0..100).map(|t| if t < 60 { 1.0 } else { 6.0 }).collect();
655 let points = SpectralResidual::new().analyze(&series).unwrap();
656 check_shape(&points, 100);
657 let first = points.iter().position(|p| p.is_anomaly);
658 assert_eq!(
659 first,
660 Some(60),
661 "{:?}",
662 points
663 .iter()
664 .filter(|p| p.is_anomaly)
665 .map(|p| p.index)
666 .collect::<Vec<_>>()
667 );
668 }
669
670 #[test]
671 fn a_constant_and_plain_noise_raise_no_anomaly() {
672 let constant = vec![3.0; 50];
673 let points = SpectralResidual::new().analyze(&constant).unwrap();
674 check_shape(&points, 50);
675 assert!(points.iter().all(|p| !p.is_anomaly));
676 assert!(points.iter().all(|p| (p.expected - 3.0).abs() < 1e-9));
677
678 let series = noise(9, 200);
679 let points = SpectralResidual::new().analyze(&series).unwrap();
680 check_shape(&points, 200);
681 let flagged = points.iter().filter(|p| p.is_anomaly).count();
682 assert!(flagged <= 4, "{flagged} of 200 noise points flagged");
683 }
684
685 #[test]
686 fn scoring_is_deterministic_and_batching_keeps_the_shape() {
687 let mut series: Vec<f64> = noise(5, 300).iter().map(|e| 10.0 + e).collect();
688 series[150] = 20.0;
689 series[260] = -5.0;
690 let sr = SpectralResidual::new();
691 let a = sr.analyze(&series).unwrap();
692 let b = sr.analyze(&series).unwrap();
693 assert_eq!(a, b);
694 let batched = sr.with_batch_size(Some(100)).analyze(&series).unwrap();
695 check_shape(&batched, 300);
696 assert!(batched[150].is_anomaly && batched[260].is_anomaly);
697 assert_eq!(batched[200].index, 200);
700 }
701
702 #[test]
703 fn the_band_widens_with_sensitivity() {
704 let series: Vec<f64> = noise(1, 80).iter().map(|e| 5.0 + e).collect();
705 let narrow = SpectralResidual::new()
706 .with_sensitivity(50.0)
707 .analyze(&series)
708 .unwrap();
709 let wide = SpectralResidual::new()
710 .with_sensitivity(99.0)
711 .analyze(&series)
712 .unwrap();
713 for (a, b) in narrow.iter().zip(&wide) {
714 assert!(b.upper - b.lower > a.upper - a.lower);
715 assert_eq!(a.expected, b.expected);
716 }
717 }
718
719 #[test]
723 fn points_near_either_end_of_a_batch_are_marked() {
724 let series: Vec<f64> = (0..200).map(|i| (i % 7) as f64).collect();
725 let points = SpectralResidual::new().analyze(&series).expect("ok");
726 let marked: Vec<usize> = points
727 .iter()
728 .filter(|p| p.near_edge)
729 .map(|p| p.index)
730 .collect();
731 assert_eq!(marked, vec![0, 1, 2, 3, 4, 195, 196, 197, 198, 199]);
732
733 let batched = SpectralResidual::new()
735 .with_batch_size(Some(50))
736 .analyze(&series)
737 .expect("ok");
738 let marked: Vec<usize> = batched
739 .iter()
740 .filter(|p| p.near_edge)
741 .map(|p| p.index)
742 .collect();
743 assert!(marked.contains(&49) && marked.contains(&50), "{marked:?}");
746 assert!(!marked.contains(&75), "{marked:?}");
747 assert_eq!(
748 marked.len(),
749 4 * 10,
750 "four batches, ten marks each: {marked:?}"
751 );
752
753 let mut spiked: Vec<f64> = (0..60).map(|i| (i % 7) as f64).collect();
756 spiked[57] += 30.0;
757 let points = SpectralResidual::new().analyze(&spiked).expect("ok");
758 assert!(points[57].is_anomaly);
759 assert!(points[57].near_edge);
760 }
761
762 #[test]
765 fn a_step_can_flag_the_point_before_the_transition() {
766 let step: Vec<f64> = (0..120)
767 .map(|i| {
768 let jitter = ((i as f64 * 7.3891).sin() * 10000.0).fract().abs() * 0.2;
769 (if i < 60 { 1.0 } else { 6.0 }) + jitter
770 })
771 .collect();
772 let points = SpectralResidual::new().analyze(&step).expect("ok");
773 let flagged: Vec<usize> = points
774 .iter()
775 .filter(|p| p.is_anomaly && !p.near_edge)
776 .map(|p| p.index)
777 .collect();
778 assert!(!flagged.is_empty(), "the transition is detected");
779 assert!(
780 flagged.iter().all(|&i| (59..=61).contains(&i)),
781 "detections sit at the transition or its neighbour: {flagged:?}"
782 );
783 }
784
785 #[test]
788 fn a_refusal_names_the_condition_that_failed() {
789 let ok = vec![1.0; 20];
790 use SpectralResidualError as E;
791
792 assert_eq!(
793 SpectralResidual::new().analyze(&[1.0; 11]),
794 Err(SpectralResidualError::TooFewObservations {
795 needed: MIN_OBSERVATIONS,
796 got: 11
797 })
798 );
799
800 let mut series = vec![1.0; 20];
801 series[7] = f64::INFINITY;
802 assert_eq!(
803 SpectralResidual::new().analyze(&series),
804 Err(SpectralResidualError::ValueNotFinite { index: 7 })
805 );
806
807 assert_eq!(
811 SpectralResidual::new().with_threshold(0.0).analyze(&ok),
812 Err(E::not_above("threshold", "a finite number > 0", 0.0, 0.0))
813 );
814 let sensitivity = |got| {
815 Err(E::not_between(
816 "sensitivity",
817 "a finite number strictly between 0 and 100",
818 got,
819 0.0,
820 100.0,
821 ))
822 };
823 assert_eq!(
824 SpectralResidual::new().with_sensitivity(100.0).analyze(&ok),
825 sensitivity(100.0)
826 );
827 assert_eq!(
828 SpectralResidual::new().with_sensitivity(0.0).analyze(&ok),
829 sensitivity(0.0)
830 );
831 assert_eq!(
832 SpectralResidual::new()
833 .with_batch_size(Some(5))
834 .analyze(&ok),
835 Err(E::below("batch_size", "unset, or at least 12", 5.0, 12.0))
836 );
837 assert_eq!(
838 SpectralResidual::new()
839 .with_averaging_window(0)
840 .analyze(&ok),
841 Err(E::below("averaging_window", ">= 1", 0.0, 1.0))
842 );
843 assert_eq!(
844 SpectralResidual::new()
845 .with_judgement_window(0)
846 .analyze(&ok),
847 Err(E::below("judgement_window", ">= 1", 0.0, 1.0))
848 );
849 assert_eq!(
850 SpectralResidual::new().with_min_zscore(-1.0).analyze(&ok),
851 Err(E::below("min_zscore", "a finite number >= 0", -1.0, 0.0))
852 );
853 assert!(matches!(
855 SpectralResidual::new()
856 .with_threshold(f64::NAN)
857 .analyze(&ok),
858 Err(E::OptionOutOfRange {
859 option: "threshold",
860 min: None,
861 exclusive_min,
862 max: None,
863 exclusive_max: None,
864 got,
865 ..
866 }) if got.is_nan() && exclusive_min == Some(0.0)
867 ));
868
869 let message = SpectralResidual::new()
871 .with_threshold(0.0)
872 .analyze(&ok)
873 .expect_err("refused")
874 .to_string();
875 assert_eq!(message, "threshold must be a finite number > 0");
876 assert!(!message.contains("sensitivity"));
877
878 assert!(SpectralResidual::new()
880 .with_min_zscore(0.0)
881 .analyze(&ok)
882 .is_ok());
883 }
884}