qualia_core_db/solvers/statistics/
anomaly.rs1use super::descriptive::{mean, quantile_in_place, std_dev};
11use super::distributions::{chi_squared, students_t};
12use super::robust::median_abs_deviation;
13
14pub fn z_score_outliers(values: &[f64], threshold: f64) -> Option<Vec<usize>> {
17 if values.len() < 2 {
18 return None;
19 }
20 let mu = mean(values)?;
21 let sd = std_dev(values, true)?;
22 if sd <= 0.0 {
23 return None;
24 }
25 Some(
26 values
27 .iter()
28 .enumerate()
29 .filter(|(_, &x)| ((x - mu) / sd).abs() > threshold)
30 .map(|(i, _)| i)
31 .collect(),
32 )
33}
34
35pub fn modified_z_score_outliers(values: &[f64], threshold: f64) -> Option<Vec<usize>> {
39 if values.len() < 2 {
40 return None;
41 }
42 let mut sorted = values.to_vec();
44 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
45 let med = super::descriptive::median_sorted(&sorted)?;
46 let mad = median_abs_deviation(values, true)?;
48 if mad <= 0.0 {
49 return None;
50 }
51 Some(
52 values
53 .iter()
54 .enumerate()
55 .filter(|(_, &x)| ((x - med) / mad).abs() > threshold)
58 .map(|(i, _)| i)
59 .collect(),
60 )
61}
62
63pub fn tukey_fences(values: &[f64], k: f64) -> Option<(f64, f64)> {
66 if values.len() < 4 {
67 return None;
68 }
69 let mut v = values.to_vec();
70 let q1 = quantile_in_place(&mut v, 0.25)?;
71 let q3 = quantile_in_place(&mut v, 0.75)?;
72 let iqr = q3 - q1;
73 Some((q1 - k * iqr, q3 + k * iqr))
74}
75
76pub fn iqr_outliers(values: &[f64], k: f64) -> Option<Vec<usize>> {
78 let (lo, hi) = tukey_fences(values, k)?;
79 Some(
80 values
81 .iter()
82 .enumerate()
83 .filter(|(_, &x)| x < lo || x > hi)
84 .map(|(i, _)| i)
85 .collect(),
86 )
87}
88
89#[derive(Debug, Clone, Copy, PartialEq)]
91pub struct GrubbsResult {
92 pub index: usize,
94 pub statistic: f64,
96 pub critical: f64,
98 pub is_outlier: bool,
100}
101
102pub fn grubbs_test(values: &[f64], alpha: f64) -> Option<GrubbsResult> {
106 let n = values.len();
107 if n < 3 {
108 return None;
109 }
110 let mu = mean(values)?;
111 let sd = std_dev(values, true)?;
112 if sd <= 0.0 {
113 return None;
114 }
115 let (index, statistic) = values
116 .iter()
117 .enumerate()
118 .map(|(i, &x)| (i, (x - mu).abs() / sd))
119 .fold((0usize, f64::NEG_INFINITY), |best, cur| {
120 if cur.1 > best.1 {
121 cur
122 } else {
123 best
124 }
125 });
126
127 let nf = n as f64;
130 let t = students_t::quantile(1.0 - alpha / (2.0 * nf), nf - 2.0);
131 let t2 = t * t;
132 let critical = ((nf - 1.0) / nf.sqrt()) * (t2 / (nf - 2.0 + t2)).sqrt();
133
134 Some(GrubbsResult {
135 index,
136 statistic,
137 critical,
138 is_outlier: statistic > critical,
139 })
140}
141
142pub fn mahalanobis_sq(x: &[f64], mean_vec: &[f64], inv_cov: &[f64]) -> Option<f64> {
146 let d = x.len();
147 if d == 0 || mean_vec.len() != d || inv_cov.len() != d * d {
148 return None;
149 }
150 let diff: Vec<f64> = x.iter().zip(mean_vec).map(|(&xi, &mi)| xi - mi).collect();
152 let mut acc = 0.0;
153 for i in 0..d {
154 let mut row = 0.0;
155 for j in 0..d {
156 row += inv_cov[i * d + j] * diff[j];
157 }
158 acc += diff[i] * row;
159 }
160 Some(acc)
161}
162
163pub fn is_multivariate_outlier(
167 x: &[f64],
168 mean_vec: &[f64],
169 inv_cov: &[f64],
170 alpha: f64,
171) -> Option<bool> {
172 let d2 = mahalanobis_sq(x, mean_vec, inv_cov)?;
173 let threshold = chi_squared::quantile(1.0 - alpha, x.len() as f64);
174 Some(d2 > threshold)
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn z_score_flags_the_obvious_spike() {
183 let data = [10.0, 11.0, 9.0, 10.5, 9.5, 10.2, 100.0];
184 let out = z_score_outliers(&data, 2.0).unwrap();
185 assert_eq!(out, vec![6]); }
187
188 #[test]
189 fn modified_z_is_robust_to_masking() {
190 let data = [1.0, 2.0, 1.5, 1.8, 2.2, 50.0, 52.0];
193 let out = modified_z_score_outliers(&data, 3.5).unwrap();
194 assert!(
195 out.contains(&5) && out.contains(&6),
196 "both spikes flagged: {out:?}"
197 );
198 }
199
200 #[test]
201 fn tukey_fences_and_iqr_outliers() {
202 let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 100.0];
203 let (lo, hi) = tukey_fences(&data, 1.5).unwrap();
204 assert!(hi < 100.0 && lo < 1.0);
205 let out = iqr_outliers(&data, 1.5).unwrap();
206 assert!(out.contains(&8));
207 }
208
209 #[test]
210 fn grubbs_detects_and_gates() {
211 let data = [2.0, 3.0, 2.5, 2.8, 3.1, 2.9, 12.0];
212 let g = grubbs_test(&data, 0.05).unwrap();
213 assert_eq!(g.index, 6);
214 assert!(g.is_outlier, "G {} vs crit {}", g.statistic, g.critical);
215 let clean = [2.0, 3.0, 2.5, 2.8, 3.1, 2.9, 2.7];
217 assert!(!grubbs_test(&clean, 0.05).unwrap().is_outlier);
218 }
219
220 #[test]
221 fn mahalanobis_reduces_to_scaled_distance_for_identity() {
222 let x = [3.0, 4.0];
224 let mu = [0.0, 0.0];
225 let inv_cov = [1.0, 0.0, 0.0, 1.0];
226 assert!((mahalanobis_sq(&x, &mu, &inv_cov).unwrap() - 25.0).abs() < 1e-9);
227 }
228
229 #[test]
230 fn multivariate_gate_flags_a_far_point() {
231 let inv_cov = [1.0, 0.0, 0.0, 1.0];
233 let mu = [0.0, 0.0];
234 assert_eq!(
235 is_multivariate_outlier(&[5.0, 5.0], &mu, &inv_cov, 0.01),
236 Some(true)
237 );
238 assert_eq!(
239 is_multivariate_outlier(&[0.1, -0.1], &mu, &inv_cov, 0.01),
240 Some(false)
241 );
242 }
243
244 #[test]
245 fn fails_closed_on_degenerate() {
246 assert!(z_score_outliers(&[5.0], 3.0).is_none());
247 assert!(z_score_outliers(&[2.0, 2.0, 2.0], 3.0).is_none()); assert!(mahalanobis_sq(&[1.0], &[0.0, 0.0], &[1.0]).is_none()); }
250}