Skip to main content

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}