Skip to main content

solow_robust/
scale.rs

1//! Robust scale estimators used to standardize residuals during IRLS.
2
3use solow_distributions::{norm_cdf, norm_pdf, norm_ppf};
4
5/// The default MAD normalization constant `Φ⁻¹(3/4) ≈ 0.6744897…`.
6///
7/// Dividing the raw median absolute deviation by this constant makes the
8/// estimator consistent for the standard deviation at the Gaussian model.
9pub fn mad_c() -> f64 {
10    norm_ppf(0.75)
11}
12
13/// Median of a slice (lower-of-two-middles average for even length).
14///
15/// Uses the standard "average of the two central order statistics" definition
16/// for even-length inputs, matching the reference's `numpy.median`.
17pub fn median(a: &[f64]) -> f64 {
18    let mut v: Vec<f64> = a.to_vec();
19    v.sort_by(|x, y| x.total_cmp(y));
20    let n = v.len();
21    if n == 0 {
22        return f64::NAN;
23    }
24    if n % 2 == 1 {
25        v[n / 2]
26    } else {
27        0.5 * (v[n / 2 - 1] + v[n / 2])
28    }
29}
30
31/// The median absolute deviation, normalized by `c` (default `0.6745…`).
32///
33/// `mad = median(|a − center|) / c`. When `center` is `None` the sample median
34/// of `a` is used; RLM passes `center = Some(0.0)` so the scale is computed
35/// directly from residuals about zero.
36pub fn mad(a: &[f64], c: f64, center: Option<f64>) -> f64 {
37    let cen = center.unwrap_or_else(|| median(a));
38    let dev: Vec<f64> = a.iter().map(|&x| (x - cen).abs() / c).collect();
39    median(&dev)
40}
41
42/// Huber's "proposal 2" scaling for the IRLS weights ([`HuberScale`]).
43///
44/// This is the `scale_est=HuberScale()` option in the reference. It solves, by
45/// fixed-point iteration,
46///
47/// ```text
48/// scale_{i+1}² = (1 / (n·h)) · Σ χ(r / scale_i) · scale_i²
49/// ```
50///
51/// with `χ(x) = x²/2` for `|x| < d` and `d²/2` otherwise, and the consistency
52/// constant `h = (df_resid / n) · (d² + (1 − d²)·Φ(d) − 1/2 − d·φ(d))`.
53#[derive(Clone, Copy, Debug)]
54pub struct HuberScale {
55    /// Tuning constant `d` (default `2.5`).
56    pub d: f64,
57    /// Convergence tolerance on successive scale estimates.
58    pub tol: f64,
59    /// Maximum number of fixed-point iterations.
60    pub maxiter: usize,
61}
62
63impl Default for HuberScale {
64    fn default() -> Self {
65        HuberScale {
66            d: 2.5,
67            tol: 1e-8,
68            maxiter: 30,
69        }
70    }
71}
72
73impl HuberScale {
74    /// Evaluate Huber's proposal-2 scale for the given residuals.
75    ///
76    /// `df_resid` and `nobs` are the model degrees of freedom and the number of
77    /// observations; the iteration is seeded with the MAD of `resid`.
78    pub fn scale(&self, df_resid: f64, nobs: f64, resid: &[f64]) -> f64 {
79        let d = self.d;
80        let h = df_resid / nobs
81            * (d * d + (1.0 - d * d) * norm_cdf(d)
82                - 0.5
83                - d / (2.0 * std::f64::consts::PI).sqrt() * (-0.5 * d * d).exp());
84        let s0 = mad(resid, mad_c(), None);
85
86        let chi_sum = |s: f64| -> f64 {
87            resid
88                .iter()
89                .map(|&r| {
90                    if (r / s).abs() < d {
91                        (r / s).powi(2) / 2.0
92                    } else {
93                        d * d / 2.0
94                    }
95                })
96                .sum::<f64>()
97        };
98
99        let mut prev = f64::INFINITY;
100        let mut cur = s0;
101        let mut niter = 1;
102        while (prev - cur).abs() > self.tol && niter < self.maxiter {
103            let nscale = (1.0 / (nobs * h) * chi_sum(cur) * cur * cur).sqrt();
104            prev = cur;
105            cur = nscale;
106            niter += 1;
107        }
108        cur
109    }
110}
111
112/// Huber's "proposal 2" joint location/scale estimator ([`Huber`]).
113///
114/// Estimates location `μ` and scale `σ` simultaneously for a 1-d sample by the
115/// fixed-point scheme of Venables & Ripley §5.5, using the one-step clipped-mean
116/// location update (`norm = None` in the reference).
117#[derive(Clone, Copy, Debug)]
118pub struct Huber {
119    /// Clipping threshold `c` (default `1.5`).
120    pub c: f64,
121    /// Convergence tolerance.
122    pub tol: f64,
123    /// Maximum number of iterations.
124    pub maxiter: usize,
125}
126
127impl Default for Huber {
128    fn default() -> Self {
129        Huber {
130            c: 1.5,
131            tol: 1e-8,
132            maxiter: 30,
133        }
134    }
135}
136
137impl Huber {
138    /// The consistency constant `γ` used in the scale denominator.
139    fn gamma(&self) -> f64 {
140        let tmp = 2.0 * norm_cdf(self.c) - 1.0;
141        tmp + self.c * self.c * (1.0 - tmp) - 2.0 * self.c * norm_pdf(self.c)
142    }
143
144    /// Jointly estimate location and scale, returning `(mu, scale)`.
145    ///
146    /// Returns `None` if the iteration fails to converge within `maxiter`.
147    pub fn estimate(&self, a: &[f64]) -> Option<(f64, f64)> {
148        let n = (a.len() - 1) as f64;
149        let gamma = self.gamma();
150        let mut mu = median(a);
151        let mut sc = mad(a, mad_c(), None);
152
153        for _ in 0..self.maxiter {
154            let lo = mu - self.c * sc;
155            let hi = mu + self.c * sc;
156            let nmu = a.iter().map(|&x| x.clamp(lo, hi)).sum::<f64>() / a.len() as f64;
157
158            let mut card = 0usize;
159            let mut num = 0.0;
160            for &x in a {
161                if ((x - mu) / sc).abs() <= self.c {
162                    card += 1;
163                    num += (x - nmu).powi(2);
164                }
165            }
166            let denom = n * gamma - (a.len() - card) as f64 * self.c * self.c;
167            let nscale = (num / denom).sqrt();
168
169            let test1 = (sc - nscale).abs() <= nscale * self.tol;
170            let test2 = (mu - nmu).abs() <= nscale * self.tol;
171            if test1 && test2 {
172                return Some((nmu, nscale));
173            }
174            mu = nmu;
175            sc = nscale;
176        }
177        None
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn median_handles_even_and_odd() {
187        assert_eq!(median(&[3.0, 1.0, 2.0]), 2.0);
188        assert_eq!(median(&[1.0, 2.0, 3.0, 4.0]), 2.5);
189    }
190
191    #[test]
192    fn mad_of_standard_normal_constant_is_unit_scale() {
193        // For symmetric data about 0, mad(center=0) == median(|x|)/c.
194        let x = [-2.0, -1.0, 0.0, 1.0, 2.0];
195        let want = 1.0 / mad_c();
196        assert!((mad(&x, mad_c(), Some(0.0)) - want).abs() < 1e-12);
197    }
198
199    #[test]
200    fn mad_default_centers_on_median() {
201        let x = [10.0, 11.0, 12.0, 13.0, 14.0];
202        // median = 12, abs devs = [2,1,0,1,2], median dev = 1, /c.
203        assert!((mad(&x, mad_c(), None) - 1.0 / mad_c()).abs() < 1e-12);
204    }
205}