stats_claw/algorithms/outlier/mod.rs
1//! Univariate outlier / anomaly detection: z-score, IQR (Tukey fence), and
2//! modified (MAD-based) z-score detectors.
3//!
4//! Each detector scores a one-dimensional sample and flags the points that fall
5//! outside a chosen rule, returning both the per-point scores and the boolean
6//! outlier mask so a caller can threshold or inspect either. The numerics pin the
7//! reference-library conventions the equivalence suite checks:
8//!
9//! * the **z-score** uses the population standard deviation (`ddof = 0`), matching
10//! `scipy.stats.zscore`;
11//! * the **IQR / Tukey-fence** quartiles use `numpy.percentile`'s default
12//! `'linear'` interpolation, so the fences `Q1 − k·IQR .. Q3 + k·IQR` agree with
13//! numpy exactly;
14//! * the **modified z-score** uses the median and the median absolute deviation
15//! (MAD) with the conventional `0.6745` consistency constant (Iglewicz & Hoaglin).
16//!
17//! These base blocks back both the `OutlierDetection` and `AnomalyDetection`
18//! computational methods.
19
20mod stats;
21
22use stats::{mean, median, median_absolute_deviation, percentile_linear, population_std};
23
24/// The consistency constant `Φ⁻¹(0.75) ≈ 0.6745` scaling the MAD so the modified
25/// z-score is comparable to a standard z-score for normally distributed data
26/// (Iglewicz & Hoaglin, 1993).
27const MAD_CONSISTENCY: f64 = 0.674_5;
28
29/// The result of running a detector over a sample: the per-point scores and the
30/// boolean outlier mask, aligned to the input order.
31///
32/// Returned by every detector so a caller can threshold, rank, or simply read off
33/// which observations were flagged. The two vectors always share the input
34/// length; `mask[i]` is the flag for the point with score `scores[i]`.
35#[derive(Debug, Clone, PartialEq)]
36pub struct Detection {
37 /// Per-point detector score, in input order. The score's meaning depends on
38 /// the detector: a signed z-score, a signed modified z-score, or the signed
39 /// distance past the nearer Tukey fence (`0.0` inside the fences).
40 scores: Vec<f64>,
41 /// Per-point outlier flag, in input order: `true` where the point was flagged.
42 mask: Vec<bool>,
43}
44
45impl Detection {
46 /// The per-point detector scores, in input order.
47 #[must_use]
48 pub fn scores(&self) -> &[f64] {
49 &self.scores
50 }
51
52 /// The per-point outlier mask, in input order (`true` = flagged).
53 #[must_use]
54 pub fn mask(&self) -> &[bool] {
55 &self.mask
56 }
57
58 /// The number of points flagged as outliers.
59 #[must_use]
60 pub fn outlier_count(&self) -> usize {
61 self.mask.iter().filter(|&&flagged| flagged).count()
62 }
63}
64
65/// Errors that prevent a detector from scoring a sample.
66///
67/// Returned (never panicked) so callers stay clear of the crate's `unwrap`/`panic`
68/// lint gate and can surface a clean diagnostic.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum OutlierError {
71 /// The sample was empty, so nothing can be scored.
72 EmptyInput,
73 /// An observation was non-finite (`NaN` or `±∞`), which has no valid score.
74 NonFinite,
75 /// The requested threshold (or fence multiplier) was not a finite, positive
76 /// value, so the flagging rule would be ill-defined.
77 InvalidThreshold,
78 /// The sample's spread collapsed to zero (a constant sample for the z-score,
79 /// or a zero MAD for the modified z-score), so a scale-normalised score is
80 /// undefined.
81 ZeroSpread,
82}
83
84impl std::fmt::Display for OutlierError {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 match self {
87 Self::EmptyInput => write!(f, "sample has no observations"),
88 Self::NonFinite => write!(f, "sample contains a non-finite value"),
89 Self::InvalidThreshold => write!(f, "threshold must be finite and positive"),
90 Self::ZeroSpread => write!(f, "sample has zero spread (constant or zero MAD)"),
91 }
92 }
93}
94
95impl std::error::Error for OutlierError {}
96
97/// Validates a sample is non-empty and finite, and a threshold is finite-positive.
98///
99/// Shared front-door check so every detector rejects the same degenerate inputs
100/// before any arithmetic runs.
101///
102/// # Arguments
103///
104/// * `data` — the sample to validate.
105/// * `threshold` — the flagging threshold / fence multiplier to validate.
106///
107/// # Errors
108///
109/// Returns [`OutlierError::EmptyInput`], [`OutlierError::NonFinite`], or
110/// [`OutlierError::InvalidThreshold`].
111fn validate(data: &[f64], threshold: f64) -> Result<(), OutlierError> {
112 if data.is_empty() {
113 return Err(OutlierError::EmptyInput);
114 }
115 if data.iter().any(|v| !v.is_finite()) {
116 return Err(OutlierError::NonFinite);
117 }
118 if !threshold.is_finite() || threshold <= 0.0 {
119 return Err(OutlierError::InvalidThreshold);
120 }
121 Ok(())
122}
123
124/// Flags outliers by the standard z-score `z = (x − mean) / std` (`|z| > threshold`).
125///
126/// Uses the population standard deviation (`ddof = 0`), so the returned scores
127/// reproduce `scipy.stats.zscore` exactly; the mask flags every point whose
128/// absolute z-score strictly exceeds `threshold` (3.0 is the textbook default).
129///
130/// # Arguments
131///
132/// * `data` — the one-dimensional sample to score; must be non-empty and finite.
133/// * `threshold` — the absolute z-score above which a point is flagged; must be
134/// finite and `> 0`.
135///
136/// # Returns
137///
138/// A [`Detection`] whose `scores` are the signed z-scores and whose `mask` flags
139/// `|z| > threshold`.
140///
141/// # Errors
142///
143/// Returns [`OutlierError::EmptyInput`], [`OutlierError::NonFinite`],
144/// [`OutlierError::InvalidThreshold`], or [`OutlierError::ZeroSpread`] (constant
145/// sample).
146///
147/// # Examples
148///
149/// ```
150/// use stats_claw::algorithms::outlier::zscore_detect;
151///
152/// // One point sits far from the rest; with threshold 1.5 it is flagged.
153/// // (With only five points the maximal |z| caps near 2.0, so 1.5 isolates it.)
154/// let det = zscore_detect(&[1.0, 2.0, 3.0, 4.0, 100.0], 1.5)?;
155/// assert_eq!(det.mask(), &[false, false, false, false, true]);
156/// // The flagged point's z-score reproduces scipy.stats.zscore.
157/// let z = det.scores().last().copied().unwrap_or(f64::NAN);
158/// assert!((z - 1.999_342_861_818_962_6).abs() < 1e-9, "z was {z}");
159/// # Ok::<(), stats_claw::algorithms::outlier::OutlierError>(())
160/// ```
161pub fn zscore_detect(data: &[f64], threshold: f64) -> Result<Detection, OutlierError> {
162 validate(data, threshold)?;
163 let std = population_std(data);
164 if std <= 0.0 {
165 return Err(OutlierError::ZeroSpread);
166 }
167 let m = mean(data);
168 let scores: Vec<f64> = data.iter().map(|&x| (x - m) / std).collect();
169 let mask: Vec<bool> = scores.iter().map(|z| z.abs() > threshold).collect();
170 Ok(Detection { scores, mask })
171}
172
173/// Flags outliers by the Tukey fence `Q1 − k·IQR .. Q3 + k·IQR` (the IQR rule).
174///
175/// The quartiles use `numpy.percentile`'s default `'linear'` interpolation, so the
176/// fences agree with a numpy reference exactly. A point is flagged when it falls
177/// strictly outside the closed fence interval; its score is the signed distance to
178/// the nearer fence (`0.0` inside the fences, positive above the upper fence,
179/// negative below the lower fence).
180///
181/// # Arguments
182///
183/// * `data` — the one-dimensional sample to score; must be non-empty and finite.
184/// * `k` — the fence multiplier (`1.5` is Tukey's classic value, `3.0` flags only
185/// "far" outliers); must be finite and `> 0`.
186///
187/// # Returns
188///
189/// A [`Detection`] whose `scores` are the signed distances past the nearer fence
190/// and whose `mask` flags points outside the fences.
191///
192/// # Errors
193///
194/// Returns [`OutlierError::EmptyInput`], [`OutlierError::NonFinite`], or
195/// [`OutlierError::InvalidThreshold`] (non-finite or non-positive `k`).
196///
197/// # Examples
198///
199/// ```
200/// use stats_claw::algorithms::outlier::iqr_detect;
201///
202/// // The extreme value lies far above the upper Tukey fence (k = 1.5).
203/// let det = iqr_detect(&[1.0, 2.0, 3.0, 4.0, 100.0], 1.5)?;
204/// assert_eq!(det.mask(), &[false, false, false, false, true]);
205/// assert_eq!(det.outlier_count(), 1);
206/// # Ok::<(), stats_claw::algorithms::outlier::OutlierError>(())
207/// ```
208pub fn iqr_detect(data: &[f64], k: f64) -> Result<Detection, OutlierError> {
209 validate(data, k)?;
210 let mut sorted = data.to_vec();
211 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
212 let q1 = percentile_linear(&sorted, 0.25);
213 let q3 = percentile_linear(&sorted, 0.75);
214 let iqr = q3 - q1;
215 let lower = k.mul_add(-iqr, q1);
216 let upper = k.mul_add(iqr, q3);
217 let scores: Vec<f64> = data
218 .iter()
219 .map(|&x| {
220 if x > upper {
221 x - upper
222 } else if x < lower {
223 x - lower
224 } else {
225 0.0
226 }
227 })
228 .collect();
229 let mask: Vec<bool> = data.iter().map(|&x| x < lower || x > upper).collect();
230 Ok(Detection { scores, mask })
231}
232
233/// Flags outliers by the modified (MAD-based) z-score `M = 0.6745·(x − med)/MAD`.
234///
235/// Robust alternative to the mean/std z-score: it uses the median and the median
236/// absolute deviation, which a few extreme points cannot inflate, so the very
237/// outliers being sought do not mask themselves. A point is flagged when
238/// `|M| > threshold` (Iglewicz & Hoaglin recommend `3.5`).
239///
240/// # Arguments
241///
242/// * `data` — the one-dimensional sample to score; must be non-empty and finite.
243/// * `threshold` — the absolute modified-z above which a point is flagged; must be
244/// finite and `> 0`.
245///
246/// # Returns
247///
248/// A [`Detection`] whose `scores` are the signed modified z-scores and whose
249/// `mask` flags `|M| > threshold`.
250///
251/// # Errors
252///
253/// Returns [`OutlierError::EmptyInput`], [`OutlierError::NonFinite`],
254/// [`OutlierError::InvalidThreshold`], or [`OutlierError::ZeroSpread`] (zero MAD,
255/// i.e. more than half the sample shares the median).
256///
257/// # Examples
258///
259/// ```
260/// use stats_claw::algorithms::outlier::modified_zscore_detect;
261///
262/// // The MAD-based score flags the extreme point at the 3.5 threshold.
263/// let det = modified_zscore_detect(&[1.0, 2.0, 3.0, 4.0, 100.0], 3.5)?;
264/// assert_eq!(det.mask(), &[false, false, false, false, true]);
265/// # Ok::<(), stats_claw::algorithms::outlier::OutlierError>(())
266/// ```
267pub fn modified_zscore_detect(data: &[f64], threshold: f64) -> Result<Detection, OutlierError> {
268 validate(data, threshold)?;
269 let mad = median_absolute_deviation(data);
270 if mad <= 0.0 {
271 return Err(OutlierError::ZeroSpread);
272 }
273 let med = median(data);
274 let scale = MAD_CONSISTENCY / mad;
275 let scores: Vec<f64> = data.iter().map(|&x| (x - med) * scale).collect();
276 let mask: Vec<bool> = scores.iter().map(|m| m.abs() > threshold).collect();
277 Ok(Detection { scores, mask })
278}
279
280#[cfg(test)]
281#[path = "tests.rs"]
282mod tests;