Skip to main content

robust_rs/multivariate/
m_scatter.rs

1//! A monotone M-estimator of multivariate location and scatter (Maronna 1976):
2//! the direct multivariate analogue of the regression M-estimator, reusing a
3//! [`RhoFunction`] weight.
4//!
5//! Where regression M-estimation reweights each observation by `ρ.weight(rᵢ/s)`
6//! on its scalar residual, scatter M-estimation reweights by `ρ.weight(dᵢ)` on
7//! its **Mahalanobis distance** `dᵢ = √((xᵢ − μ)ᵀ Σ⁻¹ (xᵢ − μ))`, then solves the
8//! coupled fixed point
9//!
10//! ```text
11//! μ  = Σᵢ wᵢ xᵢ / Σᵢ wᵢ,        Σ ∝ Σᵢ wᵢ (xᵢ − μ)(xᵢ − μ)ᵀ / Σᵢ wᵢ,
12//! wᵢ = ρ.weight(dᵢ / median_j dⱼ),
13//! ```
14//! by iteratively reweighted covariance. The weight argument is the distance
15//! divided by its sample median, so the *weights* are scale-free (affine
16//! equivariant); the overall size of `Σ` is then pinned separately by matching
17//! the median squared distance to `χ²_{p, 0.5}`, which makes the estimator
18//! Fisher-consistent for `Σ` at the Gaussian.
19//!
20//! With the default monotone [`Huber`] weight the objective is convex, so (like
21//! regression M-estimation) it converges from any reasonable start and is
22//! unique. Its breakdown point is only about `1/(p + 1)`, though (Maronna 1976):
23//! a *single* well-placed high-leverage outlier can carry a monotone scatter
24//! M-estimate, exactly as in the regression case. For a high-breakdown scatter,
25//! use [`crate::multivariate::Mcd`]. A redescending `ρ` would need a
26//! high-breakdown start (MCD/OGK), the multivariate echo of why S seeds MM.
27
28use ndarray::{Array1, Array2};
29use robust_rs_core::error::RobustError;
30use robust_rs_core::rho::{Huber, RhoFunction};
31use robust_rs_core::scale::{Mad, ScaleEstimator};
32use robust_rs_core::solver::Control;
33
34use super::chi2::chi2_quantile;
35use super::linalg::{center, mahalanobis_sq, spd_inverse_logdet};
36use super::{distances_from, ScatterFit};
37use crate::util::median;
38
39/// A configured monotone M-estimator of location and scatter, generic over the
40/// [`RhoFunction`] weight (default [`Huber`], `k = 1.345`).
41#[derive(Debug, Clone, Copy)]
42pub struct MScatter<R = Huber> {
43    /// The loss whose IRLS weight `ρ.weight(·)` downweights distant points.
44    rho: R,
45    /// Convergence control for the reweighting iteration.
46    control: Control,
47}
48
49impl Default for MScatter<Huber> {
50    fn default() -> Self {
51        Self::new(Huber::default())
52    }
53}
54
55impl<R: RhoFunction> MScatter<R> {
56    /// Create an M-scatter estimator with the given loss and default controls.
57    pub fn new(rho: R) -> Self {
58        Self {
59            rho,
60            control: Control::default(),
61        }
62    }
63
64    /// Override the convergence control.
65    pub fn control(mut self, control: Control) -> Self {
66        self.control = control;
67        self
68    }
69
70    /// Fit the estimator to the `n × p` data matrix `x`.
71    pub fn fit(&self, x: &Array2<f64>) -> Result<ScatterFit, RobustError> {
72        let (n, p) = x.dim();
73        if p == 0 {
74            return Err(RobustError::SingularDesign);
75        }
76        if n < p + 1 {
77            return Err(RobustError::InsufficientData {
78                needed: p + 1,
79                got: n,
80            });
81        }
82        let chi_med = chi2_quantile(0.5, p as f64); // E[d²] median target at N(μ,Σ)
83
84        // Robust start: coordinatewise median location, diagonal MAD² scatter.
85        let mut mu = Array1::from_shape_fn(p, |j| median(&mut x.column(j).to_vec()));
86        let mut sigma = {
87            let mad = Mad::default();
88            let mut d = Array2::<f64>::eye(p);
89            for j in 0..p {
90                let s = mad.scale(&x.column(j).to_vec())?.get();
91                d[[j, j]] = s * s;
92            }
93            d
94        };
95
96        let mut weights = Array1::<f64>::ones(n);
97        let mut converged = false;
98        for _ in 0..self.control.max_iter {
99            let (inv, _logdet) = spd_inverse_logdet(&sigma)?;
100            let d2 = mahalanobis_sq(x, &mu, &inv);
101            let dist = d2.mapv(f64::sqrt);
102
103            // Scale-free weight argument: distance over its sample median.
104            let med = {
105                let mut buf = dist.to_vec();
106                median(&mut buf)
107            };
108            if med <= 0.0 || !med.is_finite() {
109                return Err(RobustError::DegenerateScale);
110            }
111            weights = dist.mapv(|di| self.rho.weight(di / med));
112            let wsum: f64 = weights.sum();
113            if wsum <= 0.0 || !wsum.is_finite() {
114                return Err(RobustError::DegenerateScale);
115            }
116
117            // Weighted location.
118            let mut mu_next = Array1::<f64>::zeros(p);
119            for i in 0..n {
120                let wi = weights[i];
121                for j in 0..p {
122                    mu_next[j] += wi * x[[i, j]];
123                }
124            }
125            mu_next.mapv_inplace(|v| v / wsum);
126
127            // Weighted covariance about the new location.
128            let centered = center(x, &mu_next);
129            let mut cov = Array2::<f64>::zeros((p, p));
130            for i in 0..n {
131                let wi = weights[i];
132                for a in 0..p {
133                    let ca = centered[[i, a]];
134                    for b in 0..p {
135                        cov[[a, b]] += wi * ca * centered[[i, b]];
136                    }
137                }
138            }
139            cov.mapv_inplace(|v| v / wsum);
140
141            // Pin the scale: rescale so median squared distance = χ²_{p,0.5}.
142            let (cov_inv, _ld) = spd_inverse_logdet(&cov)?;
143            let d2c = mahalanobis_sq(x, &mu_next, &cov_inv);
144            let m = {
145                let mut buf = d2c.to_vec();
146                median(&mut buf)
147            };
148            if m > 0.0 {
149                cov.mapv_inplace(|v| v * m / chi_med);
150            }
151
152            // Convergence on the combined (μ, Σ) change.
153            let dmu: f64 = (&mu_next - &mu).iter().map(|v| v * v).sum();
154            let dsig: f64 = (&cov - &sigma).iter().map(|v| v * v).sum();
155            let base: f64 = mu.iter().map(|v| v * v).sum::<f64>()
156                + sigma.iter().map(|v| v * v).sum::<f64>()
157                + 1e-30;
158            mu = mu_next;
159            sigma = cov;
160            if ((dmu + dsig) / base).sqrt() <= self.control.tol {
161                converged = true;
162                break;
163            }
164        }
165        if !converged {
166            return Err(RobustError::NonConvergence {
167                iters: self.control.max_iter,
168            });
169        }
170
171        let distances = distances_from(x, &mu, &sigma)?;
172        Ok(ScatterFit {
173            location: mu,
174            scatter: sigma,
175            distances,
176            weights,
177        })
178    }
179}