robust_rs/multivariate/mcd.rs
1//! The Minimum Covariance Determinant estimator (Rousseeuw 1985) via FAST-MCD
2//! (Rousseeuw & Van Driessen 1999).
3//!
4//! MCD is the multivariate analogue of LTS: among all `h`-subsets of the `n`
5//! observations it seeks the one whose sample covariance has the **smallest
6//! determinant** and reports that subset's mean and (scaled) covariance. It is
7//! affine equivariant and (at the max-breakdown coverage `h ≈ ⌊(n+p+1)/2⌋`)
8//! has a 50% breakdown point.
9//!
10//! The exact search is combinatorial, so FAST-MCD approximates it: draw many
11//! random elemental starts, refine each with **C-steps** and keep the
12//! min-determinant result. A C-step is the multivariate concentration step
13//! (given `(μ, Σ)`, take the `h` observations with the smallest Mahalanobis
14//! distance and recompute `(μ, Σ)` on them) and Rousseeuw & Van Driessen prove
15//! it never increases `det Σ`, so the objective decreases monotonically to a
16//! local optimum. This mirrors exactly the FAST-LTS C-step in
17//! [`crate::regression::Lts`], with `det Σ` in place of the trimmed sum of
18//! squares.
19//!
20//! Two corrections turn the raw min-determinant subset into a usable covariance
21//! (Croux & Haesbroeck 1999; Pison et al. 2002):
22//!
23//! 1. a **consistency factor** `c(α, p) = α / F_{χ²_{p+2}}(χ²_{p,α})`, `α = h/n`,
24//! scaling the raw covariance to be Fisher-consistent for `Σ` at the Gaussian;
25//! 2. a one-step **reweighting** (recompute mean/covariance on the points whose
26//! (consistency-corrected) robust distance is within the `χ²_{p,0.975}` cutoff,
27//! with its own consistency factor) which recovers efficiency while keeping
28//! the breakdown point. This *reweighted MCD* (RMCD) is the primary estimate,
29//! matching what R's `robustbase::covMcd` returns by default.
30//!
31//! The finite-sample multiplier that R additionally applies is a documented
32//! deferral; the asymptotic consistency
33//! factor above is implemented and validated by the Gaussian-consistency test.
34
35use ndarray::{Array1, Array2, Axis};
36use rand::seq::SliceRandom;
37use rand::Rng;
38use robust_rs_core::error::RobustError;
39use robust_rs_core::solver::Control;
40
41#[cfg(feature = "rayon")]
42use rayon::prelude::*;
43
44use super::correction::{consistency_factor, hard_reweight};
45use super::linalg::{mahalanobis_sq, mean_covariance, spd_inverse_logdet, spd_logdet};
46use super::{distances_from, RobustScatter};
47use crate::util::substream;
48
49/// Default master seed, so `fit` is reproducible without configuration.
50const DEFAULT_SEED: u64 = 0x11CD_5EED;
51/// C-steps applied to every random start before ranking (cheap pre-refinement).
52const INITIAL_CSTEPS: usize = 2;
53/// How many of the best pre-refined starts are concentrated to convergence.
54const N_KEEP: usize = 10;
55
56/// A configured FAST-MCD estimator.
57///
58/// Reproducible by default (a fixed-seed [`rand_chacha::ChaCha8Rng`] sub-stream
59/// per random start, so results are thread-count invariant even with the
60/// `rayon` feature on); configure with [`Mcd::seed`] / [`Mcd::fit_with_rng`].
61#[derive(Debug, Clone, Copy)]
62pub struct Mcd {
63 /// Coverage as a fraction of `n`; `None` = the max-breakdown default
64 /// `⌊(n + p + 1)/2⌋`.
65 coverage: Option<f64>,
66 /// Number of random elemental starts.
67 n_subsamples: usize,
68 /// Whether to apply the one-step reweighting (RMCD). Off ⇒ report the raw
69 /// (consistency-corrected) MCD as the primary estimate.
70 reweight: bool,
71 /// Reweighting cutoff quantile (default `0.975`).
72 reweight_quantile: f64,
73 /// Master RNG seed.
74 seed: u64,
75 /// Convergence control for the concentration loop.
76 control: Control,
77}
78
79impl Default for Mcd {
80 fn default() -> Self {
81 Self {
82 coverage: None,
83 n_subsamples: 500,
84 reweight: true,
85 reweight_quantile: 0.975,
86 seed: DEFAULT_SEED,
87 control: Control::default(),
88 }
89 }
90}
91
92impl Mcd {
93 /// A max-breakdown MCD with default search settings.
94 pub fn new() -> Self {
95 Self::default()
96 }
97
98 /// Retain a `fraction ∈ (0.5, 1]` of the observations instead of the
99 /// max-breakdown default; the coverage is `h = ⌊fraction · n⌋` (clamped to
100 /// `[p + 1, n]`). Larger `fraction` trades breakdown for efficiency.
101 pub fn coverage(mut self, fraction: f64) -> Self {
102 self.coverage = Some(fraction);
103 self
104 }
105
106 /// Set the number of random elemental starts (default `500`).
107 pub fn n_subsamples(mut self, n: usize) -> Self {
108 self.n_subsamples = n;
109 self
110 }
111
112 /// Enable or disable the one-step reweighting (default `true`).
113 pub fn reweight(mut self, on: bool) -> Self {
114 self.reweight = on;
115 self
116 }
117
118 /// Set the master RNG seed (default is a fixed internal constant).
119 pub fn seed(mut self, seed: u64) -> Self {
120 self.seed = seed;
121 self
122 }
123
124 /// Override the concentration-loop convergence control.
125 pub fn control(mut self, control: Control) -> Self {
126 self.control = control;
127 self
128 }
129
130 /// Fit reproducibly from the configured seed.
131 pub fn fit(&self, x: &Array2<f64>) -> Result<McdFit, RobustError> {
132 self.fit_from_seed(x, self.seed)
133 }
134
135 /// Fit drawing the master seed from a caller-supplied generator.
136 pub fn fit_with_rng<G: Rng>(
137 &self,
138 x: &Array2<f64>,
139 rng: &mut G,
140 ) -> Result<McdFit, RobustError> {
141 self.fit_from_seed(x, rng.random::<u64>())
142 }
143
144 fn fit_from_seed(&self, x: &Array2<f64>, master_seed: u64) -> Result<McdFit, RobustError> {
145 let (n, p) = x.dim();
146 if p == 0 {
147 return Err(RobustError::SingularDesign);
148 }
149 // A non-degenerate covariance needs at least p + 1 points in the subset,
150 // hence at least p + 2 observations for the default h.
151 if n < p + 2 {
152 return Err(RobustError::InsufficientData {
153 needed: p + 2,
154 got: n,
155 });
156 }
157
158 let h = match self.coverage {
159 Some(fraction) => {
160 if !(fraction.is_finite() && fraction > 0.5 && fraction <= 1.0) {
161 return Err(RobustError::InvalidTuning { value: fraction });
162 }
163 ((fraction * n as f64).floor() as usize).clamp(p + 1, n)
164 }
165 None => (n + p).div_ceil(2), // ⌊(n+p+1)/2⌋ = max breakdown; ≥ p+1 since n ≥ p+2
166 };
167
168 // --- Stage 1: many cheap starts, pre-refined by a couple of C-steps. ---
169 let eval_start = |i: u64| -> Option<Candidate> {
170 let mut rng = substream(master_seed, i);
171 let (mu0, cov0) = elemental_start(x, p, &mut rng)?;
172 let mut state = State { mu: mu0, cov: cov0 };
173 for _ in 0..INITIAL_CSTEPS {
174 state = c_step(x, &state, h).ok()?;
175 }
176 let logdet = spd_logdet(&state.cov).ok()?;
177 Some(Candidate { logdet, state })
178 };
179
180 #[cfg(feature = "rayon")]
181 let mut candidates: Vec<Candidate> = (0..self.n_subsamples as u64)
182 .into_par_iter()
183 .filter_map(eval_start)
184 .collect();
185 #[cfg(not(feature = "rayon"))]
186 let mut candidates: Vec<Candidate> = (0..self.n_subsamples as u64)
187 .filter_map(eval_start)
188 .collect();
189
190 if candidates.is_empty() {
191 return Err(RobustError::SubsampleFailure);
192 }
193
194 // --- Stage 2: fully concentrate the best few; keep the global minimum. ---
195 candidates.sort_by(|a, b| a.logdet.total_cmp(&b.logdet));
196 candidates.truncate(N_KEEP);
197
198 let mut best: Option<(f64, State, Vec<usize>)> = None;
199 for cand in candidates {
200 if let Ok((state, subset, logdet)) =
201 concentrate(x, cand.state, h, self.control.max_iter)
202 {
203 if best.as_ref().map_or(true, |(bl, _, _)| logdet < *bl) {
204 best = Some((logdet, state, subset));
205 }
206 }
207 }
208 let (objective, raw_state, mut support) = best.ok_or(RobustError::SubsampleFailure)?;
209 support.sort_unstable();
210
211 // --- Corrections. Raw MCD: subset mean/cov × consistency factor. ---
212 let alpha = h as f64 / n as f64;
213 let c_raw = consistency_factor(alpha, p as f64);
214 let raw_location = raw_state.mu.clone();
215 let raw_scatter = &raw_state.cov * c_raw;
216
217 let breakdown_point = (n - h + 1) as f64 / n as f64;
218
219 // Primary estimate: the one-step reweighted MCD (RMCD) if requested and
220 // enough points survive the cutoff, else the raw estimate.
221 let reweighted = if self.reweight {
222 hard_reweight(
223 x,
224 &raw_location,
225 &raw_scatter,
226 self.reweight_quantile,
227 false,
228 )?
229 } else {
230 None
231 };
232
233 let (location, scatter, distances, weights) = match reweighted {
234 Some(rw) => (rw.location, rw.scatter, rw.distances, rw.weights),
235 None => {
236 let d = distances_from(x, &raw_location, &raw_scatter)?;
237 (
238 raw_location.clone(),
239 raw_scatter.clone(),
240 d,
241 Array1::ones(n),
242 )
243 }
244 };
245
246 Ok(McdFit {
247 location,
248 scatter,
249 distances,
250 weights,
251 raw_location,
252 raw_scatter,
253 support,
254 coverage: h,
255 objective,
256 breakdown_point,
257 })
258 }
259}
260
261/// A running location/scatter state during concentration.
262#[derive(Clone)]
263struct State {
264 mu: Array1<f64>,
265 cov: Array2<f64>,
266}
267
268/// A pre-refined random start, ranked by log-determinant.
269struct Candidate {
270 logdet: f64,
271 state: State,
272}
273
274/// Draw an elemental start: a random `(p + 1)`-subset, extended one point at a
275/// time (from the same shuffle) until its covariance is non-singular. Returns
276/// `None` only if the *whole* data set is rank-deficient.
277fn elemental_start<G: Rng>(
278 x: &Array2<f64>,
279 p: usize,
280 rng: &mut G,
281) -> Option<(Array1<f64>, Array2<f64>)> {
282 let n = x.nrows();
283 let mut idx: Vec<usize> = (0..n).collect();
284 idx.shuffle(rng);
285 let mut k = p + 1;
286 loop {
287 let sub = &idx[..k];
288 let xs = x.select(Axis(0), sub);
289 let (mu, cov) = mean_covariance(&xs);
290 if spd_logdet(&cov).is_ok() {
291 return Some((mu, cov));
292 }
293 k += 1;
294 if k > n {
295 return None; // degenerate: data lies on a lower-dimensional subspace
296 }
297 }
298}
299
300/// One C-step: with the current `(μ, Σ)`, keep the `h` smallest-Mahalanobis
301/// observations and recompute `(μ, Σ)` on them.
302fn c_step(x: &Array2<f64>, state: &State, h: usize) -> Result<State, RobustError> {
303 let (inv, _logdet) = spd_inverse_logdet(&state.cov)?;
304 let d2 = mahalanobis_sq(x, &state.mu, &inv);
305 let subset = h_smallest(&d2, h);
306 let xs = x.select(Axis(0), &subset);
307 let (mu, cov) = mean_covariance(&xs);
308 Ok(State { mu, cov })
309}
310
311/// Concentrate to convergence: apply C-steps until the retained `h`-subset stops
312/// changing (the textbook FAST-MCD criterion, exact, since at a fixed point the
313/// subset and hence `det Σ` are constant) or the iteration cap is hit. Returns
314/// the converged state, its (ascending) subset and its log-determinant.
315fn concentrate(
316 x: &Array2<f64>,
317 start: State,
318 h: usize,
319 max_steps: usize,
320) -> Result<(State, Vec<usize>, f64), RobustError> {
321 let mut state = start;
322 let mut prev: Option<Vec<usize>> = None;
323 for _ in 0..max_steps.max(1) {
324 // Subset selected by the *current* state, then the state it induces.
325 let (inv, _ld) = spd_inverse_logdet(&state.cov)?;
326 let d2 = mahalanobis_sq(x, &state.mu, &inv);
327 let subset = h_smallest(&d2, h);
328 let xs = x.select(Axis(0), &subset);
329 let (mu, cov) = mean_covariance(&xs);
330 let logdet = spd_logdet(&cov)?;
331 state = State { mu, cov };
332 if prev.as_ref() == Some(&subset) {
333 return Ok((state, subset, logdet));
334 }
335 prev = Some(subset);
336 }
337 // Cap hit: report the last state and its subset/objective.
338 let (inv, _ld) = spd_inverse_logdet(&state.cov)?;
339 let d2 = mahalanobis_sq(x, &state.mu, &inv);
340 let subset = h_smallest(&d2, h);
341 let logdet = spd_logdet(&state.cov)?;
342 Ok((state, subset, logdet))
343}
344
345/// Indices of the `h` observations with the smallest values, returned in
346/// ascending index order (so two calls that select the same *set* compare equal
347/// regardless of ties in value). `O(n log n)`.
348fn h_smallest(v: &Array1<f64>, h: usize) -> Vec<usize> {
349 let mut idx: Vec<usize> = (0..v.len()).collect();
350 idx.sort_by(|&a, &b| v[a].total_cmp(&v[b]));
351 idx.truncate(h);
352 idx.sort_unstable();
353 idx
354}
355
356/// A fitted Minimum Covariance Determinant estimate.
357///
358/// Like [`crate::regression::LtsFit`], MCD carries more than the shared
359/// [`ScatterFit`](super::ScatterFit) triple, so it is its own type, but it
360/// *does* implement [`RobustScatter`], reporting the reweighted (RMCD)
361/// location/scatter through it. The raw min-determinant estimate and the
362/// retained subset remain available as fields for auditing.
363#[derive(Debug, Clone)]
364pub struct McdFit {
365 /// Reweighted (RMCD) location: the primary estimate.
366 pub location: Array1<f64>,
367 /// Reweighted (RMCD) covariance: the primary estimate.
368 pub scatter: Array2<f64>,
369 /// Robust Mahalanobis distances w.r.t. the primary `(location, scatter)`.
370 pub distances: Array1<f64>,
371 /// Reweighting weights: `1.0` for observations retained by the `χ²` cutoff,
372 /// `0.0` for those rejected (all `1.0` when reweighting is disabled).
373 pub weights: Array1<f64>,
374 /// Raw (consistency-corrected) MCD location: the best `h`-subset mean.
375 pub raw_location: Array1<f64>,
376 /// Raw (consistency-corrected) MCD covariance: the min-determinant subset
377 /// covariance times the consistency factor.
378 pub raw_scatter: Array2<f64>,
379 /// The retained min-determinant `h`-subset (ascending indices).
380 pub support: Vec<usize>,
381 /// Coverage `h`.
382 pub coverage: usize,
383 /// The MCD objective at the fit: `log det` of the raw (uncorrected) subset
384 /// covariance.
385 pub objective: f64,
386 /// Breakdown point `(n − h + 1)/n`.
387 pub breakdown_point: f64,
388}
389
390impl McdFit {
391 /// Reweighted (RMCD) location.
392 pub fn location(&self) -> &Array1<f64> {
393 &self.location
394 }
395 /// Reweighted (RMCD) covariance.
396 pub fn scatter(&self) -> &Array2<f64> {
397 &self.scatter
398 }
399 /// The raw min-determinant `h`-subset (ascending indices).
400 pub fn support(&self) -> &[usize] {
401 &self.support
402 }
403 /// Breakdown point.
404 pub fn breakdown_point(&self) -> f64 {
405 self.breakdown_point
406 }
407}
408
409impl RobustScatter for McdFit {
410 fn location(&self) -> &Array1<f64> {
411 &self.location
412 }
413 fn scatter(&self) -> &Array2<f64> {
414 &self.scatter
415 }
416 fn distances(&self) -> &Array1<f64> {
417 &self.distances
418 }
419}