Skip to main content

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}