robust_rs/regression/lts.rs
1//! Least Trimmed Squares (Rousseeuw 1984) via FAST-LTS (Rousseeuw & Van
2//! Driessen 2006).
3//!
4//! LTS minimizes the sum of the `h` smallest squared residuals, a
5//! high-breakdown fit that simply *ignores* the `n − h` largest residuals. It
6//! reuses the shared elemental-resampling search in [`super::subsample`];
7//! the only LTS-specific pieces are its objective (Σ of the `h` smallest
8//! squared residuals) and its C-step (refit OLS on the `h` smallest-residual
9//! observations). Both derive residuals from a single `residuals(β)` binding,
10//! so the objective and the step can never select on differently-computed
11//! residuals.
12//!
13//! **Convergence.** The shared concentration loop stops on a relative change in
14//! the objective below `tol`. FAST-LTS's textbook criterion is instead "the
15//! retained `h`-subset stops changing"; the two coincide here, because at a fixed
16//! point the objective is *exactly* constant, so a below-`tol` change means the
17//! subset has (bar sub-`tol` ties) stabilized. LTS is used as a high-breakdown
18//! initializer, for which an ε-neighborhood of the fixed point suffices.
19
20use ndarray::{Array1, Array2, Axis};
21use rand::Rng;
22use robust_rs_core::error::RobustError;
23use robust_rs_core::scale::{Mad, ScaleEstimator};
24use robust_rs_core::solver::Control;
25use robust_rs_core::types::Scale;
26
27use super::subsample::{fast_resample, SearchConfig};
28use crate::wls::weighted_least_squares;
29
30/// Default master seed, so `fit` is reproducible without any configuration.
31const DEFAULT_SEED: u64 = 0x0175_5EED;
32
33/// A configured Least Trimmed Squares estimator.
34///
35/// Like [`crate::regression::SEstimator`], it is reproducible by default (a
36/// fixed-seed [`rand_chacha::ChaCha8Rng`] sub-stream per subsample); configure
37/// with [`Lts::seed`] / [`Lts::fit_with_rng`].
38#[derive(Debug, Clone, Copy)]
39pub struct Lts {
40 /// Coverage as a fraction of `n`; `None` = the max-breakdown default
41 /// `⌊(n + p + 1)/2⌋`.
42 coverage: Option<f64>,
43 /// Number of random elemental subsets to draw.
44 n_subsamples: usize,
45 /// Master RNG seed.
46 seed: u64,
47 /// Convergence control for the concentration steps.
48 control: Control,
49}
50
51impl Default for Lts {
52 /// Max-breakdown coverage, 500 subsamples.
53 fn default() -> Self {
54 Self {
55 coverage: None,
56 n_subsamples: 500,
57 seed: DEFAULT_SEED,
58 control: Control::default(),
59 }
60 }
61}
62
63impl Lts {
64 /// A max-breakdown LTS with default search settings.
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 /// Retain a `fraction ∈ (0, 1]` of the observations instead of the
70 /// max-breakdown default; the coverage is `h = ⌊fraction · n⌋` (clamped to
71 /// `[p, n]`). Larger `fraction` trades breakdown for efficiency.
72 pub fn coverage(mut self, fraction: f64) -> Self {
73 self.coverage = Some(fraction);
74 self
75 }
76
77 /// Set the number of random elemental subsets (default `500`).
78 pub fn n_subsamples(mut self, n: usize) -> Self {
79 self.n_subsamples = n;
80 self
81 }
82
83 /// Set the master RNG seed (default is a fixed internal constant).
84 pub fn seed(mut self, seed: u64) -> Self {
85 self.seed = seed;
86 self
87 }
88
89 /// Override the concentration-step convergence control.
90 pub fn control(mut self, control: Control) -> Self {
91 self.control = control;
92 self
93 }
94
95 /// Fit reproducibly from the configured seed.
96 pub fn fit(&self, x: &Array2<f64>, y: &Array1<f64>) -> Result<LtsFit, RobustError> {
97 self.fit_from_seed(x, y, self.seed)
98 }
99
100 /// Fit drawing the master seed from a caller-supplied generator.
101 pub fn fit_with_rng<G: Rng>(
102 &self,
103 x: &Array2<f64>,
104 y: &Array1<f64>,
105 rng: &mut G,
106 ) -> Result<LtsFit, RobustError> {
107 self.fit_from_seed(x, y, rng.random::<u64>())
108 }
109
110 fn fit_from_seed(
111 &self,
112 x: &Array2<f64>,
113 y: &Array1<f64>,
114 master_seed: u64,
115 ) -> Result<LtsFit, RobustError> {
116 let (n, p) = x.dim();
117 // Validate here too (not only in the resampler) because `h` needs sane
118 // `n`/`p` before the clamp below.
119 if p == 0 {
120 return Err(RobustError::SingularDesign);
121 }
122 if y.len() != n {
123 return Err(RobustError::DimensionMismatch {
124 expected: n,
125 got: y.len(),
126 });
127 }
128 if n < p {
129 return Err(RobustError::InsufficientData { needed: p, got: n });
130 }
131
132 let h = match self.coverage {
133 Some(fraction) => {
134 if !(fraction.is_finite() && fraction > 0.0 && fraction <= 1.0) {
135 return Err(RobustError::InvalidTuning { value: fraction });
136 }
137 ((fraction * n as f64).floor() as usize).clamp(p, n)
138 }
139 None => (n + p).div_ceil(2).clamp(p, n), // ⌊(n+p+1)/2⌋ = max breakdown
140 };
141
142 // Single residual definition shared by both closures ⇒ the objective and
143 // the C-step provably select on the same residuals.
144 let residuals = |beta: &Array1<f64>| -> Array1<f64> { y - &x.dot(beta) };
145 let ones_h = Array1::ones(h);
146
147 // Objective: Σ of the h smallest squared residuals.
148 let score = |beta: &Array1<f64>| -> Result<f64, RobustError> {
149 let r = residuals(beta);
150 let idx = h_smallest_abs(&r, h);
151 Ok(idx.iter().map(|&i| r[i] * r[i]).sum())
152 };
153 // One C-step: refit OLS on the h smallest-residual observations.
154 let cstep = |beta: &Array1<f64>| -> Result<Array1<f64>, RobustError> {
155 let r = residuals(beta);
156 let idx = h_smallest_abs(&r, h);
157 let xs = x.select(Axis(0), &idx);
158 let ys = y.select(Axis(0), &idx);
159 weighted_least_squares(&xs, &ys, &ones_h)
160 };
161
162 let cfg = SearchConfig {
163 n_subsamples: self.n_subsamples,
164 control: self.control,
165 ..SearchConfig::default()
166 };
167 let (coefficients, objective) = fast_resample(x, y, master_seed, &cfg, score, cstep)?;
168
169 let resid = y - &x.dot(&coefficients);
170 let mut subset = h_smallest_abs(&resid, h);
171 subset.sort_unstable(); // ascending, for readable auditing of the trim
172 // A MAD of the residuals is a consistent robust scale for the fit; the
173 // classical trimmed-RMS-with-consistency-factor is a later refinement.
174 let scale = Mad::default().scale(resid.as_slice().expect("contiguous residuals"))?;
175 let breakdown_point = (n - h + 1) as f64 / n as f64;
176
177 Ok(LtsFit {
178 coefficients,
179 scale,
180 residuals: resid,
181 subset,
182 objective,
183 coverage: h,
184 breakdown_point,
185 })
186 }
187}
188
189/// A fitted Least Trimmed Squares regression.
190///
191/// LTS is a high-breakdown *initializer*, not an M-estimator, so (unlike
192/// [`crate::estimator::RegressionFit`]) it does **not** implement the ρ-based
193/// [`crate::estimator::RobustEstimator`] surface. LTS has a well-defined influence
194/// function and Gaussian efficiency (it is √n-consistent and asymptotically
195/// normal, Rousseeuw & Leroy 1987; Víšek), but these follow from the asymptotics
196/// of the trimmed objective, **not** from `ψ(r)/E[ψ']` and `1/V(ψ)` (a hard 0/1
197/// trim has no smooth ψ) so they are not reported here. It exposes coefficients,
198/// the retained `h`-subset (so the trimming is auditable), a robust residual scale
199/// (MAD) and its coverage-implied breakdown point.
200#[derive(Debug, Clone)]
201pub struct LtsFit {
202 /// Estimated coefficients.
203 pub coefficients: Array1<f64>,
204 /// Robust residual scale (MAD of the residuals).
205 pub scale: Scale,
206 /// Residuals `y − Xβ̂` for all `n` observations.
207 pub residuals: Array1<f64>,
208 /// Retained subset: the indices of the `h` smallest-residual observations
209 /// (ascending).
210 pub subset: Vec<usize>,
211 /// The LTS objective at the fit: Σ of the `h` smallest squared residuals.
212 pub objective: f64,
213 /// Coverage `h`: the number of observations retained.
214 pub coverage: usize,
215 /// Breakdown point `(n − h + 1)/n`.
216 pub breakdown_point: f64,
217}
218
219impl LtsFit {
220 /// Estimated coefficients.
221 pub fn coefficients(&self) -> &Array1<f64> {
222 &self.coefficients
223 }
224 /// Robust residual scale.
225 pub fn scale(&self) -> Scale {
226 self.scale
227 }
228 /// The retained `h`-subset (ascending indices).
229 pub fn subset(&self) -> &[usize] {
230 &self.subset
231 }
232 /// Breakdown point.
233 pub fn breakdown_point(&self) -> f64 {
234 self.breakdown_point
235 }
236}
237
238/// Indices of the `h` observations with the smallest absolute residual (=
239/// smallest squared residual). `O(n log n)`.
240fn h_smallest_abs(resid: &Array1<f64>, h: usize) -> Vec<usize> {
241 let mut idx: Vec<usize> = (0..resid.len()).collect();
242 idx.sort_by(|&a, &b| resid[a].abs().total_cmp(&resid[b].abs()));
243 idx.truncate(h);
244 idx
245}