Skip to main content

qualia_core_db/solvers/statistics/
anomaly.rs

1//! Anomaly / outlier detection — folded into the statistics foundation (it is just
2//! statistics with a decision rule). Univariate detectors (z-score, robust modified
3//! z-score, Tukey fences, Grubbs' test) plus a multivariate Mahalanobis gate. Each
4//! reuses the existing descriptive / robust / distribution primitives and follows the
5//! module's `Option` idiom (`None` on degenerate input — empty, zero spread).
6//!
7//! Mission note: outliers are flagged as **candidates for human attention**, never
8//! auto-acted-on — a deviation is a signal, not a verdict.
9
10use super::descriptive::{mean, quantile_in_place, std_dev};
11use super::distributions::{chi_squared, students_t};
12use super::robust::median_abs_deviation;
13
14/// Indices whose standard score `|x − μ| / σ` exceeds `threshold` (e.g. 3.0). `None`
15/// if there are fewer than 2 points or the spread is zero.
16pub 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
35/// Robust outliers via the **modified z-score** (Iglewicz–Hoaglin):
36/// `0.6745 · (x − median) / MAD`. Resistant to the very outliers it detects — the mean
37/// and SD are not. Flags indices exceeding `threshold` (3.5 is the standard choice).
38pub fn modified_z_score_outliers(values: &[f64], threshold: f64) -> Option<Vec<usize>> {
39    if values.len() < 2 {
40        return None;
41    }
42    // Median.
43    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    // Scaled MAD (already ×1.4826 → consistent with σ for normal data).
47    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            // 0.6745·(x−med)/MAD; with the *scaled* MAD the constant folds in, so we
56            // compare (x−med)/MAD directly against the threshold.
57            .filter(|(_, &x)| ((x - med) / mad).abs() > threshold)
58            .map(|(i, _)| i)
59            .collect(),
60    )
61}
62
63/// Tukey fences `[Q1 − k·IQR, Q3 + k·IQR]` (`k = 1.5` mild, `3.0` extreme). `None` if
64/// fewer than 4 points.
65pub 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
76/// Indices outside the Tukey fences.
77pub 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/// The result of Grubbs' test for a single outlier.
90#[derive(Debug, Clone, Copy, PartialEq)]
91pub struct GrubbsResult {
92    /// Index of the most extreme point.
93    pub index: usize,
94    /// Grubbs statistic `G = max|x − μ| / σ`.
95    pub statistic: f64,
96    /// Two-sided critical value at the requested `alpha`.
97    pub critical: f64,
98    /// `true` iff `G > critical` — the point is a statistically significant outlier.
99    pub is_outlier: bool,
100}
101
102/// **Grubbs' test** for the single most extreme value (assumes approximate normality).
103/// `alpha` is the significance level (e.g. 0.05). Reuses the Student-t quantile for the
104/// critical value. `None` if fewer than 3 points or zero spread.
105pub 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    // Two-sided critical value: G_crit = ((n-1)/√n)·√( t² / (n-2 + t²) ),
128    // t = t-quantile(1 − alpha/(2n)) with n-2 d.f.
129    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
142/// Squared **Mahalanobis distance** `(x − μ)ᵀ Σ⁻¹ (x − μ)`. The caller supplies the
143/// inverse covariance `inv_cov` (row-major `d×d`, obtained from the linear-algebra
144/// substrate) — this keeps the metric self-contained. `None` on a dimension mismatch.
145pub 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    // diff = x − μ ; then diffᵀ · inv_cov · diff.
151    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
163/// Multivariate outlier gate: a point is flagged when its squared Mahalanobis distance
164/// exceeds the χ²(d) upper-`alpha` quantile (the standard `d`-dimensional rule). `None`
165/// on a dimension mismatch.
166pub 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]); // the 100 is the spike
186    }
187
188    #[test]
189    fn modified_z_is_robust_to_masking() {
190        // Two large outliers can inflate SD enough to *mask* themselves under plain z;
191        // the MAD-based rule still catches them.
192        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        // A clean sample yields no outlier.
216        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        // inv_cov = I → Mahalanobis² = Euclidean².
223        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        // Unit covariance; a point 5σ out in 2-D is well past the χ²(2) 0.99 quantile.
232        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()); // zero spread
248        assert!(mahalanobis_sq(&[1.0], &[0.0, 0.0], &[1.0]).is_none()); // dim mismatch
249    }
250}