ferrolearn_linear/ransac.rs
1//! RANSAC (RANdom SAmple Consensus) robust regression.
2//!
3//! This module provides [`RANSACRegressor`], a meta-estimator that fits a
4//! base regressor to inlier data, automatically detecting and excluding
5//! outliers.
6//!
7//! # Algorithm
8//!
9//! Mirrors scikit-learn 1.5.2's `RANSACRegressor.fit` decision rule
10//! (`sklearn/linear_model/_ransac.py:451-606`). Initialize `n_inliers_best = 1`,
11//! `score_best = -inf`, `inlier_mask_best = None`. Then for each trial:
12//!
13//! 1. Draw a `min_samples`-sized subset of indices (seedable; RNG-sequence
14//! parity with numpy is out of scope — see `## REQ status`).
15//! 2. Fit the base estimator on the SUBSET.
16//! 3. Predict on ALL of `X`; classify a sample as an inlier iff
17//! `|y − y_pred| <= residual_threshold` (boundary inclusive).
18//! 4. If `n_inliers_subset < n_inliers_best`, skip.
19//! 5. Compute `score_subset` = R² of the SUBSET model on its inlier set
20//! (`1 − SS_res/SS_tot`; if `SS_tot == 0` then `1.0` when `SS_res == 0` else
21//! `0.0`). No refit inside the loop.
22//! 6. If `n_inliers_subset == n_inliers_best` and `score_subset < score_best`,
23//! skip — so higher R² wins ties on inlier count.
24//! 7. Otherwise record the new best (`n_inliers_best`, `score_best`, and the
25//! SUBSET model's mask — never recomputed from a refit).
26//!
27//! After the loop, refit the base estimator ONCE on the best inlier set; that is
28//! the stored model. The reported `inlier_mask` is the winning subset model's
29//! mask. The default `residual_threshold` is the MAD of `y`
30//! (`median(|y − median(y)|)`), which may be exactly `0` for a constant target.
31//!
32//! ## REQ status (per `.design/linear/ransac.md`, mirrors `sklearn/linear_model/_ransac.py` @ 1.5.2)
33//!
34//! Binary classification (R-DEFER-2): SHIPPED means impl plus non-test consumer
35//! plus tests, all green; NOT-STARTED means an open blocker referenced by number.
36//! The boundary estimator types [`RANSACRegressor`] and
37//! [`FittedRANSACRegressor`] are re-exported at the crate root
38//! (`pub use ransac::{...} in lib.rs`); under S5/R-DEFER-1 the public estimator
39//! type IS the consumer surface (no `ferrolearn-python` RANSAC binding yet).
40//!
41//! **RNG non-parity caveat:** subset draws use `rand::rngs::StdRng` (Fisher-Yates),
42//! NOT numpy's Mersenne-Twister `sample_without_replacement`. Same-seed
43//! cross-implementation subset-sequence parity is infeasible and out of scope;
44//! parity is asserted only on the deterministic decision rules below.
45//!
46//! | REQ | Status | Evidence |
47//! |---|---|---|
48//! | REQ-1 (sampling loop) | SHIPPED | `fn sample_indices` draws `k` distinct indices via Fisher-Yates, called per trial in `fn fit`, seeded deterministically. Test: `test_ransac_reproducible_with_seed`. Structural only (RNG caveat above). |
49//! | REQ-2 (MAD threshold default) | SHIPPED | `fn fit` sets the auto threshold to `fn mad` (`median(|y − median(y)|)`) when `residual_threshold` is `None`, mirroring `_ransac.py:401`. Test: `test_ransac_auto_threshold`. |
50//! | REQ-3 (inlier classification) | SHIPPED | `fn fit`: `if (preds[i] - y[i]).abs() <= threshold { inlier_mask_subset[i] = true }`, boundary-inclusive `<=` per `_ransac.py:511`. Tests: `test_ransac_with_outlier`, `test_ransac_multiple_outliers`. |
51//! | REQ-4 (selection: n_inliers then R²) | SHIPPED | `fn fit` ranks by `(n_inliers_subset, score_subset)` with `score_subset = fn r2_score` of the subset model on its inliers; ties skip when `score_subset < score_best` (higher R² wins), mirroring `_ransac.py:530-543`. Test: `ransac_selection_criterion_r2_not_residual_sum` (tests/divergence_ransac_fit.rs) — oracle picks group B `[F,F,F,T,T,T]`, predict([[1.0]])≈10.05. Closed #512. |
52//! | REQ-5 (refit-once; mask from subset model) | SHIPPED | `fn fit` records `inlier_mask_best` from the SUBSET model (no in-loop refit/recompute) and refits the base estimator ONCE after the loop on `(x_inlier_best, y_inlier_best)`, mirroring `_ransac.py:544,602,605`. The stored `inlier_mask` is never recomputed from the refit. Verified by the green divergence suite + module unit tests. Closed #513. |
53//! | REQ-6 (n_inliers_best init / acceptance gate) | SHIPPED | `fn fit` initializes `n_inliers_best = 1` (`_ransac.py:451`), skips only when `n_inliers_subset < n_inliers_best` (`_ransac.py:515`), and no longer gates on `n_inliers >= min_samples`. The up-front `n_samples < min_samples` guard mirrors `_ransac.py:393-397`. Closed #514. |
54//! | REQ-7 (dynamic max_trials + stop criteria) | NOT-STARTED | open blocker #515. `fn fit` runs a FIXED `for _ in 0..self.max_trials` loop; no `_dynamic_max_trials` shrink, no `stop_n_inliers`/`stop_score`/`stop_probability`/`max_skips`, no `n_trials_`/`n_skips_*` tracking. |
55//! | REQ-8 (loss='squared_error') | SHIPPED | impl: `pub enum RansacLoss { #[default] AbsoluteError, SquaredError }` + field `loss: RansacLoss` + `with_loss` builder (sklearn default `'absolute_error'`, `_ransac.py:301`). `fn fit` branches the per-sample residual: `AbsoluteError → (preds[i]-y[i]).abs()`, `SquaredError → { let d = preds[i]-y[i]; d*d }`, mirroring `_ransac.py:407,414` and applied at the `residuals <= residual_threshold` classification (`_ransac.py:508,511`); the MAD-default threshold stays loss-independent (`_ransac.py:399-401`). Consumer: boundary types re-exported at crate root + `RansacLoss` re-exported (`pub use ransac::{...RansacLoss} in lib.rs`). Tests (live-oracle, RNG-independent): `ransac_loss_squared_error_recovers_line`, `ransac_loss_default_absolute_error_byte_identical` (tests/divergence_ransac_fit.rs). Closed #516. |
56//! | REQ-9 (MAD-zero parity) | SHIPPED | `fn fit` uses the MAD value directly (`mad(&y.to_vec())`) with no `1e-6` substitution, so a constant target yields threshold `0.0` per `_ransac.py:399-401`. Test: `ransac_mad_zero_threshold_excludes_tiny_deviation` (tests/divergence_ransac_fit.rs) — idx 7 (residual 1e-7) is an OUTLIER. Closed #517. |
57//! | REQ-10 (introspection attributes) | NOT-STARTED | open blocker #518. `FittedRANSACRegressor` exposes only `inlier_mask()`; no `estimator_`/`n_trials_`/`n_skips_*`/`n_features_in_`. |
58//! | REQ-11 (is_data_valid / is_model_valid / max_skips) | NOT-STARTED | open blocker #519. `RANSACRegressor` has no such fields. |
59//! | REQ-12 (min_samples float fraction) | SHIPPED | impl: `pub enum MinSamples<F> { Count(usize), Fraction(F) }` + field `min_samples: Option<MinSamples<F>>` + builders `with_min_samples(usize) → Count`, `with_min_samples_fraction(F) → Fraction`, getter `min_samples()`. `fn fit` resolves `None → n_features+1` (unchanged), `Count(k) → k`, `Fraction(f) → ceil(f·n_samples)` validating `0 < f < 1` (else `FerroError::InvalidParameter`), with the resolved-count `> n_samples → InvalidParameter` guard mirroring `_ransac.py:382-397` (sklearn `ValueError`). Consumer: boundary types re-exported at crate root + `MinSamples` re-exported (`pub use ransac::{...MinSamples} in lib.rs`). Tests (live-oracle): `ransac_min_samples_fraction_resolves_ceil`, `ransac_min_samples_fraction_out_of_range_errors`, `ransac_min_samples_count_unchanged` (tests/divergence_ransac_fit.rs). Closed #520. |
60//! | REQ-13 (ferray substrate) | NOT-STARTED | open blocker #521. Still on `ndarray` + `rand::rngs::StdRng`, not `ferray-core`/`ferray::random`. |
61//!
62//! # Examples
63//!
64//! ```
65//! use ferrolearn_linear::ransac::RANSACRegressor;
66//! use ferrolearn_linear::LinearRegression;
67//! use ferrolearn_core::{Fit, Predict};
68//! use ndarray::{array, Array1, Array2};
69//!
70//! // Data with an outlier at index 4.
71//! let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
72//! let y = array![2.0, 4.0, 6.0, 8.0, 100.0]; // last point is outlier
73//!
74//! let base = LinearRegression::<f64>::new();
75//! let model = RANSACRegressor::new(base);
76//! let fitted = model.fit(&x, &y).unwrap();
77//!
78//! // The outlier should be detected.
79//! let mask = fitted.inlier_mask();
80//! assert!(!mask[4], "outlier at index 4 should be detected");
81//! ```
82
83use ferrolearn_core::error::FerroError;
84use ferrolearn_core::traits::{Fit, Predict};
85use ndarray::{Array1, Array2, ScalarOperand};
86use num_traits::Float;
87use rand::Rng;
88use rand::SeedableRng;
89
90// ---------------------------------------------------------------------------
91// Loss family (REQ-8)
92// ---------------------------------------------------------------------------
93
94/// Per-sample residual function used to classify inliers.
95///
96/// Mirrors scikit-learn's `RANSACRegressor(loss=...)` string options
97/// (`sklearn/linear_model/_ransac.py:284,405-418`), constrained by
98/// `StrOptions({"absolute_error", "squared_error"})`. The residual produced
99/// here is compared (boundary-inclusive) against `residual_threshold`; the
100/// MAD-based default threshold itself is computed identically regardless of the
101/// loss (`_ransac.py:399-401` is unconditional — only the per-sample residual at
102/// `_ransac.py:508` changes).
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
104pub enum RansacLoss {
105 /// Absolute error per sample: `|y_true − y_pred|`
106 /// (`_ransac.py:407`, sklearn default).
107 #[default]
108 AbsoluteError,
109 /// Squared error per sample: `(y_true − y_pred)²` (`_ransac.py:414`).
110 SquaredError,
111}
112
113// ---------------------------------------------------------------------------
114// min_samples specification (REQ-12)
115// ---------------------------------------------------------------------------
116
117/// How `min_samples` (the per-trial subset size) is specified.
118///
119/// Mirrors scikit-learn's `min_samples` parameter, which is `int (>= 1)` for an
120/// absolute count or `float ([0, 1])` for a relative fraction
121/// (`sklearn/linear_model/_ransac.py:115-125`; constraint
122/// `Interval(Integral, 1, None) | Interval(RealNotInt, 0, 1, closed="both")`,
123/// `_ransac.py:262-266`). The resolution at fit time is:
124/// `0 < f < 1 → ceil(f · n_samples)` (`_ransac.py:389-390`); an integer count is
125/// used directly (`_ransac.py:391-392`). A resolved count larger than
126/// `n_samples` is a `ValueError` (`_ransac.py:393-397`).
127#[derive(Debug, Clone, Copy, PartialEq)]
128pub enum MinSamples<F> {
129 /// An absolute number of samples per subset (`min_samples >= 1`).
130 Count(usize),
131 /// A fraction of `n_samples` per subset, resolved to
132 /// `ceil(fraction · n_samples)` (sklearn `0 < min_samples < 1`).
133 Fraction(F),
134}
135
136// ---------------------------------------------------------------------------
137// RANSACRegressor (unfitted)
138// ---------------------------------------------------------------------------
139
140/// RANSAC robust regression meta-estimator.
141///
142/// Wraps a base regressor (e.g., [`LinearRegression`](crate::LinearRegression))
143/// and repeatedly fits it on random subsets to find a model robust to
144/// outliers.
145///
146/// # Type Parameters
147///
148/// - `F`: The floating-point type (`f32` or `f64`).
149/// - `E`: The base estimator type.
150#[derive(Debug, Clone)]
151pub struct RANSACRegressor<F, E> {
152 /// The base estimator.
153 pub estimator: E,
154 /// Minimum number of samples per subset. `None` resolves to
155 /// `n_features + 1` (the `LinearRegression` default,
156 /// `sklearn/linear_model/_ransac.py:388`); a [`MinSamples::Count`] is an
157 /// absolute count, a [`MinSamples::Fraction`] resolves to
158 /// `ceil(fraction · n_samples)` (`_ransac.py:389-390`).
159 pub min_samples: Option<MinSamples<F>>,
160 /// Residual threshold: points whose per-sample residual (under [`loss`]) is
161 /// `<= threshold` are considered inliers. If `None`, uses the MAD of the
162 /// target.
163 ///
164 /// [`loss`]: RANSACRegressor::loss
165 pub residual_threshold: Option<F>,
166 /// Maximum number of random trials.
167 pub max_trials: usize,
168 /// Per-sample residual loss used for inlier classification. Defaults to
169 /// [`RansacLoss::AbsoluteError`] (sklearn default,
170 /// `sklearn/linear_model/_ransac.py:301`).
171 pub loss: RansacLoss,
172 /// Optional random seed for reproducibility.
173 pub random_state: Option<u64>,
174}
175
176impl<F: Float, E> RANSACRegressor<F, E> {
177 /// Create a new `RANSACRegressor` with the given base estimator.
178 ///
179 /// Defaults: `min_samples = None` (auto: n_features + 1),
180 /// `residual_threshold = None` (auto: MAD), `max_trials = 100`,
181 /// `loss = AbsoluteError` (sklearn default), `random_state = None`.
182 #[must_use]
183 pub fn new(estimator: E) -> Self {
184 Self {
185 estimator,
186 min_samples: None,
187 residual_threshold: None,
188 max_trials: 100,
189 loss: RansacLoss::AbsoluteError,
190 random_state: None,
191 }
192 }
193
194 /// Set the minimum number of samples per subset as an absolute count
195 /// ([`MinSamples::Count`]). Mirrors sklearn `min_samples` as an `int >= 1`.
196 #[must_use]
197 pub fn with_min_samples(mut self, min_samples: usize) -> Self {
198 self.min_samples = Some(MinSamples::Count(min_samples));
199 self
200 }
201
202 /// Set the minimum number of samples per subset as a fraction of
203 /// `n_samples` ([`MinSamples::Fraction`]), resolved at fit time to
204 /// `ceil(fraction · n_samples)`. Mirrors sklearn `min_samples` as a
205 /// `float` in `(0, 1)` (`sklearn/linear_model/_ransac.py:389-390`).
206 ///
207 /// The fraction is validated at fit time: a value outside `(0, 1)` (or one
208 /// whose resolved count exceeds `n_samples`) yields
209 /// [`FerroError::InvalidParameter`], mirroring sklearn's `ValueError`
210 /// (`_ransac.py:393-397`).
211 #[must_use]
212 pub fn with_min_samples_fraction(mut self, fraction: F) -> Self {
213 self.min_samples = Some(MinSamples::Fraction(fraction));
214 self
215 }
216
217 /// Returns the configured `min_samples` specification (`None` = auto).
218 #[must_use]
219 pub fn min_samples(&self) -> Option<MinSamples<F>> {
220 self.min_samples
221 }
222
223 /// Set the per-sample residual loss used for inlier classification.
224 ///
225 /// Mirrors sklearn `RANSACRegressor(loss=...)`
226 /// (`sklearn/linear_model/_ransac.py:284`). Defaults to
227 /// [`RansacLoss::AbsoluteError`].
228 #[must_use]
229 pub fn with_loss(mut self, loss: RansacLoss) -> Self {
230 self.loss = loss;
231 self
232 }
233
234 /// Set the residual threshold for inlier detection.
235 #[must_use]
236 pub fn with_residual_threshold(mut self, threshold: F) -> Self {
237 self.residual_threshold = Some(threshold);
238 self
239 }
240
241 /// Set the maximum number of random trials.
242 #[must_use]
243 pub fn with_max_trials(mut self, max_trials: usize) -> Self {
244 self.max_trials = max_trials;
245 self
246 }
247
248 /// Set the random seed for reproducibility.
249 #[must_use]
250 pub fn with_random_state(mut self, seed: u64) -> Self {
251 self.random_state = Some(seed);
252 self
253 }
254}
255
256// ---------------------------------------------------------------------------
257// FittedRANSACRegressor
258// ---------------------------------------------------------------------------
259
260/// Fitted RANSAC robust regression model.
261///
262/// Stores the best estimator fitted on inlier data, and the inlier mask.
263#[derive(Debug, Clone)]
264pub struct FittedRANSACRegressor<Fitted> {
265 /// The fitted base estimator (fitted on inliers).
266 fitted_estimator: Fitted,
267 /// Boolean mask: true if the sample was classified as an inlier.
268 inlier_mask: Vec<bool>,
269}
270
271impl<Fitted> FittedRANSACRegressor<Fitted> {
272 /// Returns the inlier mask. `true` indicates the sample was an inlier.
273 #[must_use]
274 pub fn inlier_mask(&self) -> &[bool] {
275 &self.inlier_mask
276 }
277}
278
279// ---------------------------------------------------------------------------
280// Helper: Median Absolute Deviation
281// ---------------------------------------------------------------------------
282
283/// Compute the median of a slice of floats.
284fn median<F: Float>(values: &[F]) -> F {
285 let mut sorted: Vec<F> = values.to_vec();
286 // Total order without `.unwrap()`: NaNs (absent for valid targets) sort last.
287 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
288 let n = sorted.len();
289 if n == 0 {
290 return F::zero();
291 }
292 if n.is_multiple_of(2) {
293 (sorted[n / 2 - 1] + sorted[n / 2]) / (F::one() + F::one())
294 } else {
295 sorted[n / 2]
296 }
297}
298
299/// Compute the Median Absolute Deviation (MAD) of a slice.
300fn mad<F: Float>(values: &[F]) -> F {
301 let med = median(values);
302 let abs_devs: Vec<F> = values.iter().map(|&v| (v - med).abs()).collect();
303 median(&abs_devs)
304}
305
306/// Coefficient of determination R² of `y_pred` against `y_true`.
307///
308/// Mirrors sklearn's `r2_score` (used through `estimator.score`,
309/// `_ransac.py:530`): `R² = 1 - SS_res / SS_tot` where
310/// `SS_res = Σ(y_true − y_pred)²` and `SS_tot = Σ(y_true − mean(y_true))²`.
311///
312/// Matches sklearn's constant-target edge case (`metrics/_regression.py`):
313/// when `SS_tot == 0`, R² is `1.0` if `SS_res == 0` (perfect prediction) and
314/// `0.0` otherwise.
315fn r2_score<F: Float>(y_true: &[F], y_pred: &[F]) -> F {
316 let n = y_true.len();
317 if n == 0 {
318 return F::zero();
319 }
320 let mut sum = F::zero();
321 for &v in y_true {
322 sum = sum + v;
323 }
324 let mean = sum / F::from(n).unwrap_or_else(F::one);
325
326 let mut ss_res = F::zero();
327 let mut ss_tot = F::zero();
328 for (&t, &p) in y_true.iter().zip(y_pred.iter()) {
329 let res = t - p;
330 ss_res = ss_res + res * res;
331 let dev = t - mean;
332 ss_tot = ss_tot + dev * dev;
333 }
334
335 if ss_tot == F::zero() {
336 if ss_res == F::zero() {
337 F::one()
338 } else {
339 F::zero()
340 }
341 } else {
342 F::one() - ss_res / ss_tot
343 }
344}
345
346// ---------------------------------------------------------------------------
347// Random subset sampling
348// ---------------------------------------------------------------------------
349
350/// Sample `k` distinct indices from `0..n` using Fisher-Yates.
351fn sample_indices<R: Rng>(rng: &mut R, n: usize, k: usize) -> Vec<usize> {
352 let mut indices: Vec<usize> = (0..n).collect();
353 for i in 0..k {
354 let j = rng.random_range(i..n);
355 indices.swap(i, j);
356 }
357 indices.truncate(k);
358 indices
359}
360
361/// Extract a subset of rows from a 2D array and a 1D array.
362fn subset<F: Float>(x: &Array2<F>, y: &Array1<F>, indices: &[usize]) -> (Array2<F>, Array1<F>) {
363 let n_features = x.ncols();
364 let n = indices.len();
365 let mut x_sub = Array2::<F>::zeros((n, n_features));
366 let mut y_sub = Array1::<F>::zeros(n);
367 for (row, &idx) in indices.iter().enumerate() {
368 for col in 0..n_features {
369 x_sub[[row, col]] = x[[idx, col]];
370 }
371 y_sub[row] = y[idx];
372 }
373 (x_sub, y_sub)
374}
375
376// ---------------------------------------------------------------------------
377// Fit and Predict
378// ---------------------------------------------------------------------------
379
380impl<F, E, Ef> Fit<Array2<F>, Array1<F>> for RANSACRegressor<F, E>
381where
382 F: Float + Send + Sync + ScalarOperand + num_traits::FromPrimitive + 'static,
383 E: Fit<Array2<F>, Array1<F>, Fitted = Ef, Error = FerroError> + Clone,
384 Ef: Predict<Array2<F>, Output = Array1<F>, Error = FerroError> + Clone,
385{
386 type Fitted = FittedRANSACRegressor<Ef>;
387 type Error = FerroError;
388
389 /// Fit the RANSAC model by repeatedly sampling and fitting.
390 ///
391 /// # Errors
392 ///
393 /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have different
394 /// sample counts.
395 /// Returns [`FerroError::ConvergenceFailure`] if no valid model is found
396 /// after `max_trials` iterations.
397 fn fit(
398 &self,
399 x: &Array2<F>,
400 y: &Array1<F>,
401 ) -> Result<FittedRANSACRegressor<E::Fitted>, FerroError> {
402 let (n_samples, n_features) = x.dim();
403
404 if n_samples != y.len() {
405 return Err(FerroError::ShapeMismatch {
406 expected: vec![n_samples],
407 actual: vec![y.len()],
408 context: "y length must match number of samples in X".into(),
409 });
410 }
411
412 // Resolve `min_samples` per sklearn (`_ransac.py:382-397`):
413 // None -> n_features + 1 (the LinearRegression default).
414 // Count(k) -> k (integer count, `>= 1` branch); `k < 1` is a
415 // ValueError per the parameter constraint
416 // `Interval(Integral, 1, None, closed="left")`
417 // (`_ransac.py:263`) — never silently coerced.
418 // Fraction(f) -> ceil(f * n_samples) for `0 < f < 1`; a fraction
419 // outside (0, 1) is a ValueError.
420 // A resolved count `> n_samples` is a ValueError (`_ransac.py:393-397`).
421 let min_samples = match self.min_samples {
422 None => (n_features + 1).max(1),
423 Some(MinSamples::Count(k)) => {
424 // sklearn rejects `min_samples < 1` at parameter validation
425 // (`Interval(Integral, 1, None, closed="left")`,
426 // `_ransac.py:263`); do not coerce `0` to `1`.
427 if k < 1 {
428 return Err(FerroError::InvalidParameter {
429 name: "min_samples".into(),
430 reason: "min_samples must be >= 1".into(),
431 });
432 }
433 k
434 }
435 Some(MinSamples::Fraction(f)) => {
436 // sklearn's float branch is `0 < min_samples < 1`
437 // (`_ransac.py:389`); a float `>= 1` would take the integer
438 // branch — for the explicit `Fraction` variant we require a
439 // genuine fraction in (0, 1).
440 if !(f > F::zero() && f < F::one()) {
441 return Err(FerroError::InvalidParameter {
442 name: "min_samples".into(),
443 reason: "min_samples fraction must be in the open \
444 interval (0, 1); use with_min_samples for an \
445 absolute count"
446 .into(),
447 });
448 }
449 // ceil(f * n_samples) (`_ransac.py:390`).
450 let raw = f * F::from(n_samples).unwrap_or_else(F::one);
451 let resolved = raw.ceil();
452 // n_samples >= 1 and 0 < f < 1 keep `resolved` finite and >= 1.
453 let resolved = resolved.to_usize().unwrap_or(1).max(1);
454 if resolved > n_samples {
455 return Err(FerroError::InvalidParameter {
456 name: "min_samples".into(),
457 reason: format!(
458 "`min_samples` may not be larger than number of \
459 samples: n_samples = {n_samples}."
460 ),
461 });
462 }
463 resolved
464 }
465 };
466
467 if n_samples < min_samples {
468 return Err(FerroError::InsufficientSamples {
469 required: min_samples,
470 actual: n_samples,
471 context: "RANSAC requires at least min_samples samples".into(),
472 });
473 }
474
475 // Compute residual threshold if not provided.
476 //
477 // sklearn (`_ransac.py:399-401`) sets the default threshold to the MAD
478 // (median absolute deviation) of `y` with NO special-casing of zero:
479 // `residual_threshold = np.median(np.abs(y - np.median(y)))`. A constant
480 // (or near-constant) target therefore yields a threshold of exactly 0.0,
481 // under which only samples with a zero residual are inliers.
482 let threshold = match self.residual_threshold {
483 Some(t) => t,
484 None => mad(&y.to_vec()),
485 };
486
487 let mut rng = match self.random_state {
488 Some(seed) => rand::rngs::StdRng::seed_from_u64(seed),
489 None => rand::rngs::StdRng::seed_from_u64(42),
490 };
491
492 // sklearn-faithful selection state (`_ransac.py:451-456`):
493 // n_inliers_best = 1, score_best = -inf, inlier_mask_best = None.
494 // The best inlier index set is remembered for the single final refit.
495 let mut n_inliers_best: usize = 1;
496 let mut score_best = F::neg_infinity();
497 let mut inlier_mask_best: Option<Vec<bool>> = None;
498 let mut inlier_best_idxs: Option<Vec<usize>> = None;
499
500 // `while n_trials < max_trials` (`_ransac.py:467`). We keep the fixed
501 // `self.max_trials` loop; dynamic max-trials / stop criteria are #515.
502 for _ in 0..self.max_trials {
503 // Choose a random sample set (`_ransac.py:478-482`). RNG-sequence
504 // parity with numpy's `sample_without_replacement` is infeasible and
505 // explicitly out of scope (see module REQ status).
506 let subset_idxs = sample_indices(&mut rng, n_samples, min_samples);
507 let (x_subset, y_subset) = subset(x, y, &subset_idxs);
508
509 // Fit the base estimator on the SUBSET (`_ransac.py:497`).
510 let fitted_subset = match self.estimator.fit(&x_subset, &y_subset) {
511 Ok(f) => f,
512 Err(_) => continue, // Skip failed fits (degenerate subset).
513 };
514
515 // Residuals of ALL data under the subset model (`_ransac.py:507-508`).
516 let preds = match fitted_subset.predict(x) {
517 Ok(p) => p,
518 Err(_) => continue,
519 };
520
521 // Per-sample residual under the configured loss (`_ransac.py:508`,
522 // `residuals_subset = loss_function(y, y_pred)`):
523 // AbsoluteError -> |y − y_pred| (`_ransac.py:407`)
524 // SquaredError -> (y − y_pred)² (`_ransac.py:414`)
525 // The residual is then compared, boundary-inclusive, to
526 // `residual_threshold` (`_ransac.py:511-512`); the MAD-default
527 // threshold is loss-independent (`_ransac.py:399-401`).
528 let mut inlier_mask_subset = vec![false; n_samples];
529 let mut inlier_idxs_subset: Vec<usize> = Vec::new();
530 for i in 0..n_samples {
531 let residual = match self.loss {
532 RansacLoss::AbsoluteError => (preds[i] - y[i]).abs(),
533 RansacLoss::SquaredError => {
534 let d = preds[i] - y[i];
535 d * d
536 }
537 };
538 if residual <= threshold {
539 inlier_mask_subset[i] = true;
540 inlier_idxs_subset.push(i);
541 }
542 }
543 let n_inliers_subset = inlier_idxs_subset.len();
544
545 // Fewer inliers than the best so far -> skip (`_ransac.py:514-517`).
546 if n_inliers_subset < n_inliers_best {
547 continue;
548 }
549
550 // Score the SUBSET model on the inlier set: R² of the subset-fitted
551 // model on `(X_inlier_subset, y_inlier_subset)` (`_ransac.py:530-534`).
552 // No refit inside the loop.
553 let y_inlier_subset: Vec<F> = inlier_idxs_subset.iter().map(|&i| y[i]).collect();
554 let pred_inlier_subset: Vec<F> = inlier_idxs_subset.iter().map(|&i| preds[i]).collect();
555 let score_subset = r2_score(&y_inlier_subset, &pred_inlier_subset);
556
557 // Same inlier count but worse score -> skip (`_ransac.py:538-539`).
558 // Higher R² wins ties on inlier count.
559 if n_inliers_subset == n_inliers_best && score_subset < score_best {
560 continue;
561 }
562
563 // Record the new best (`_ransac.py:542-547`). The stored mask is the
564 // SUBSET model's mask — NOT recomputed from a refit.
565 n_inliers_best = n_inliers_subset;
566 score_best = score_subset;
567 inlier_mask_best = Some(inlier_mask_subset);
568 inlier_best_idxs = Some(inlier_idxs_subset);
569 }
570
571 // No valid consensus set found (`_ransac.py:561-580`).
572 let (mask_best, idxs_best) = match (inlier_mask_best, inlier_best_idxs) {
573 (Some(mask), Some(idxs)) => (mask, idxs),
574 _ => {
575 return Err(FerroError::ConvergenceFailure {
576 iterations: self.max_trials,
577 message: "RANSAC could not find a valid model after max_trials iterations"
578 .into(),
579 });
580 }
581 };
582
583 // Estimate the final model using the best inlier set ONCE, after the
584 // loop (`_ransac.py:597-602`). `inlier_mask_` stays the subset model's
585 // mask; it is never recomputed from this refit.
586 let (x_inlier_best, y_inlier_best) = subset(x, y, &idxs_best);
587 let fitted_estimator = self.estimator.fit(&x_inlier_best, &y_inlier_best)?;
588
589 Ok(FittedRANSACRegressor {
590 fitted_estimator,
591 inlier_mask: mask_best,
592 })
593 }
594}
595
596impl<F, Fitted> Predict<Array2<F>> for FittedRANSACRegressor<Fitted>
597where
598 F: Float + Send + Sync + 'static,
599 Fitted: Predict<Array2<F>, Output = Array1<F>, Error = FerroError>,
600{
601 type Output = Array1<F>;
602 type Error = FerroError;
603
604 /// Predict target values using the base estimator fitted on inliers.
605 ///
606 /// # Errors
607 ///
608 /// Returns any error from the base estimator's predict method.
609 fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
610 self.fitted_estimator.predict(x)
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use crate::LinearRegression;
618 use approx::assert_relative_eq;
619 use ndarray::array;
620
621 #[test]
622 fn test_ransac_no_outliers() {
623 // Perfect linear data, no outliers.
624 let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
625 let y = array![2.0, 4.0, 6.0, 8.0, 10.0];
626
627 let base = LinearRegression::<f64>::new();
628 let model = RANSACRegressor::new(base)
629 .with_random_state(42)
630 .with_residual_threshold(1.0);
631 let fitted = model.fit(&x, &y).unwrap();
632
633 // All should be inliers.
634 let mask = fitted.inlier_mask();
635 assert!(mask.iter().all(|&v| v), "All should be inliers");
636
637 // Predictions should be accurate.
638 let preds = fitted.predict(&x).unwrap();
639 for (p, &actual) in preds.iter().zip(y.iter()) {
640 assert_relative_eq!(*p, actual, epsilon = 0.5);
641 }
642 }
643
644 #[test]
645 fn test_ransac_with_outlier() {
646 // y = 2x, but one outlier.
647 let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
648 let y = array![2.0, 4.0, 6.0, 8.0, 10.0, 100.0]; // outlier at idx 5
649
650 let base = LinearRegression::<f64>::new();
651 let model = RANSACRegressor::new(base)
652 .with_random_state(42)
653 .with_max_trials(200)
654 .with_residual_threshold(2.0);
655 let fitted = model.fit(&x, &y).unwrap();
656
657 let mask = fitted.inlier_mask();
658 // The outlier at index 5 should be detected.
659 assert!(!mask[5], "Outlier at index 5 should not be an inlier");
660
661 // Most other points should be inliers.
662 let n_inliers: usize = mask.iter().filter(|&&v| v).count();
663 assert!(
664 n_inliers >= 4,
665 "Expected at least 4 inliers, got {n_inliers}"
666 );
667
668 // The prediction at x=3 should be close to 6.
669 let x_test = Array2::from_shape_vec((1, 1), vec![3.0]).unwrap();
670 let pred = fitted.predict(&x_test).unwrap();
671 assert!(
672 (pred[0] - 6.0).abs() < 3.0,
673 "Prediction at x=3 should be near 6.0, got {}",
674 pred[0]
675 );
676 }
677
678 #[test]
679 fn test_ransac_multiple_outliers() {
680 // y = x + 1, with two outliers.
681 let x =
682 Array2::from_shape_vec((8, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
683 let y = array![2.0, 3.0, 50.0, 5.0, 6.0, -40.0, 8.0, 9.0]; // outliers at 2 and 5
684
685 let base = LinearRegression::<f64>::new();
686 let model = RANSACRegressor::new(base)
687 .with_random_state(123)
688 .with_max_trials(500)
689 .with_residual_threshold(2.0);
690 let fitted = model.fit(&x, &y).unwrap();
691
692 let mask = fitted.inlier_mask();
693 // Outliers at index 2 and 5 should be detected.
694 assert!(!mask[2], "Outlier at index 2 should not be an inlier");
695 assert!(!mask[5], "Outlier at index 5 should not be an inlier");
696 }
697
698 #[test]
699 fn test_ransac_shape_mismatch() {
700 let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
701 let y = array![1.0, 2.0];
702
703 let base = LinearRegression::<f64>::new();
704 let model = RANSACRegressor::new(base);
705 assert!(model.fit(&x, &y).is_err());
706 }
707
708 #[test]
709 fn test_ransac_insufficient_samples() {
710 let x = Array2::from_shape_vec((1, 1), vec![1.0]).unwrap();
711 let y = array![1.0];
712
713 let base = LinearRegression::<f64>::new();
714 let model = RANSACRegressor::new(base).with_min_samples(3);
715 assert!(model.fit(&x, &y).is_err());
716 }
717
718 #[test]
719 fn test_ransac_reproducible_with_seed() {
720 let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
721 let y = array![2.0, 4.0, 6.0, 8.0, 10.0, 100.0];
722
723 let base1 = LinearRegression::<f64>::new();
724 let model1 = RANSACRegressor::new(base1)
725 .with_random_state(42)
726 .with_residual_threshold(2.0);
727 let fitted1 = model1.fit(&x, &y).unwrap();
728
729 let base2 = LinearRegression::<f64>::new();
730 let model2 = RANSACRegressor::new(base2)
731 .with_random_state(42)
732 .with_residual_threshold(2.0);
733 let fitted2 = model2.fit(&x, &y).unwrap();
734
735 // Same seed should produce same inlier mask.
736 assert_eq!(fitted1.inlier_mask(), fitted2.inlier_mask());
737 }
738
739 #[test]
740 fn test_ransac_auto_threshold() {
741 // No explicit threshold — should use MAD.
742 let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
743 let y = array![2.0, 4.0, 6.0, 8.0, 10.0, 100.0];
744
745 let base = LinearRegression::<f64>::new();
746 let model = RANSACRegressor::new(base)
747 .with_random_state(42)
748 .with_max_trials(200);
749 let fitted = model.fit(&x, &y).unwrap();
750
751 let mask = fitted.inlier_mask();
752 // At least some points should be inliers.
753 let n_inliers: usize = mask.iter().filter(|&&v| v).count();
754 assert!(
755 n_inliers >= 3,
756 "Expected at least 3 inliers, got {n_inliers}"
757 );
758 }
759}