Skip to main content

nereids_core/
stats.rs

1//! Small robust-statistics helpers shared across NEREIDS crates.
2//!
3//! These live in `nereids-core` (the dependency-free foundation crate) so that
4//! the same estimator is computed identically everywhere instead of being
5//! re-implemented per crate.  Each helper returns a plain `Option`/value
6//! rather than a formatted error, so the calling crate can map degenerate
7//! inputs onto its own error type / message wording without `nereids-core`
8//! having to know about `IoError`, `FittingError`, etc.
9//!
10//! Precondition: callers pass **finite** inputs (they enforce this via
11//! [`crate::validation`] before calling in).  The helpers nevertheless sort
12//! with [`f64::total_cmp`], which is a total order even over NaN (NaN sorts
13//! last), so a violated precondition degrades to a deterministic — if
14//! meaningless — result instead of a `partial_cmp` panic path.
15
16/// Consistency factor converting a median absolute deviation into a Gaussian
17/// standard-deviation estimate: `sigma ≈ MAD_TO_SIGMA * MAD`.
18///
19/// Derivation: for X ~ N(μ, σ²), the MAD about the median satisfies
20/// P(|X − μ| ≤ MAD) = 1/2, i.e. MAD = σ·Φ⁻¹(3/4) where Φ is the standard
21/// normal CDF.  The consistency factor is therefore
22///
23///   1 / Φ⁻¹(3/4) = 1 / 0.674489750196081… = 1.482602218505601…
24///
25/// so that `MAD_TO_SIGMA * MAD` is an unbiased estimate of σ for Gaussian
26/// data while staying robust (50% breakdown point) against outliers.
27pub const MAD_TO_SIGMA: f64 = 1.482_602_218_505_601_8;
28
29/// Median of a slice of values.
30///
31/// Copies the input and sorts with [`f64::total_cmp`] (total, deterministic —
32/// see the module-level precondition note on NaN).  For even `n` the median
33/// is the midpoint mean of the two central order statistics.
34///
35/// # Arguments
36/// * `values` — Sample values.  Precondition: finite (callers enforce via
37///   [`crate::validation`]).
38///
39/// # Returns
40/// `Some(median)`, or `None` if `values` is empty.
41pub fn median(values: &[f64]) -> Option<f64> {
42    if values.is_empty() {
43        return None;
44    }
45    let mut sorted = values.to_vec();
46    sorted.sort_unstable_by(f64::total_cmp);
47    let n = sorted.len();
48    if n % 2 == 1 {
49        Some(sorted[n / 2])
50    } else {
51        // Even n: midpoint mean of the two central order statistics.
52        Some((sorted[n / 2 - 1] + sorted[n / 2]) / 2.0)
53    }
54}
55
56/// Median absolute deviation of a slice about a given center.
57///
58/// Computes `median(|v − center|)` with the same conventions as [`median`]
59/// (total-order sort, even-`n` midpoint mean).  Note this returns the *raw*
60/// MAD — multiply by [`MAD_TO_SIGMA`] to obtain a Gaussian-consistent σ
61/// estimate.
62///
63/// # Arguments
64/// * `values` — Sample values.  Precondition: finite (callers enforce via
65///   [`crate::validation`]).
66/// * `center` — Center to take deviations about (typically the sample
67///   median).
68///
69/// # Returns
70/// `Some(mad)`, or `None` if `values` is empty.
71pub fn median_abs_deviation(values: &[f64], center: f64) -> Option<f64> {
72    if values.is_empty() {
73        return None;
74    }
75    let deviations: Vec<f64> = values.iter().map(|v| (v - center).abs()).collect();
76    median(&deviations)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn median_empty_is_none() {
85        assert_eq!(median(&[]), None);
86    }
87
88    #[test]
89    fn median_single_element() {
90        assert_eq!(median(&[3.5]), Some(3.5));
91    }
92
93    #[test]
94    fn median_odd_n() {
95        // Unsorted input; median of {1, 2, 9} is 2.
96        assert_eq!(median(&[9.0, 1.0, 2.0]), Some(2.0));
97    }
98
99    #[test]
100    fn median_even_n_midpoint_mean() {
101        // Sorted {1, 2, 3, 10} → midpoint mean of 2 and 3 = 2.5.
102        assert_eq!(median(&[10.0, 3.0, 1.0, 2.0]), Some(2.5));
103    }
104
105    #[test]
106    fn mad_empty_is_none() {
107        assert_eq!(median_abs_deviation(&[], 0.0), None);
108    }
109
110    #[test]
111    fn mad_basic() {
112        // Values {1, 2, 4, 8}, center 3 → |dev| = {2, 1, 1, 5} → sorted
113        // {1, 1, 2, 5} → midpoint mean of 1 and 2 = 1.5.
114        assert_eq!(median_abs_deviation(&[1.0, 2.0, 4.0, 8.0], 3.0), Some(1.5));
115    }
116
117    #[test]
118    fn mad_about_own_median() {
119        // Values {1, 2, 3, 4, 100}, median 3 → |dev| = {2, 1, 0, 1, 97}
120        // → median 1: the outlier does not blow up the scale estimate.
121        let vals = [1.0, 2.0, 3.0, 4.0, 100.0];
122        let med = median(&vals).unwrap();
123        assert_eq!(med, 3.0);
124        assert_eq!(median_abs_deviation(&vals, med), Some(1.0));
125    }
126
127    #[test]
128    fn mad_of_constant_slice_is_zero() {
129        let vals = [7.0; 5];
130        let med = median(&vals).unwrap();
131        assert_eq!(median_abs_deviation(&vals, med), Some(0.0));
132    }
133
134    #[test]
135    fn mad_to_sigma_matches_inverse_normal_quantile() {
136        // Φ(1/MAD_TO_SIGMA) must equal 3/4: check via the error function
137        // relation Φ(x) = (1 + erf(x/√2))/2 evaluated numerically with a
138        // rational-approximation-free identity — instead verify the inverse
139        // direction: for the standard normal, P(|X| <= q) = 1/2 at
140        // q = 1/MAD_TO_SIGMA ≈ 0.67449.  We hard-check the published value
141        // of Φ⁻¹(3/4) to 15 significant digits.
142        let q = 1.0 / MAD_TO_SIGMA;
143        assert!((q - 0.674_489_750_196_081_7).abs() < 1e-15);
144    }
145}