Skip to main content

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;