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}