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}