Skip to main content

robust_rs/multivariate/
ogk.rs

1//! The Orthogonalized Gnanadesikan–Kettenring (OGK) robust covariance estimator
2//! (Maronna & Zamar 2002).
3//!
4//! Gnanadesikan & Kettenring (1972) build a robust covariance one *pair* of
5//! variables at a time from the polarization identity, using a robust scale `σ`:
6//! `cov(a, b) = ¼[σ(a + b)² − σ(a − b)²]`. Assembled entrywise this is fast and
7//! highly robust, but the resulting matrix need not be positive definite. OGK
8//! fixes that: scale the variables to unit `σ`, form the GK "correlation"
9//! matrix, rotate into its eigenbasis (where the variables are nearly
10//! uncorrelated), estimate a robust location and scale along each new axis and
11//! transform back. Because the reconstructed covariance is `E · diag(γ²) · Eᵀ`
12//! (conjugated by the diagonal scales), it is **positive definite by
13//! construction**. The rotate–estimate–reconstruct pass is iterated (twice by
14//! default) and a one-step reweighting recovers efficiency.
15//!
16//! OGK is deterministic (no random starts) and `O(n p² log n + p³)`, so it is a
17//! cheap, positive-definite alternative to FAST-MCD and a good warm start for
18//! it. It is equivariant under coordinatewise scaling and permutation and
19//! (only) *approximately* affine equivariant, which is the price of avoiding the
20//! combinatorial min-determinant search.
21
22use ndarray::{Array1, Array2};
23use robust_rs_core::error::RobustError;
24use robust_rs_core::scale::{Qn, ScaleEstimator};
25
26use super::correction::hard_reweight;
27use super::linalg::symmetric_eigen;
28use super::{distances_from, ScatterFit};
29use crate::util::median;
30
31/// A configured OGK estimator, generic over the robust univariate scale (the
32/// default [`Qn`] is Maronna & Zamar's recommendation; the location functional
33/// paired with it is the median).
34#[derive(Debug, Clone, Copy)]
35pub struct Ogk<S = Qn> {
36    /// Robust univariate scale used both to standardize variables and inside the
37    /// pairwise GK covariance.
38    scale: S,
39    /// Number of orthogonalization iterations (Maronna & Zamar recommend `2`).
40    n_iter: usize,
41    /// Whether to apply the one-step reweighting.
42    reweight: bool,
43    /// Reweighting cutoff quantile for the median-adjusted cutoff (Maronna &
44    /// Zamar use `0.9`).
45    reweight_quantile: f64,
46}
47
48impl Default for Ogk<Qn> {
49    fn default() -> Self {
50        Self::new(Qn::default())
51    }
52}
53
54impl<S: ScaleEstimator> Ogk<S> {
55    /// Create an OGK estimator with the given robust scale, two orthogonalization
56    /// iterations and reweighting on.
57    pub fn new(scale: S) -> Self {
58        Self {
59            scale,
60            n_iter: 2,
61            reweight: true,
62            reweight_quantile: 0.9,
63        }
64    }
65
66    /// Set the number of orthogonalization iterations (default `2`).
67    pub fn n_iter(mut self, n: usize) -> Self {
68        self.n_iter = n;
69        self
70    }
71
72    /// Enable or disable the one-step reweighting (default `true`).
73    pub fn reweight(mut self, on: bool) -> Self {
74        self.reweight = on;
75        self
76    }
77
78    /// Fit the estimator to the `n × p` data matrix `x`.
79    pub fn fit(&self, x: &Array2<f64>) -> Result<ScatterFit, RobustError> {
80        let (n, p) = x.dim();
81        if p == 0 {
82            return Err(RobustError::SingularDesign);
83        }
84        if n < p + 1 {
85            return Err(RobustError::InsufficientData {
86                needed: p + 1,
87                got: n,
88            });
89        }
90
91        let (raw_location, raw_scatter) = self.raw(x)?;
92
93        let reweighted = if self.reweight {
94            // OGK uses the median-adjusted cutoff (Maronna & Zamar), which
95            // self-calibrates to the raw scatter's scale.
96            hard_reweight(x, &raw_location, &raw_scatter, self.reweight_quantile, true)?
97        } else {
98            None
99        };
100
101        let (location, scatter, distances, weights) = match reweighted {
102            Some(rw) => (rw.location, rw.scatter, rw.distances, rw.weights),
103            None => {
104                let d = distances_from(x, &raw_location, &raw_scatter)?;
105                (raw_location, raw_scatter, d, Array1::ones(n))
106            }
107        };
108
109        Ok(ScatterFit {
110            location,
111            scatter,
112            distances,
113            weights,
114        })
115    }
116
117    /// The raw (pre-reweighting) OGK location and covariance.
118    ///
119    /// Each iteration works on the current representation `W` (initially `X`):
120    /// standardize columns to unit robust scale, form the pairwise GK matrix,
121    /// take its eigenvectors `E`, project `Z = (W D⁻¹) E` and record the linear
122    /// map `B = D E` that sends `Z`-coordinates back to `W`-coordinates. The maps
123    /// compose into `M = B₁ B₂ … B_k`, so the final robust marginal location `ν`
124    /// and scales `γ` (estimated on the last `Z`) transform back as
125    /// `μ = M ν`, `Σ = M diag(γ²) Mᵀ`, positive definite since `diag(γ²) ≻ 0`.
126    fn raw(&self, x: &Array2<f64>) -> Result<(Array1<f64>, Array2<f64>), RobustError> {
127        let (n, p) = x.dim();
128        let mut w = x.clone();
129        let mut m = Array2::<f64>::eye(p);
130
131        for _ in 0..self.n_iter.max(1) {
132            // Robust column scales; standardize to Y = W · D⁻¹.
133            let mut sigma = Array1::<f64>::zeros(p);
134            for j in 0..p {
135                sigma[j] = self.scale.scale(&w.column(j).to_vec())?.get();
136            }
137            let y = Array2::from_shape_fn((n, p), |(i, j)| w[[i, j]] / sigma[j]);
138
139            // Pairwise GK "correlation" matrix (unit diagonal, since Y has unit
140            // robust scale): U_jk = ¼[σ(Y_j + Y_k)² − σ(Y_j − Y_k)²].
141            let mut u = Array2::<f64>::eye(p);
142            for j in 0..p {
143                for k in (j + 1)..p {
144                    let sum: Vec<f64> = (0..n).map(|i| y[[i, j]] + y[[i, k]]).collect();
145                    let dif: Vec<f64> = (0..n).map(|i| y[[i, j]] - y[[i, k]]).collect();
146                    let sp = self.scale.scale(&sum)?.get();
147                    let sm = self.scale.scale(&dif)?.get();
148                    let ujk = 0.25 * (sp * sp - sm * sm);
149                    u[[j, k]] = ujk;
150                    u[[k, j]] = ujk;
151                }
152            }
153
154            let (_vals, e) = symmetric_eigen(&u)?;
155            let z = y.dot(&e); // Z = Y E
156            let b = Array2::from_shape_fn((p, p), |(i, j)| sigma[i] * e[[i, j]]); // B = D E
157            m = m.dot(&b);
158            w = z;
159        }
160
161        // Robust marginal location/scale in the final coordinates.
162        let mut nu = Array1::<f64>::zeros(p);
163        let mut gamma2 = Array1::<f64>::zeros(p);
164        for l in 0..p {
165            let g = self.scale.scale(&w.column(l).to_vec())?.get();
166            gamma2[l] = g * g;
167            nu[l] = median(&mut w.column(l).to_vec());
168        }
169
170        let mu = m.dot(&nu);
171        // Σ = M diag(γ²) Mᵀ, formed as (M scaled columnwise by γ²) · Mᵀ.
172        let mg = Array2::from_shape_fn((p, p), |(i, j)| m[[i, j]] * gamma2[j]);
173        let sigma = mg.dot(&m.t());
174        Ok((mu, sigma))
175    }
176}