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}