Skip to main content

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}