Skip to main content

robust_rs_core/scale/
s_scale.rs

1//! The S-estimator of scale.
2
3use crate::error::RobustError;
4use crate::rho::RhoFunction;
5use crate::scale::ScaleEstimator;
6use crate::solver::Control;
7use crate::theory::gauss_hermite;
8use crate::types::Scale;
9
10/// The M-estimate of scale ("S-scale"): the `s > 0` solving
11/// `(1/n) Σ ρ(rᵢ/s) = δ`.
12///
13/// With a bounded, redescending `ρ` and `δ = ρ.rho_sup()/2` this is the
14/// 50%-breakdown scale that S- and MM-regression minimize; with `δ = E_Φ[ρ]` it
15/// is Fisher-consistent for σ at the Gaussian (for the biweight the S-step
16/// tuning `c ≈ 1.547` makes the two coincide). Generic over the loss, but the
17/// loss **must** have bounded `ρ` (`rho_sup().is_some()`): an unbounded `ρ`
18/// gives zero breakdown (a single `rᵢ → ∞` drives `s` without limit) so
19/// `new` rejects it with `RobustError::UnboundedLoss`.
20///
21/// Solved by the standard fixed-point iteration
22/// `s ← s·√(mean_i ρ(rᵢ/s) / δ)` from a MAD start; `g(s) = (1/n)Σρ(rᵢ/s)` is
23/// monotone decreasing, so the root is unique.
24#[derive(Debug, Clone, Copy)]
25pub struct SScale<R: RhoFunction> {
26    rho: R,
27    delta: f64,
28    control: Control,
29}
30
31impl<R: RhoFunction> SScale<R> {
32    /// Create an S-scale for loss `rho` with consistency target `0 < delta < sup ρ`.
33    ///
34    /// Rejects an unbounded loss (`rho_sup() == None`) with
35    /// `RobustError::UnboundedLoss`, since it cannot define a high-breakdown
36    /// scale and a `delta` outside `(0, sup ρ)` (for which the scale equation
37    /// has no root) with `RobustError::InvalidTuning`.
38    pub fn new(rho: R, delta: f64) -> Result<Self, RobustError> {
39        // Boundedness is the S-scale's defining precondition, not an
40        // optimization: with unbounded ρ the equation still has a unique root,
41        // but that root has zero breakdown, the opposite of the point.
42        let sup = rho.rho_sup().ok_or(RobustError::UnboundedLoss)?;
43        if delta.is_finite() && delta > 0.0 && delta < sup {
44            Ok(Self {
45                rho,
46                delta,
47                control: Control::default(),
48            })
49        } else {
50            Err(RobustError::InvalidTuning { value: delta })
51        }
52    }
53
54    /// Create a Fisher-consistent S-scale by setting `δ = E_Φ[ρ]`, evaluated by
55    /// Gauss–Hermite quadrature with `quad_points` nodes.
56    pub fn fisher_consistent(rho: R, quad_points: usize) -> Result<Self, RobustError> {
57        let (nodes, weights) = gauss_hermite(quad_points);
58        let delta = nodes
59            .iter()
60            .zip(&weights)
61            .map(|(&x, &w)| w * rho.rho(x))
62            .sum();
63        Self::new(rho, delta)
64    }
65
66    /// Override the default convergence control.
67    pub fn with_control(mut self, control: Control) -> Self {
68        self.control = control;
69        self
70    }
71
72    /// The consistency target `δ`.
73    pub fn delta(&self) -> f64 {
74        self.delta
75    }
76}
77
78impl<R: RhoFunction> ScaleEstimator for SScale<R> {
79    fn scale(&self, residuals: &[f64]) -> Result<Scale, RobustError> {
80        let n = residuals.len();
81        if n == 0 {
82            return Err(RobustError::InsufficientData { needed: 1, got: 0 });
83        }
84
85        // MAD start (about the median); any positive scale seeds the fixed point.
86        let mut buf = residuals.to_vec();
87        let med = median(&mut buf);
88        for (b, &r) in buf.iter_mut().zip(residuals) {
89            *b = (r - med).abs();
90        }
91        let mut s = 1.482_602_218_505_602 * median(&mut buf);
92        if !(s.is_finite() && s > 0.0) {
93            return Err(RobustError::DegenerateScale); // ≥ half the residuals tied
94        }
95
96        // Fixed point on (1/n) Σ ρ(rᵢ/s) = δ:  s ← s·√(mean ρ(rᵢ/s) / δ).
97        for _ in 0..self.control.max_iter {
98            let mean_rho = residuals.iter().map(|&r| self.rho.rho(r / s)).sum::<f64>() / n as f64;
99            let s_next = s * (mean_rho / self.delta).sqrt();
100            if !(s_next.is_finite() && s_next > 0.0) {
101                return Err(RobustError::DegenerateScale);
102            }
103            if (s_next - s).abs() <= self.control.tol * s {
104                return Scale::new(s_next);
105            }
106            s = s_next;
107        }
108        Err(RobustError::NonConvergence {
109            iters: self.control.max_iter,
110        })
111    }
112}
113
114/// Median via in-place total-order sort.
115fn median(v: &mut [f64]) -> f64 {
116    v.sort_unstable_by(f64::total_cmp);
117    let n = v.len();
118    let mid = n / 2;
119    if n % 2 == 1 {
120        v[mid]
121    } else {
122        0.5 * (v[mid - 1] + v[mid])
123    }
124}