common_function/scalars/anomaly/
zscore.rs1use 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
36const 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, )))
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 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 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 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 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 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 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 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 #[test]
274 fn test_sequential_evaluate_calls() {
275 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 let r0 = evaluator.evaluate(vals, &full_range).unwrap();
287 assert_eq!(r0, ScalarValue::Float64(Some(0.5)));
288
289 let r1 = evaluator.evaluate(vals, &full_range).unwrap();
291 assert_eq!(r1, ScalarValue::Float64(Some(0.5)));
292
293 let _ = evaluator.evaluate(vals, &full_range).unwrap();
295 let _ = evaluator.evaluate(vals, &full_range).unwrap();
296
297 let r4 = evaluator.evaluate(vals, &full_range).unwrap();
299 assert_eq!(r4, ScalarValue::Float64(Some(2.0)));
300 }
301
302 #[test]
306 fn test_centered_window_frame() {
307 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 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 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}