Skip to main content

robust_rs/multivariate/
tyler.rs

1//! Tyler's M-estimator of shape (Tyler 1987): a distribution-free scatter
2//! estimator for elliptically-symmetric data, normalized to unit determinant.
3//!
4//! Tyler's estimator depends on the data only through the *directions*
5//! `rᵢ / ‖rᵢ‖` of the centered observations, so it is the "most robust"
6//! distribution-free estimator of shape: it is consistent for the shape matrix
7//! of *any* elliptical distribution without knowing the radial density (no
8//! Gaussianity assumed). It solves the fixed-point equation
9//!
10//! ```text
11//! V ∝ Σᵢ (rᵢ rᵢᵀ) / (rᵢᵀ V⁻¹ rᵢ),      rᵢ = xᵢ − μ,
12//! ```
13//!
14//! normalized to `det V = 1`, by the iteration `V ← normalize(Σ …)`. Because the
15//! iterate is renormalized to unit determinant every step, the scalar in front
16//! of the sum is immaterial; only the *shape* is identified, so the returned
17//! [`TylerFit::shape`] carries no scale (`det = 1`). Mahalanobis distances
18//! from it still rank observations correctly for outlier detection.
19//!
20//! Location is not estimated jointly; a robust centre must be supplied. The
21//! default is the coordinatewise median.
22
23use ndarray::{Array1, Array2};
24use robust_rs_core::error::RobustError;
25use robust_rs_core::solver::Control;
26
27use super::distances_from;
28use super::linalg::{center, spd_inverse_logdet, spd_logdet};
29use crate::util::median;
30
31/// A configured Tyler shape estimator.
32#[derive(Debug, Clone, Default)]
33pub struct Tyler {
34    /// Location `μ`; `None` = the coordinatewise median.
35    location: Option<Array1<f64>>,
36    /// Convergence control for the fixed-point iteration.
37    control: Control,
38}
39
40impl Tyler {
41    /// A Tyler estimator centering at the coordinatewise median.
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    /// Center at a supplied location instead of the coordinatewise median.
47    pub fn location(mut self, loc: Array1<f64>) -> Self {
48        self.location = Some(loc);
49        self
50    }
51
52    /// Override the fixed-point convergence control.
53    pub fn control(mut self, control: Control) -> Self {
54        self.control = control;
55        self
56    }
57
58    /// Fit the shape estimator to the `n × p` data matrix `x`.
59    pub fn fit(&self, x: &Array2<f64>) -> Result<TylerFit, RobustError> {
60        let (n, p) = x.dim();
61        if p == 0 {
62            return Err(RobustError::SingularDesign);
63        }
64        if n < p + 1 {
65            return Err(RobustError::InsufficientData {
66                needed: p + 1,
67                got: n,
68            });
69        }
70
71        let loc = match &self.location {
72            Some(l) => {
73                if l.len() != p {
74                    return Err(RobustError::DimensionMismatch {
75                        expected: p,
76                        got: l.len(),
77                    });
78                }
79                l.clone()
80            }
81            None => coordinatewise_median(x),
82        };
83        let r = center(x, &loc); // residuals rᵢ = xᵢ − μ
84
85        // Iterate V ← normalize_to_unit_det( Σᵢ rᵢrᵢᵀ / (rᵢᵀ V⁻¹ rᵢ) ).
86        let mut v = Array2::<f64>::eye(p);
87        let tiny = 1e-12;
88        let mut converged = false;
89        for _ in 0..self.control.max_iter {
90            let (v_inv, _logdet) = spd_inverse_logdet(&v)?;
91
92            let mut s = Array2::<f64>::zeros((p, p));
93            let mut used = 0usize;
94            for i in 0..n {
95                let ri = r.row(i);
96                let vinv_ri = v_inv.dot(&ri);
97                let d = ri.dot(&vinv_ri); // rᵢᵀ V⁻¹ rᵢ
98                if d <= tiny {
99                    continue; // residual at (essentially) the centre: skip 0/0 term
100                }
101                used += 1;
102                for a in 0..p {
103                    let ra = ri[a];
104                    for b in 0..p {
105                        s[[a, b]] += ra * ri[b] / d;
106                    }
107                }
108            }
109            if used <= p {
110                // Too few non-degenerate directions to identify a full-rank shape.
111                return Err(RobustError::InsufficientData {
112                    needed: p + 1,
113                    got: used,
114                });
115            }
116
117            // Normalize to unit determinant (the leading scalar cancels here).
118            let logdet = spd_logdet(&s)?;
119            let scale = (logdet / p as f64).exp(); // det(S)^{1/p}
120            let v_next = s.mapv(|x| x / scale);
121
122            let num: f64 = (&v_next - &v).iter().map(|x| x * x).sum();
123            let den: f64 = v.iter().map(|x| x * x).sum::<f64>().max(1e-30);
124            v = v_next;
125            if (num / den).sqrt() <= self.control.tol {
126                converged = true;
127                break;
128            }
129        }
130        if !converged {
131            return Err(RobustError::NonConvergence {
132                iters: self.control.max_iter,
133            });
134        }
135
136        let distances = distances_from(x, &loc, &v)?;
137        Ok(TylerFit {
138            location: loc,
139            shape: v,
140            distances,
141        })
142    }
143}
144
145/// A fitted Tyler shape estimate.
146///
147/// Tyler estimates **shape only**: [`shape`](Self::shape) is normalized to unit
148/// determinant and carries no scale. A consequence is that its Mahalanobis
149/// distances are correct for *ranking* observations but are **not**
150/// `χ²_p`-calibrated; for `N(0, Σ)` data their *squared* distances are the
151/// Gaussian `χ²_p` distances times the unidentified factor `det(Σ)^{1/p}` (so the
152/// distances themselves scale by `det(Σ)^{1/(2p)}`) and for non-Gaussian
153/// elliptical data the `χ²` shape does not apply at all. `TylerFit` therefore
154/// does **not** implement [`RobustScatter`](crate::multivariate::RobustScatter)
155/// and offers no default Gaussian outlier map, mirroring how the regression
156/// [`LtsFit`](crate::regression::LtsFit) declines the ρ-derived efficiency it
157/// cannot report. Rank with [`distances`](Self::distances), or opt in explicitly
158/// via [`outliers_assuming_chi2_radial`](Self::outliers_assuming_chi2_radial).
159#[derive(Debug, Clone)]
160pub struct TylerFit {
161    location: Array1<f64>,
162    shape: Array2<f64>,
163    distances: Array1<f64>,
164}
165
166impl TylerFit {
167    /// The robust centre `μ̂` (the supplied location, or the coordinatewise median).
168    pub fn location(&self) -> &Array1<f64> {
169        &self.location
170    }
171
172    /// The unit-determinant shape matrix `V̂` (`det V̂ = 1`; no scale identified).
173    pub fn shape(&self) -> &Array2<f64> {
174        &self.shape
175    }
176
177    /// Robust Mahalanobis distances from the shape. Correct for **ranking**
178    /// observations, but not `χ²_p`-calibrated (the scale is unidentified).
179    pub fn distances(&self) -> &Array1<f64> {
180        &self.distances
181    }
182
183    /// Flag outliers by comparing the Tyler distances to the Gaussian cutoff
184    /// `√χ²_{p, quantile}`.
185    ///
186    /// As the name says, this assumes a `χ²`-radial model (e.g. Gaussian) (the
187    /// assumption Tyler's shape estimate exists to avoid) and disregards the
188    /// unidentified squared-distance factor `det(Σ)^{1/p}`, so the cutoff is
189    /// meaningful only under that model. It is an opt-in for the Gaussian case;
190    /// for distribution-free use, threshold [`distances`](Self::distances)
191    /// directly. An *empirical* quantile flags a **fixed proportion** of points
192    /// (≈ `1 − quantile`) whether or not any are outliers, unlike this absolute
193    /// `χ²` cutoff, which flags ≈ none of clean Gaussian data.
194    pub fn outliers_assuming_chi2_radial(&self, quantile: f64) -> Vec<bool> {
195        let p = self.shape.nrows() as f64;
196        let cut = super::chi2::chi2_quantile(quantile, p).sqrt();
197        self.distances.iter().map(|&d| d > cut).collect()
198    }
199}
200
201/// Coordinatewise median: the median of each column of `x`.
202fn coordinatewise_median(x: &Array2<f64>) -> Array1<f64> {
203    let p = x.ncols();
204    Array1::from_shape_fn(p, |j| median(&mut x.column(j).to_vec()))
205}