Skip to main content

common_function/scalars/anomaly/
zscore.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! `anomaly_score_zscore` window function — Z-Score-based anomaly scoring.
16//!
17//! Algorithm: `score = |x - mean(window)| / stddev(window)`
18//!
19//! When stddev = 0 (constant window), returns 0.0 if value equals mean,
20//! or +inf otherwise.
21
22use std::fmt::Debug;
23use std::ops::Range;
24use std::sync::Arc;
25
26use arrow::array::{Array, ArrayRef, Float64Array};
27use arrow::datatypes::{DataType, Field, FieldRef};
28use datafusion_common::{DataFusionError, Result, ScalarValue};
29use datafusion_expr::{PartitionEvaluator, Signature, Volatility, WindowUDFImpl};
30use datafusion_functions_window_common::field::WindowUDFFieldArgs;
31use datafusion_functions_window_common::partition::PartitionEvaluatorArgs;
32
33use crate::helper::NUMERICS;
34use crate::scalars::anomaly::utils::{anomaly_ratio, cast_to_f64, collect_window_values};
35
36/// Minimum valid samples for zscore (stddev requires n >= 2).
37const MIN_SAMPLES: usize = 2;
38
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40pub struct AnomalyScoreZscore {
41    signature: Signature,
42}
43
44impl AnomalyScoreZscore {
45    pub fn new() -> Self {
46        Self {
47            signature: Signature::uniform(1, NUMERICS.to_vec(), Volatility::Immutable),
48        }
49    }
50}
51
52impl WindowUDFImpl for AnomalyScoreZscore {
53    fn name(&self) -> &str {
54        "anomaly_score_zscore"
55    }
56
57    fn signature(&self) -> &Signature {
58        &self.signature
59    }
60
61    fn field(&self, field_args: WindowUDFFieldArgs) -> Result<FieldRef> {
62        Ok(Arc::new(Field::new(
63            field_args.name(),
64            DataType::Float64,
65            true, // nullable
66        )))
67    }
68
69    fn partition_evaluator(
70        &self,
71        _partition_evaluator_args: PartitionEvaluatorArgs,
72    ) -> Result<Box<dyn PartitionEvaluator>> {
73        Ok(Box::new(AnomalyScoreZscoreEvaluator { current_row: 0 }))
74    }
75}
76
77#[derive(Debug)]
78struct AnomalyScoreZscoreEvaluator {
79    /// Tracks the current row index within the partition.
80    current_row: usize,
81}
82
83impl PartitionEvaluator for AnomalyScoreZscoreEvaluator {
84    fn uses_window_frame(&self) -> bool {
85        true
86    }
87
88    fn supports_bounded_execution(&self) -> bool {
89        false
90    }
91
92    fn evaluate(&mut self, values: &[ArrayRef], range: &Range<usize>) -> Result<ScalarValue> {
93        let values_f64 = cast_to_f64(&values[0])?;
94        let array = values_f64
95            .as_any()
96            .downcast_ref::<Float64Array>()
97            .ok_or_else(|| {
98                DataFusionError::Internal(format!(
99                    "Expected Float64Array, got: {:?}",
100                    values_f64.data_type()
101                ))
102            })?;
103
104        // Use the tracked current row index — correct for any window frame.
105        let current_idx = self.current_row;
106        self.current_row += 1;
107
108        if current_idx >= array.len()
109            || !array.is_valid(current_idx)
110            || !array.value(current_idx).is_finite()
111        {
112            return Ok(ScalarValue::Float64(None));
113        }
114        let current_value = array.value(current_idx);
115
116        let window_values = collect_window_values(array, range);
117        if window_values.len() < MIN_SAMPLES {
118            return Ok(ScalarValue::Float64(None));
119        }
120
121        let n = window_values.len() as f64;
122        let mean = window_values.iter().sum::<f64>() / n;
123
124        let variance = window_values
125            .iter()
126            .map(|x| (x - mean).powi(2))
127            .sum::<f64>()
128            / n;
129        let stddev = variance.sqrt();
130
131        let distance = (current_value - mean).abs();
132        let score = anomaly_ratio(distance, stddev);
133        Ok(ScalarValue::Float64(Some(score)))
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use std::sync::Arc;
140
141    use arrow::array::Float64Array;
142    use datafusion_expr::WindowUDF;
143
144    use super::*;
145
146    fn eval_zscore(values: &[Option<f64>], range: Range<usize>) -> ScalarValue {
147        let array = Arc::new(Float64Array::from(values.to_vec())) as ArrayRef;
148        let current_row = range.end.saturating_sub(1);
149        let mut evaluator = AnomalyScoreZscoreEvaluator { current_row };
150        evaluator.evaluate(&[array], &range).unwrap()
151    }
152
153    #[test]
154    fn test_basic_outlier() {
155        // Use enough normal points so the outlier doesn't dominate stddev
156        let values: Vec<Option<f64>> = vec![
157            Some(1.0),
158            Some(2.0),
159            Some(1.5),
160            Some(2.5),
161            Some(1.0),
162            Some(2.0),
163            Some(1.5),
164            Some(2.5),
165            Some(1.0),
166            Some(2.0),
167            Some(100.0),
168        ];
169        let len = values.len();
170        let result = eval_zscore(&values, 0..len);
171        match result {
172            ScalarValue::Float64(Some(score)) => assert!(score > 3.0, "score={score}"),
173            other => panic!("expected Some(score), got {other:?}"),
174        }
175    }
176
177    #[test]
178    fn test_constant_sequence() {
179        let values: Vec<Option<f64>> = vec![Some(5.0); 10];
180        let result = eval_zscore(&values, 0..10);
181        match result {
182            ScalarValue::Float64(Some(score)) => assert_eq!(score, 0.0),
183            other => panic!("expected Some(0.0), got {other:?}"),
184        }
185    }
186
187    #[test]
188    fn test_outlier_in_window_gives_finite_score() {
189        // 5.0 is included in the window, so stddev is non-zero → finite positive score
190        let values: Vec<Option<f64>> = vec![Some(1.0), Some(1.0), Some(1.0), Some(1.0), Some(5.0)];
191        let result = eval_zscore(&values, 0..5);
192        match result {
193            ScalarValue::Float64(Some(score)) => assert!(score > 0.0),
194            other => panic!("expected Some(score>0), got {other:?}"),
195        }
196    }
197
198    #[test]
199    fn test_all_null() {
200        let values: Vec<Option<f64>> = vec![None, None, None, None];
201        let result = eval_zscore(&values, 0..4);
202        assert_eq!(result, ScalarValue::Float64(None));
203    }
204
205    #[test]
206    fn test_two_samples_returns_score() {
207        // zscore min_samples=2, so two points should produce a score
208        // [1.0, 2.0]: mean=1.5, stddev=0.5, zscore(2.0) = |2.0-1.5|/0.5 = 1.0
209        let values: Vec<Option<f64>> = vec![Some(1.0), Some(2.0)];
210        let result = eval_zscore(&values, 0..2);
211        match result {
212            ScalarValue::Float64(Some(score)) => {
213                assert!((score - 1.0).abs() < 1e-10, "score={score}")
214            }
215            other => panic!("expected Some(1.0), got {other:?}"),
216        }
217    }
218
219    #[test]
220    fn test_insufficient_samples() {
221        // Single point is insufficient even for zscore
222        let values: Vec<Option<f64>> = vec![Some(1.0)];
223        let result = eval_zscore(&values, 0..1);
224        assert_eq!(result, ScalarValue::Float64(None));
225    }
226
227    #[test]
228    fn test_nan_inf_skipped() {
229        let values: Vec<Option<f64>> = vec![
230            Some(1.0),
231            Some(f64::NAN),
232            Some(f64::INFINITY),
233            Some(2.0),
234            Some(3.0),
235        ];
236        let result = eval_zscore(&values, 0..5);
237        match result {
238            ScalarValue::Float64(Some(_)) => {}
239            other => panic!("expected Some(score), got {other:?}"),
240        }
241    }
242
243    #[test]
244    fn test_current_row_null() {
245        let values: Vec<Option<f64>> = vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0), None];
246        let result = eval_zscore(&values, 0..5);
247        assert_eq!(result, ScalarValue::Float64(None));
248    }
249
250    #[test]
251    fn test_known_zscore() {
252        // For data [0, 0, 0, 0, 10]:
253        // mean = 2.0, variance = (4+4+4+4+64)/5 = 16, stddev = 4.0
254        // zscore of 10 = |10-2|/4 = 2.0
255        let values: Vec<Option<f64>> = vec![Some(0.0), Some(0.0), Some(0.0), Some(0.0), Some(10.0)];
256        let result = eval_zscore(&values, 0..5);
257        match result {
258            ScalarValue::Float64(Some(score)) => {
259                assert!((score - 2.0).abs() < 1e-10, "score={score}")
260            }
261            other => panic!("expected Some(2.0), got {other:?}"),
262        }
263    }
264
265    #[test]
266    fn test_udwf_creation() {
267        let udwf = WindowUDF::from(AnomalyScoreZscore::new());
268        assert_eq!(udwf.name(), "anomaly_score_zscore");
269    }
270
271    /// Verify the current_row counter increments correctly across sequential
272    /// evaluate() calls, and that each call scores the right row.
273    #[test]
274    fn test_sequential_evaluate_calls() {
275        // Data: [0, 0, 0, 0, 10]
276        // Full window for all rows (ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
277        // mean=2.0, stddev=4.0 for all rows
278        let values: Vec<Option<f64>> = vec![Some(0.0), Some(0.0), Some(0.0), Some(0.0), Some(10.0)];
279        let array = Arc::new(Float64Array::from(values)) as ArrayRef;
280        let full_range = 0..5;
281        let vals = std::slice::from_ref(&array);
282
283        let mut evaluator = AnomalyScoreZscoreEvaluator { current_row: 0 };
284
285        // Row 0: value=0.0, zscore = |0-2|/4 = 0.5
286        let r0 = evaluator.evaluate(vals, &full_range).unwrap();
287        assert_eq!(r0, ScalarValue::Float64(Some(0.5)));
288
289        // Row 1: value=0.0, same score
290        let r1 = evaluator.evaluate(vals, &full_range).unwrap();
291        assert_eq!(r1, ScalarValue::Float64(Some(0.5)));
292
293        // Row 4: skip ahead by consuming rows 2 and 3
294        let _ = evaluator.evaluate(vals, &full_range).unwrap();
295        let _ = evaluator.evaluate(vals, &full_range).unwrap();
296
297        // Row 4: value=10.0, zscore = |10-2|/4 = 2.0
298        let r4 = evaluator.evaluate(vals, &full_range).unwrap();
299        assert_eq!(r4, ScalarValue::Float64(Some(2.0)));
300    }
301
302    /// Verify correct behavior with a centered window frame where the current
303    /// row is NOT at range.end - 1. This is the regression test for the P1
304    /// review finding about range.end - 1 assumption.
305    #[test]
306    fn test_centered_window_frame() {
307        // Data: [0, 0, 10, 0, 0]
308        // Centered window of size 3: ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
309        let values: Vec<Option<f64>> = vec![Some(0.0), Some(0.0), Some(10.0), Some(0.0), Some(0.0)];
310        let array = Arc::new(Float64Array::from(values)) as ArrayRef;
311
312        // Score row 2 (value=10.0) with window [1..4) = {0.0, 10.0, 0.0}
313        // mean = 10/3 ≈ 3.333, variance = ((10/3)^2 + (20/3)^2 + (10/3)^2)/3
314        // = (100/9 + 400/9 + 100/9) / 3 = 600/27 ≈ 22.222
315        // stddev ≈ 4.714, zscore = |10 - 3.333| / 4.714 ≈ 1.414
316        let mut evaluator = AnomalyScoreZscoreEvaluator { current_row: 2 };
317        let result = evaluator
318            .evaluate(std::slice::from_ref(&array), &(1..4))
319            .unwrap();
320        match result {
321            ScalarValue::Float64(Some(score)) => {
322                // sqrt(2) ≈ 1.4142
323                assert!(
324                    (score - std::f64::consts::SQRT_2).abs() < 1e-10,
325                    "expected ~1.414, got {score}"
326                );
327            }
328            other => panic!("expected Some(score), got {other:?}"),
329        }
330    }
331}