1use u_numflow::fourier::{fft, ifft, Complex};
32use u_numflow::special::inverse_normal_cdf;
33use u_numflow::stats::median;
34
35pub const MIN_OBSERVATIONS: usize = 12;
37
38const EXTENSION: usize = 5;
42
43#[derive(Debug, Clone, PartialEq)]
48pub struct SpectralResidual {
49 pub averaging_window: usize,
52 pub judgement_window: usize,
55 pub threshold: f64,
57 pub min_zscore: f64,
62 pub sensitivity: f64,
66 pub batch_size: Option<usize>,
71}
72
73impl Default for SpectralResidual {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79impl SpectralResidual {
80 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 pub fn with_averaging_window(mut self, q: usize) -> Self {
95 self.averaging_window = q;
96 self
97 }
98
99 pub fn with_judgement_window(mut self, z: usize) -> Self {
101 self.judgement_window = z;
102 self
103 }
104
105 pub fn with_threshold(mut self, threshold: f64) -> Self {
107 self.threshold = threshold;
108 self
109 }
110
111 pub fn with_min_zscore(mut self, min_zscore: f64) -> Self {
113 self.min_zscore = min_zscore;
114 self
115 }
116
117 pub fn with_sensitivity(mut self, sensitivity: f64) -> Self {
119 self.sensitivity = sensitivity;
120 self
121 }
122
123 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 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 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 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 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 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#[derive(Debug, Clone, PartialEq)]
250pub struct SrPoint {
251 pub index: usize,
253 pub value: f64,
255 pub saliency: f64,
257 pub score: f64,
259 pub expected: f64,
261 pub lower: f64,
263 pub upper: f64,
265 pub is_anomaly: bool,
267}
268
269fn 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 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
320fn 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 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 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}