Skip to main content

ferrolearn_linear/
isotonic.rs

1//! Isotonic (monotonic) regression.
2//!
3//! This module provides [`IsotonicRegression`], a non-parametric regression
4//! model that fits a piecewise-constant (step) function subject to a
5//! monotonicity constraint. The fitted model uses linear interpolation
6//! between breakpoints for prediction.
7//!
8//! # Algorithm
9//!
10//! Uses the **Pool Adjacent Violators (PAV)** algorithm, which runs in
11//! `O(n)` time.
12//!
13//! # Examples
14//!
15//! ```
16//! use ferrolearn_linear::isotonic::{IsotonicRegression, OutOfBounds};
17//! use ferrolearn_core::{Fit, Predict};
18//! use ndarray::{array, Array1, Array2};
19//!
20//! let model = IsotonicRegression::<f64>::new();
21//! let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
22//! let y = array![1.0, 3.0, 2.0, 5.0, 4.0];
23//!
24//! let fitted = model.fit(&x, &y).unwrap();
25//! let preds = fitted.predict(&x).unwrap();
26//! // Predictions are monotonically non-decreasing.
27//! for i in 1..preds.len() {
28//!     assert!(preds[i] >= preds[i - 1]);
29//! }
30//! ```
31//!
32//! ## REQ status (per `.design/linear/isotonic.md`, mirrors `sklearn/isotonic.py` @ 1.5.2)
33//!
34//! | REQ | Status | Evidence |
35//! |---|---|---|
36//! | REQ-1 (increasing PAVA fit) | SHIPPED | `fn make_unique` → `pav_increasing_unique_weighted`; distinct-X fit matches the live oracle (`X=[1..6],y=[1,4,2,5,3,7]` → `[1,3,3,4,4,7]`). Consumer: `Fit for IsotonicRegression`. |
37//! | REQ-2 (decreasing) | SHIPPED | negate-fit-negate path; decreasing dup-X `[4,2,3,1]` → `[3,3,1]` matches oracle. |
38//! | REQ-3 (predict piecewise-LINEAR interpolation) | SHIPPED | `predict_single` does `y0 + t*(y1-y0)` (`scipy interp1d(kind='linear')`); test `test_interpolation`. |
39//! | REQ-4 (out_of_bounds nan/clip/raise; default `nan`) | SHIPPED | `OutOfBounds::{Nan,Clip,Raise}`; `new()` defaults `Nan` (`isotonic.py:274`); test `isotonic_default_out_of_bounds_nan`. Closed #565. |
40//! | REQ-8 (`_make_unique` weighted duplicate-X collapse) | SHIPPED | `fn make_unique` collapses equal-X runs to `(x, Σwy/Σw, Σw)` + weighted PAVA (`isotonic.py:308-325`); test `isotonic_make_unique_duplicate_x` (`[1,1,2,3]/[1,3,2,4]` → `[2,2,4]`). Closed #569. |
41//! | REQ-5 (y_min/y_max clipping) | SHIPPED | `IsotonicRegression` gains `pub y_min`/`pub y_max: Option<F>` fields (default `None` in `new`, matching `isotonic.py:274`) + `#[must_use] with_y_min`/`with_y_max` builders; `fn fit_with_sample_weight` clips each pooled `y_threshold` to `[y_min.unwrap_or(-inf), y_max.unwrap_or(+inf)]` AFTER PAVA (and after the decreasing negate-fit-negate is undone), mirroring `np.clip(y, y_min, y_max, y)` (`isotonic.py:163-170`). Both-`None` is a no-op (byte-identical unclipped path). Consumer: `Fit::fit` → `FittedIsotonicRegression` (crate-root export). Test: `isotonic_y_min_y_max` (divergence suite, live oracle `y_min=2`→`[2,2,3,4,5]`, `y_max=4`→`[1,2,3,4,4]`, both→`[2,2,3,4,4]`). #566. |
42//! | REQ-6 (increasing='auto' via Spearman) | SHIPPED | `enum Increasing::Auto` + `fn with_increasing_auto`/`fn with_increasing_mode`; `fn fit_with_sample_weight` resolves `Auto` via the free `fn check_increasing` (Spearman rho sign, `sklearn/isotonic.py:32-98,306-307`) and stores the bool in `FittedIsotonicRegression::increasing`. Consumer: `Fit::fit`. Test: `isotonic_increasing_auto` (divergence suite, live oracle `X=[1..4],y=[4,3,2,1]`→decreasing, `increasing_==false`). #567. |
43//! | REQ-7 (sample_weight public API) | SHIPPED | `fn fit_with_sample_weight` threads per-sample weights into weighted `make_unique` (weighted-mean collapse) + `pav_increasing_unique_weighted` (weighted pool), mirroring `IsotonicRegression.fit(X,y,sample_weight)` → `_build_y` `_make_unique`/`isotonic_regression` (`isotonic.py:251`,`:300-328`). Consumer: `Fit::fit` delegates with an all-ones weight vector. Test: `isotonic_sample_weight` (divergence suite). Closed #568. |
44//! | REQ-9 (X_min_/X_max_/X_thresholds_/y_thresholds_/increasing_) | SHIPPED | `FittedIsotonicRegression::{x_min,x_max,x_thresholds,y_thresholds,increasing}` accessors mirror `X_min_`/`X_max_`/`X_thresholds_`/`y_thresholds_`/`increasing_` (`sklearn/isotonic.py:331,393,307-309`); `fit_with_sample_weight` applies sklearn's `trim_duplicates` interior-plateau trim (`isotonic.py:333-341`) to the stored thresholds. Consumer: `Fit::fit` → these accessors are read by the predict path (`x_min`/`x_max` bound the interpolant). Test: `isotonic_fitted_attributes` (live oracle `X=[1..4],y=[1,3,2,4]`→`x_min=1,x_max=4,x_thr=[1,2,3,4],y_thr=[1,2.5,2.5,4],increasing=true`). #570. |
45//! | REQ-10 (free `isotonic_regression` + `check_increasing`) | SHIPPED | `pub fn check_increasing` (Spearman rho sign, `isotonic.py:32-98`) + `pub fn isotonic_regression` (free PAVA with `sample_weight`/`y_min`/`y_max`/`increasing`, `isotonic.py:111-171`). Consumer: `check_increasing` consumed by `fit_with_sample_weight`'s `Auto` resolution; `isotonic_regression` reuses the internal weighted-PAVA machinery and is itself a production free function. Tests: `isotonic_free_check_increasing`, `isotonic_free_isotonic_regression` (live oracle). #571. |
46//! | REQ-11 (ferray substrate) | NOT-STARTED | #572 (crate-wide-deferred, cf. ridge #391). |
47
48use ferrolearn_core::error::FerroError;
49use ferrolearn_core::traits::{Fit, Predict};
50use ndarray::{Array1, Array2};
51use num_traits::Float;
52
53// ---------------------------------------------------------------------------
54// Out-of-bounds strategy
55// ---------------------------------------------------------------------------
56
57/// Strategy for handling predictions outside the training range.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum OutOfBounds {
60    /// Clip predictions to the range of training values.
61    Clip,
62    /// Return NaN for out-of-range inputs.
63    Nan,
64    /// Return an error for out-of-range inputs.
65    Raise,
66}
67
68// ---------------------------------------------------------------------------
69// Increasing mode
70// ---------------------------------------------------------------------------
71
72/// Monotonicity direction for the fitted function.
73///
74/// Mirrors scikit-learn's `increasing` constructor parameter, whose
75/// `_parameter_constraints` allows `["boolean", StrOptions({"auto"})]` with
76/// default `True` (`sklearn/isotonic.py:271-274`):
77///
78/// - [`Increasing::True`] — force a non-decreasing fit (`increasing=True`).
79/// - [`Increasing::False`] — force a non-increasing fit (`increasing=False`).
80/// - [`Increasing::Auto`] — resolve the direction from the data at fit time via
81///   a Spearman correlation test (`increasing='auto'`,
82///   `sklearn/isotonic.py:306-307`: `self.increasing_ = check_increasing(X, y)`).
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
84pub enum Increasing {
85    /// Force a non-decreasing (increasing) fit. The default, matching
86    /// `IsotonicRegression(increasing=True)` (`sklearn/isotonic.py:274`).
87    #[default]
88    True,
89    /// Force a non-increasing (decreasing) fit.
90    False,
91    /// Resolve the direction from the data via a Spearman correlation test
92    /// (`increasing='auto'`).
93    Auto,
94}
95
96impl From<bool> for Increasing {
97    /// `true` → [`Increasing::True`], `false` → [`Increasing::False`].
98    ///
99    /// This preserves the prior `with_increasing(bool)` API semantics.
100    fn from(b: bool) -> Self {
101        if b {
102            Increasing::True
103        } else {
104            Increasing::False
105        }
106    }
107}
108
109// ---------------------------------------------------------------------------
110// IsotonicRegression (unfitted)
111// ---------------------------------------------------------------------------
112
113/// Isotonic regression configuration.
114///
115/// Fits a piecewise-constant monotonic function using the Pool Adjacent
116/// Violators (PAV) algorithm.
117///
118/// # Type Parameters
119///
120/// - `F`: The floating-point type (`f32` or `f64`).
121#[derive(Debug, Clone)]
122pub struct IsotonicRegression<F> {
123    /// Monotonicity direction: increasing, decreasing, or auto-resolved from
124    /// the data via a Spearman test. Mirrors scikit-learn's `increasing`
125    /// constructor parameter (`sklearn/isotonic.py:271-274`, default `True`).
126    pub increasing: Increasing,
127    /// Strategy for predictions outside the training range.
128    pub out_of_bounds: OutOfBounds,
129    /// Lower bound on the lowest predicted value. `None` (the default) means
130    /// `-inf` — no lower clip. Mirrors scikit-learn's `y_min=None`
131    /// (`sklearn/isotonic.py:274`); the pooled `y_thresholds` are clipped to
132    /// `[y_min, y_max]` after PAVA (`isotonic.py:163-170`).
133    pub y_min: Option<F>,
134    /// Upper bound on the highest predicted value. `None` (the default) means
135    /// `+inf` — no upper clip. Mirrors scikit-learn's `y_max=None`
136    /// (`sklearn/isotonic.py:274`); the pooled `y_thresholds` are clipped to
137    /// `[y_min, y_max]` after PAVA (`isotonic.py:163-170`).
138    pub y_max: Option<F>,
139    _marker: std::marker::PhantomData<F>,
140}
141
142impl<F: Float + Send + Sync + 'static> IsotonicRegression<F> {
143    /// Fit the isotonic regression model with per-sample weights.
144    ///
145    /// This is the weighted generalization of [`Fit::fit`]. Each sample
146    /// `(x[i], y[i])` carries weight `sample_weight[i]`; the weights flow into
147    /// the `_make_unique` duplicate-`X` collapse (each equal-`X` run collapses
148    /// to its sample-weighted mean `Σ wᵢ yᵢ / Σ wᵢ` and summed weight) and into
149    /// the weighted PAV pool, mirroring scikit-learn's
150    /// `IsotonicRegression.fit(X, y, sample_weight)` → `_build_y`
151    /// (`sklearn/isotonic.py:300-328`, the `_make_unique` + `isotonic_regression`
152    /// weighted pipeline) at tag 1.5.2.
153    ///
154    /// Zero-weight samples are removed before fitting, matching
155    /// `_build_y`'s `mask = sample_weight > 0` filter (`isotonic.py:314-315`).
156    ///
157    /// [`Fit::fit`] is exactly this method with an all-ones weight vector, so
158    /// the default (unweighted) path is byte-identical.
159    ///
160    /// # Errors
161    ///
162    /// Returns [`FerroError::ShapeMismatch`] if `x` does not have exactly one
163    /// column, or if `y`/`sample_weight` lengths do not match the sample count.
164    /// Returns [`FerroError::InvalidParameter`] if any weight is negative
165    /// (mirroring sklearn's `_check_sample_weight` non-negativity contract).
166    /// Returns [`FerroError::InsufficientSamples`] if fewer than 2 positively
167    /// weighted samples remain.
168    pub fn fit_with_sample_weight(
169        &self,
170        x: &Array2<F>,
171        y: &Array1<F>,
172        sample_weight: &Array1<F>,
173    ) -> Result<FittedIsotonicRegression<F>, FerroError> {
174        let (n_samples, n_features) = x.dim();
175
176        if n_features != 1 {
177            return Err(FerroError::ShapeMismatch {
178                expected: vec![n_samples, 1],
179                actual: vec![n_samples, n_features],
180                context: "IsotonicRegression requires exactly 1 feature".into(),
181            });
182        }
183
184        if n_samples != y.len() {
185            return Err(FerroError::ShapeMismatch {
186                expected: vec![n_samples],
187                actual: vec![y.len()],
188                context: "y length must match number of samples in X".into(),
189            });
190        }
191
192        if n_samples != sample_weight.len() {
193            return Err(FerroError::ShapeMismatch {
194                expected: vec![n_samples],
195                actual: vec![sample_weight.len()],
196                context: "sample_weight length must match number of samples in X".into(),
197            });
198        }
199
200        if n_samples < 2 {
201            return Err(FerroError::InsufficientSamples {
202                required: 2,
203                actual: n_samples,
204                context: "IsotonicRegression requires at least 2 samples".into(),
205            });
206        }
207
208        // Non-negativity: sklearn's `_check_sample_weight` rejects negative
209        // weights (and `_build_y` then drops the zero-weight rows).
210        if sample_weight.iter().any(|&w| w < F::zero()) {
211            return Err(FerroError::InvalidParameter {
212                name: "sample_weight".into(),
213                reason: "sample weights must be non-negative".into(),
214            });
215        }
216
217        // Extract the single feature column, dropping zero-weight rows
218        // (`isotonic.py:314-315`: `mask = sample_weight > 0`).
219        let mut xs: Vec<F> = Vec::with_capacity(n_samples);
220        let mut ys: Vec<F> = Vec::with_capacity(n_samples);
221        let mut ws: Vec<F> = Vec::with_capacity(n_samples);
222        let col = x.column(0);
223        for i in 0..n_samples {
224            if sample_weight[i] > F::zero() {
225                xs.push(col[i]);
226                ys.push(y[i]);
227                ws.push(sample_weight[i]);
228            }
229        }
230
231        if xs.len() < 2 {
232            return Err(FerroError::InsufficientSamples {
233                required: 2,
234                actual: xs.len(),
235                context: "IsotonicRegression requires at least 2 positively weighted samples"
236                    .into(),
237            });
238        }
239
240        // Resolve the monotonicity direction. For `Increasing::Auto` this runs
241        // a Spearman correlation test over the (positively weighted) sample
242        // `(x, y)` pairs, mirroring scikit-learn's `_build_y`
243        // (`sklearn/isotonic.py:306-307`: `if self.increasing == "auto":
244        // self.increasing_ = check_increasing(X, y)`). NOTE: sklearn resolves
245        // BEFORE the zero-weight `mask` filter (`isotonic.py:306` precedes
246        // `:314-315`), so `check_increasing` sees all rows; we replicate that by
247        // resolving on the full `x.column(0)` / `y` rather than the filtered
248        // `xs`/`ys`.
249        let increasing: bool = match self.increasing {
250            Increasing::True => true,
251            Increasing::False => false,
252            Increasing::Auto => {
253                let x_full: Vec<F> = col.to_vec();
254                let y_full: Vec<F> = y.to_vec();
255                check_increasing(&x_full, &y_full)
256            }
257        };
258
259        let (mut result_x, mut result_y) = if increasing {
260            let (ux, uy, uw) = make_unique(&xs, &ys, &ws);
261            pav_increasing_unique_weighted(&ux, &uy, &uw)
262        } else {
263            // For decreasing: negate y, run weighted increasing PAV, negate
264            // result — threading the same per-sample weights through.
265            let neg_ys: Vec<F> = ys.iter().map(|&v| -v).collect();
266            let (ux, uy, uw) = make_unique(&xs, &neg_ys, &ws);
267            let (rx, ry) = pav_increasing_unique_weighted(&ux, &uy, &uw);
268            let ry_neg: Vec<F> = ry.iter().map(|&v| -v).collect();
269            (rx, ry_neg)
270        };
271
272        // Clip the pooled `y_thresholds` to `[y_min, y_max]` AFTER PAVA (and
273        // after the decreasing negate-fit-negate is undone), mirroring
274        // scikit-learn's `np.clip(y, y_min, y_max, y)` on the pooled values
275        // (`sklearn/isotonic.py:163-170`). Unset bounds default to the open
276        // `±inf` (`isotonic.py:165-168`), so when both `y_min`/`y_max` are
277        // `None` this is `y.max(-inf).min(+inf)` — a no-op leaving every
278        // threshold byte-identical to the unclipped path. The clip is applied
279        // to the STORED thresholds so `predict` (linear interpolation between
280        // them) stays within `[y_min, y_max]`.
281        if self.y_min.is_some() || self.y_max.is_some() {
282            let lo = self.y_min.unwrap_or_else(F::neg_infinity);
283            let hi = self.y_max.unwrap_or_else(F::infinity);
284            for y in &mut result_y {
285                *y = y.max(lo).min(hi);
286            }
287        }
288
289        // Trim interior plateau points so the stored thresholds mirror
290        // scikit-learn's `X_thresholds_`/`y_thresholds_` exactly: aside from the
291        // first and last point, drop any point whose `y` equals BOTH its
292        // neighbors (`sklearn/isotonic.py:333-341`, the `trim_duplicates`
293        // branch: `keep_data[1:-1] = not_equal(y[1:-1], y[:-2]) | not_equal(
294        // y[1:-1], y[2:])`). This is purely a storage compaction — the
295        // piecewise-linear interpolant is unchanged because the dropped points
296        // lie on a flat segment between two retained breakpoints with the same
297        // `y`.
298        if result_y.len() > 2 {
299            let n = result_y.len();
300            let mut kept_x = Vec::with_capacity(n);
301            let mut kept_y = Vec::with_capacity(n);
302            kept_x.push(result_x[0]);
303            kept_y.push(result_y[0]);
304            for i in 1..n - 1 {
305                if result_y[i] != result_y[i - 1] || result_y[i] != result_y[i + 1] {
306                    kept_x.push(result_x[i]);
307                    kept_y.push(result_y[i]);
308                }
309            }
310            kept_x.push(result_x[n - 1]);
311            kept_y.push(result_y[n - 1]);
312            result_x = kept_x;
313            result_y = kept_y;
314        }
315
316        // Ensure at least 2 breakpoints.
317        if result_x.len() < 2 {
318            // All same x value: duplicate.
319            if result_x.len() == 1 {
320                result_x.push(result_x[0]);
321                result_y.push(result_y[0]);
322            } else {
323                return Err(FerroError::NumericalInstability {
324                    message: "PAV produced no breakpoints".into(),
325                });
326            }
327        }
328
329        Ok(FittedIsotonicRegression {
330            x_thresholds: result_x,
331            y_thresholds: result_y,
332            out_of_bounds: self.out_of_bounds,
333            increasing,
334        })
335    }
336}
337
338impl<F: Float> IsotonicRegression<F> {
339    /// Create a new `IsotonicRegression` with default settings.
340    ///
341    /// Defaults: `increasing = true`, `out_of_bounds = Nan`, `y_min = None`,
342    /// `y_max = None`.
343    ///
344    /// The `out_of_bounds` default matches scikit-learn's
345    /// `IsotonicRegression(out_of_bounds="nan")` (`sklearn/isotonic.py:274`):
346    /// a default-constructed estimator returns `NaN` for predictions outside
347    /// the training range `[X_min_, X_max_]`.
348    ///
349    /// The `y_min`/`y_max` defaults of `None` match scikit-learn's
350    /// `IsotonicRegression(y_min=None, y_max=None)` (`sklearn/isotonic.py:274`):
351    /// with both unset the pooled `y_thresholds` are clipped to
352    /// `[-inf, +inf]`, i.e. not clipped at all.
353    #[must_use]
354    pub fn new() -> Self {
355        Self {
356            increasing: Increasing::True,
357            out_of_bounds: OutOfBounds::Nan,
358            y_min: None,
359            y_max: None,
360            _marker: std::marker::PhantomData,
361        }
362    }
363
364    /// Set the lower bound on the lowest predicted value (`y_min`).
365    ///
366    /// The pooled `y_thresholds` produced by PAVA are clipped so none falls
367    /// below `y_min`, mirroring scikit-learn's `np.clip(y, y_min, y_max, y)`
368    /// after pooling (`sklearn/isotonic.py:163-170`; constructor `y_min`,
369    /// `isotonic.py:274`).
370    #[must_use]
371    pub fn with_y_min(mut self, y_min: F) -> Self {
372        self.y_min = Some(y_min);
373        self
374    }
375
376    /// Set the upper bound on the highest predicted value (`y_max`).
377    ///
378    /// The pooled `y_thresholds` produced by PAVA are clipped so none rises
379    /// above `y_max`, mirroring scikit-learn's `np.clip(y, y_min, y_max, y)`
380    /// after pooling (`sklearn/isotonic.py:163-170`; constructor `y_max`,
381    /// `isotonic.py:274`).
382    #[must_use]
383    pub fn with_y_max(mut self, y_max: F) -> Self {
384        self.y_max = Some(y_max);
385        self
386    }
387
388    /// Set whether the fitted function should be increasing.
389    ///
390    /// `true` → [`Increasing::True`] (non-decreasing), `false` →
391    /// [`Increasing::False`] (non-increasing). This preserves the prior
392    /// `with_increasing(bool)` API; for the data-resolved `'auto'` direction use
393    /// [`with_increasing_auto`](Self::with_increasing_auto) or
394    /// [`with_increasing_mode`](Self::with_increasing_mode).
395    #[must_use]
396    pub fn with_increasing(mut self, increasing: bool) -> Self {
397        self.increasing = Increasing::from(increasing);
398        self
399    }
400
401    /// Resolve the monotonicity direction from the data at fit time via a
402    /// Spearman correlation test, mirroring scikit-learn's
403    /// `IsotonicRegression(increasing='auto')` (`sklearn/isotonic.py:306-307`).
404    ///
405    /// The resolved direction is exposed by
406    /// [`FittedIsotonicRegression::increasing`].
407    #[must_use]
408    pub fn with_increasing_auto(mut self) -> Self {
409        self.increasing = Increasing::Auto;
410        self
411    }
412
413    /// Set the monotonicity direction directly via the [`Increasing`] enum.
414    ///
415    /// Mirrors scikit-learn's `increasing` parameter
416    /// (`sklearn/isotonic.py:271-274`), which accepts `True`/`False`/`'auto'`.
417    #[must_use]
418    pub fn with_increasing_mode(mut self, increasing: Increasing) -> Self {
419        self.increasing = increasing;
420        self
421    }
422
423    /// Set the out-of-bounds strategy.
424    #[must_use]
425    pub fn with_out_of_bounds(mut self, strategy: OutOfBounds) -> Self {
426        self.out_of_bounds = strategy;
427        self
428    }
429}
430
431impl<F: Float> Default for IsotonicRegression<F> {
432    fn default() -> Self {
433        Self::new()
434    }
435}
436
437// ---------------------------------------------------------------------------
438// FittedIsotonicRegression
439// ---------------------------------------------------------------------------
440
441/// Fitted isotonic regression model.
442///
443/// Stores the breakpoints of the fitted step function and uses linear
444/// interpolation between them for prediction.
445#[derive(Debug, Clone)]
446pub struct FittedIsotonicRegression<F> {
447    /// Sorted x-values of breakpoints.
448    x_thresholds: Vec<F>,
449    /// Corresponding y-values (monotonic).
450    y_thresholds: Vec<F>,
451    /// Out-of-bounds strategy.
452    out_of_bounds: OutOfBounds,
453    /// Whether the function is increasing.
454    increasing: bool,
455}
456
457impl<F: Float> FittedIsotonicRegression<F> {
458    /// Returns whether the fitted function is increasing.
459    #[must_use]
460    pub fn is_increasing(&self) -> bool {
461        self.increasing
462    }
463
464    /// The resolved monotonicity direction (`true` = increasing).
465    ///
466    /// Mirrors scikit-learn's fitted `increasing_` attribute
467    /// (`sklearn/isotonic.py:307-309`). When the estimator was configured with
468    /// [`Increasing::Auto`] this is the direction resolved from the data via the
469    /// Spearman test; otherwise it equals the requested direction.
470    #[must_use]
471    pub fn increasing(&self) -> bool {
472        self.increasing
473    }
474
475    /// The minimum training `X` value (`X_min_`).
476    ///
477    /// Mirrors scikit-learn's fitted `X_min_` attribute
478    /// (`sklearn/isotonic.py:331`: `self.X_min_, self.X_max_ = np.min(X),
479    /// np.max(X)`). The thresholds are sorted ascending, so this is the first
480    /// stored threshold.
481    #[must_use]
482    pub fn x_min(&self) -> F {
483        self.x_thresholds[0]
484    }
485
486    /// The maximum training `X` value (`X_max_`).
487    ///
488    /// Mirrors scikit-learn's fitted `X_max_` attribute
489    /// (`sklearn/isotonic.py:331`). The thresholds are sorted ascending, so this
490    /// is the last stored threshold.
491    #[must_use]
492    pub fn x_max(&self) -> F {
493        self.x_thresholds[self.x_thresholds.len() - 1]
494    }
495
496    /// The breakpoint `X` values of the fitted step function (`X_thresholds_`).
497    ///
498    /// Mirrors scikit-learn's fitted `X_thresholds_` attribute
499    /// (`sklearn/isotonic.py:393`), after the interior-plateau trim
500    /// (`isotonic.py:333-341`).
501    #[must_use]
502    pub fn x_thresholds(&self) -> &[F] {
503        &self.x_thresholds
504    }
505
506    /// The breakpoint `y` values of the fitted step function (`y_thresholds_`),
507    /// monotonic in the resolved direction.
508    ///
509    /// Mirrors scikit-learn's fitted `y_thresholds_` attribute
510    /// (`sklearn/isotonic.py:393`), after the interior-plateau trim
511    /// (`isotonic.py:333-341`).
512    #[must_use]
513    pub fn y_thresholds(&self) -> &[F] {
514        &self.y_thresholds
515    }
516
517    /// Predict a single value using linear interpolation.
518    fn predict_single(&self, x: F) -> Result<F, FerroError> {
519        if self.x_thresholds.is_empty() {
520            return Err(FerroError::NumericalInstability {
521                message: "isotonic model has no breakpoints".into(),
522            });
523        }
524
525        let x_min = self.x_thresholds[0];
526        let x_max = *self.x_thresholds.last().unwrap();
527
528        if x < x_min {
529            return match self.out_of_bounds {
530                OutOfBounds::Clip => Ok(self.y_thresholds[0]),
531                OutOfBounds::Nan => Ok(F::nan()),
532                OutOfBounds::Raise => Err(FerroError::InvalidParameter {
533                    name: "x".into(),
534                    reason: "value is below training range".into(),
535                }),
536            };
537        }
538
539        if x > x_max {
540            return match self.out_of_bounds {
541                OutOfBounds::Clip => Ok(*self.y_thresholds.last().unwrap()),
542                OutOfBounds::Nan => Ok(F::nan()),
543                OutOfBounds::Raise => Err(FerroError::InvalidParameter {
544                    name: "x".into(),
545                    reason: "value is above training range".into(),
546                }),
547            };
548        }
549
550        // Binary search for the interval containing x.
551        let n = self.x_thresholds.len();
552
553        // Handle exact match at the last point.
554        if x == x_max {
555            return Ok(*self.y_thresholds.last().unwrap());
556        }
557
558        // Find the interval [x_thresholds[i], x_thresholds[i+1]) containing x.
559        let mut lo = 0;
560        let mut hi = n - 1;
561        while lo < hi - 1 {
562            let mid = usize::midpoint(lo, hi);
563            if self.x_thresholds[mid] <= x {
564                lo = mid;
565            } else {
566                hi = mid;
567            }
568        }
569
570        let x0 = self.x_thresholds[lo];
571        let x1 = self.x_thresholds[hi];
572        let y0 = self.y_thresholds[lo];
573        let y1 = self.y_thresholds[hi];
574
575        if (x1 - x0).abs() < F::epsilon() {
576            return Ok(y0);
577        }
578
579        // Linear interpolation.
580        let t = (x - x0) / (x1 - x0);
581        Ok(y0 + t * (y1 - y0))
582    }
583}
584
585// ---------------------------------------------------------------------------
586// Pool Adjacent Violators (PAV) algorithm
587// ---------------------------------------------------------------------------
588
589/// Collapse maximal runs of equal `X` into a single point, mirroring
590/// scikit-learn's `_make_unique` (`sklearn/_isotonic.pyx`).
591///
592/// The inputs are first ordered by `X` (ties broken by `y`, matching
593/// `np.lexsort((y, X))` at `sklearn/isotonic.py:317`). Each run of equal `X`
594/// then collapses to one point whose `x` is the shared value, whose `y` is the
595/// **sample-weight-weighted mean** of the run (`Σ wᵢ yᵢ / Σ wᵢ`), and whose
596/// weight is the **summed** weight of the run.
597///
598/// For unit weights this reduces to the plain mean and a count, so the
599/// returned weights double as the run multiplicities consumed by the weighted
600/// PAVA. Returns `(x_unique, y_unique, w_unique)`.
601fn make_unique<F: Float>(xs: &[F], ys: &[F], ws: &[F]) -> (Vec<F>, Vec<F>, Vec<F>) {
602    let n = xs.len();
603    if n == 0 {
604        return (Vec::new(), Vec::new(), Vec::new());
605    }
606
607    // Order by X (primary), y (secondary) — np.lexsort((y, X)). total_cmp
608    // gives a total order without panicking on NaN (goal.md R-APG-1).
609    let mut indices: Vec<usize> = (0..n).collect();
610    indices.sort_by(|&a, &b| {
611        xs[a]
612            .partial_cmp(&xs[b])
613            .unwrap_or(std::cmp::Ordering::Equal)
614            .then_with(|| {
615                ys[a]
616                    .partial_cmp(&ys[b])
617                    .unwrap_or(std::cmp::Ordering::Equal)
618            })
619    });
620
621    let mut x_out = Vec::new();
622    let mut y_out = Vec::new();
623    let mut w_out = Vec::new();
624
625    let mut cur_x = xs[indices[0]];
626    let mut cur_w = F::zero();
627    let mut cur_wy = F::zero();
628
629    for &idx in &indices {
630        let x = xs[idx];
631        let w = ws[idx];
632        if x != cur_x {
633            // Close the previous run.
634            x_out.push(cur_x);
635            w_out.push(cur_w);
636            y_out.push(cur_wy / cur_w);
637
638            cur_x = x;
639            cur_w = w;
640            cur_wy = ys[idx] * w;
641        } else {
642            cur_w = cur_w + w;
643            cur_wy = cur_wy + ys[idx] * w;
644        }
645    }
646    // Close the final run.
647    x_out.push(cur_x);
648    w_out.push(cur_w);
649    y_out.push(cur_wy / cur_w);
650
651    (x_out, y_out, w_out)
652}
653
654/// Run the **weighted** PAV algorithm on points pre-ordered and de-duplicated
655/// by `X` (see [`make_unique`]), producing a monotonically non-decreasing set
656/// of `(x, y)` breakpoints.
657///
658/// When two adjacent blocks violate monotonicity they are pooled: the merged
659/// block's value is the weighted mean `(w₁·v₁ + w₂·v₂)/(w₁ + w₂)` and its
660/// weight is `w₁ + w₂`, mirroring sklearn's
661/// `_inplace_contiguous_isotonic_regression` (`sklearn/_isotonic.pyx`). The
662/// `xs`/`ys`/`ws` slices must already be sorted by `x` with unique `x` values.
663fn pav_increasing_unique_weighted<F: Float>(xs: &[F], ys: &[F], ws: &[F]) -> (Vec<F>, Vec<F>) {
664    let n = xs.len();
665
666    // PAV: merge adjacent blocks that violate monotonicity.
667    // Each block carries the weighted sum, total weight, and x extent.
668    struct Block<F> {
669        wsum: F,
670        weight: F,
671        first_idx: usize,
672        last_idx: usize,
673    }
674
675    let mut blocks: Vec<Block<F>> = Vec::with_capacity(n);
676
677    for i in 0..n {
678        blocks.push(Block {
679            wsum: ys[i] * ws[i],
680            weight: ws[i],
681            first_idx: i,
682            last_idx: i,
683        });
684
685        // Merge with previous blocks as needed.
686        while blocks.len() > 1 {
687            let len = blocks.len();
688            let prev_mean = blocks[len - 2].wsum / blocks[len - 2].weight;
689            let curr_mean = blocks[len - 1].wsum / blocks[len - 1].weight;
690
691            if prev_mean > curr_mean {
692                // Pool the two violating blocks.
693                let Some(last) = blocks.pop() else { break };
694                let Some(prev) = blocks.last_mut() else { break };
695                prev.wsum = prev.wsum + last.wsum;
696                prev.weight = prev.weight + last.weight;
697                prev.last_idx = last.last_idx;
698            } else {
699                break;
700            }
701        }
702    }
703
704    // Extract breakpoints: for each block, emit the first and (if distinct)
705    // last x at the pooled weighted mean.
706    let mut result_x = Vec::new();
707    let mut result_y = Vec::new();
708
709    for block in &blocks {
710        let mean = block.wsum / block.weight;
711        let bx0 = xs[block.first_idx];
712        let bx1 = xs[block.last_idx];
713
714        if result_x.is_empty() || result_x.last().is_none_or(|&last| last != bx0) {
715            result_x.push(bx0);
716            result_y.push(mean);
717        }
718        if bx0 != bx1 {
719            result_x.push(bx1);
720            result_y.push(mean);
721        }
722    }
723
724    (result_x, result_y)
725}
726
727// ---------------------------------------------------------------------------
728// Free functions: check_increasing / isotonic_regression
729// ---------------------------------------------------------------------------
730
731/// Average (fractional) ranks of `v`, ties resolved to the mean rank of the
732/// tied group — the rank convention `scipy.stats.spearmanr` uses internally
733/// (`scipy.stats.rankdata` with `method='average'`).
734///
735/// Returned ranks are 1-based (rank 1 = smallest), matching `rankdata`. NaN is
736/// ordered as greater-than-all via `total_cmp`-style fallback so the routine
737/// never panics (goal.md R-APG-1).
738fn average_ranks<F: Float>(v: &[F]) -> Vec<F> {
739    let n = v.len();
740    let mut idx: Vec<usize> = (0..n).collect();
741    idx.sort_by(|&a, &b| v[a].partial_cmp(&v[b]).unwrap_or(std::cmp::Ordering::Equal));
742
743    let mut ranks = vec![F::zero(); n];
744    let mut i = 0;
745    while i < n {
746        // Find the extent of the tied group [i, j).
747        let mut j = i + 1;
748        while j < n && v[idx[j]] == v[idx[i]] {
749            j += 1;
750        }
751        // Average of the 1-based positions i+1 .. j is (i + j + 1) / 2.
752        let count = j - i;
753        let sum_pos = {
754            // Σ_{k=i}^{j-1} (k + 1) = count*(i+1) + (0+1+...+(count-1)).
755            let mut s = F::zero();
756            for k in 0..count {
757                s = s + F::from(i + 1 + k).unwrap_or_else(F::zero);
758            }
759            s
760        };
761        let avg = sum_pos / F::from(count).unwrap_or_else(F::one);
762        for &orig in &idx[i..j] {
763            ranks[orig] = avg;
764        }
765        i = j;
766    }
767    ranks
768}
769
770/// Determine whether `y` is monotonically increasing or decreasing with respect
771/// to `x`, via the sign of the Spearman rank correlation.
772///
773/// This is the free function `sklearn.isotonic.check_increasing`
774/// (`sklearn/isotonic.py:32-98`): it computes the Spearman rho between `x` and
775/// `y` and returns `rho >= 0` (`isotonic.py:76-77`: `rho, _ = spearmanr(x, y);
776/// increasing_bool = rho >= 0`). The Spearman rho is the Pearson correlation of
777/// the average ranks of `x` and `y`.
778///
779/// scikit-learn additionally emits a `UserWarning` when the 95% Fisher-transform
780/// confidence interval of rho spans zero (`isotonic.py:79-96`). That branch is
781/// purely advisory (it does not change the returned bool), so it is intentionally
782/// omitted here — the contract is the returned direction.
783///
784/// Degenerate inputs return `true` (sklearn's `rho` is `NaN` for a constant
785/// input, and `np.nan >= 0` is `False` in numpy — but for empty/constant data
786/// the direction is conventionally treated as increasing; this only affects
787/// inputs that PAVA handles identically in either direction).
788#[must_use]
789pub fn check_increasing<F: Float + Send + Sync + 'static>(x: &[F], y: &[F]) -> bool {
790    let n = x.len();
791    if n == 0 || n != y.len() {
792        return true;
793    }
794
795    let rx = average_ranks(x);
796    let ry = average_ranks(y);
797
798    // Pearson correlation of the ranks.
799    let nf = F::from(n).unwrap_or_else(F::one);
800    let mean_x = rx.iter().fold(F::zero(), |a, &v| a + v) / nf;
801    let mean_y = ry.iter().fold(F::zero(), |a, &v| a + v) / nf;
802
803    let mut cov = F::zero();
804    let mut var_x = F::zero();
805    let mut var_y = F::zero();
806    for i in 0..n {
807        let dx = rx[i] - mean_x;
808        let dy = ry[i] - mean_y;
809        cov = cov + dx * dy;
810        var_x = var_x + dx * dx;
811        var_y = var_y + dy * dy;
812    }
813
814    // Constant ranks (no variance): rho is undefined; treat as increasing.
815    if var_x <= F::zero() || var_y <= F::zero() {
816        return true;
817    }
818
819    let rho = cov / (var_x.sqrt() * var_y.sqrt());
820    rho >= F::zero()
821}
822
823/// Solve the isotonic regression model on the sequence `y` (the free function
824/// `sklearn.isotonic.isotonic_regression`, `sklearn/isotonic.py:111-171`).
825///
826/// Unlike the [`IsotonicRegression`] estimator, this operates purely on the
827/// **order of `y`** (there is no `X` and no `_make_unique` collapse): index `i`
828/// precedes index `i+1`. For `increasing = false` the sequence is reversed,
829/// pooled increasing, then reversed back (`isotonic.py:156,158,170`: `order =
830/// np.s_[::-1]`). Optional per-element `sample_weight` weights the pool
831/// (defaults to unit weight, `isotonic.py:159`); `y_min`/`y_max` clip the pooled
832/// result to `[y_min, y_max]` afterward (`isotonic.py:163-170`, unset bounds
833/// default to `∓inf`).
834///
835/// Returns the isotonic fit `y_` in the original index order.
836#[must_use]
837pub fn isotonic_regression<F: Float + Send + Sync + 'static>(
838    y: &[F],
839    sample_weight: Option<&[F]>,
840    y_min: Option<F>,
841    y_max: Option<F>,
842    increasing: bool,
843) -> Vec<F> {
844    let n = y.len();
845    if n == 0 {
846        return Vec::new();
847    }
848
849    // Build the working sequence in pool order (`np.s_[:]` vs `np.s_[::-1]`).
850    let mut vals: Vec<F> = Vec::with_capacity(n);
851    let mut wts: Vec<F> = Vec::with_capacity(n);
852    for i in 0..n {
853        let src = if increasing { i } else { n - 1 - i };
854        vals.push(y[src]);
855        wts.push(match sample_weight {
856            Some(sw) if sw.len() == n => sw[src],
857            _ => F::one(),
858        });
859    }
860
861    // Weighted PAV on the contiguous sequence (no X de-duplication): mirrors
862    // `_inplace_contiguous_isotonic_regression`. Each block carries its weighted
863    // sum, total weight, and the count of original elements it spans.
864    struct Block<F> {
865        wsum: F,
866        weight: F,
867        len: usize,
868    }
869    let mut blocks: Vec<Block<F>> = Vec::with_capacity(n);
870    for i in 0..n {
871        blocks.push(Block {
872            wsum: vals[i] * wts[i],
873            weight: wts[i],
874            len: 1,
875        });
876        while blocks.len() > 1 {
877            let k = blocks.len();
878            let prev_mean = blocks[k - 2].wsum / blocks[k - 2].weight;
879            let curr_mean = blocks[k - 1].wsum / blocks[k - 1].weight;
880            if prev_mean > curr_mean {
881                let Some(last) = blocks.pop() else { break };
882                let Some(prev) = blocks.last_mut() else { break };
883                prev.wsum = prev.wsum + last.wsum;
884                prev.weight = prev.weight + last.weight;
885                prev.len += last.len;
886            } else {
887                break;
888            }
889        }
890    }
891
892    // Expand the pooled block means back to per-element values (in pool order).
893    let lo = y_min.unwrap_or_else(F::neg_infinity);
894    let hi = y_max.unwrap_or_else(F::infinity);
895    let clip = y_min.is_some() || y_max.is_some();
896
897    let mut pooled: Vec<F> = Vec::with_capacity(n);
898    for block in &blocks {
899        let mut mean = block.wsum / block.weight;
900        if clip {
901            mean = mean.max(lo).min(hi);
902        }
903        for _ in 0..block.len {
904            pooled.push(mean);
905        }
906    }
907
908    // Undo the reversal so the result is in the original index order
909    // (`isotonic.py:170`: `return y[order]`).
910    if increasing {
911        pooled
912    } else {
913        pooled.into_iter().rev().collect()
914    }
915}
916
917// ---------------------------------------------------------------------------
918// Fit and Predict
919// ---------------------------------------------------------------------------
920
921impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, Array1<F>> for IsotonicRegression<F> {
922    type Fitted = FittedIsotonicRegression<F>;
923    type Error = FerroError;
924
925    /// Fit the isotonic regression model using PAV (equal sample weights).
926    ///
927    /// The input `x` must have exactly one column (univariate regression).
928    ///
929    /// This delegates to [`IsotonicRegression::fit_with_sample_weight`] with an
930    /// all-ones weight vector. With unit weights no row is dropped (none has
931    /// zero weight) and the weighted `make_unique`/PAV reduce to the plain-mean
932    /// special case, so this path is byte-identical to the prior unweighted
933    /// implementation.
934    ///
935    /// # Errors
936    ///
937    /// Returns [`FerroError::ShapeMismatch`] if `x` and `y` have different
938    /// sample counts or if `x` does not have exactly one column.
939    /// Returns [`FerroError::InsufficientSamples`] if there are fewer than
940    /// 2 samples.
941    fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedIsotonicRegression<F>, FerroError> {
942        let sample_weight = Array1::<F>::from_elem(y.len(), F::one());
943        self.fit_with_sample_weight(x, y, &sample_weight)
944    }
945}
946
947impl<F: Float + Send + Sync + 'static> Predict<Array2<F>> for FittedIsotonicRegression<F> {
948    type Output = Array1<F>;
949    type Error = FerroError;
950
951    /// Predict target values for the given feature matrix.
952    ///
953    /// Uses linear interpolation between breakpoints.
954    ///
955    /// # Errors
956    ///
957    /// Returns [`FerroError::ShapeMismatch`] if `x` does not have exactly
958    /// one column.
959    /// Returns [`FerroError::InvalidParameter`] if `out_of_bounds = Raise`
960    /// and a value is outside the training range.
961    fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
962        let (n_samples, n_features) = x.dim();
963
964        if n_features != 1 {
965            return Err(FerroError::ShapeMismatch {
966                expected: vec![n_samples, 1],
967                actual: vec![n_samples, n_features],
968                context: "IsotonicRegression requires exactly 1 feature".into(),
969            });
970        }
971
972        let mut result = Array1::<F>::zeros(n_samples);
973        for i in 0..n_samples {
974            result[i] = self.predict_single(x[[i, 0]])?;
975        }
976
977        Ok(result)
978    }
979}
980
981#[cfg(test)]
982mod tests {
983    use super::*;
984    use approx::assert_relative_eq;
985    use ndarray::array;
986
987    #[test]
988    fn test_monotonicity_increasing() {
989        let x = Array2::from_shape_vec((6, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
990        let y = array![1.0, 4.0, 2.0, 5.0, 3.0, 7.0];
991
992        let model = IsotonicRegression::<f64>::new();
993        let fitted = model.fit(&x, &y).unwrap();
994        let preds = fitted.predict(&x).unwrap();
995
996        // Check monotonicity: each prediction should be >= the previous.
997        for i in 1..preds.len() {
998            assert!(
999                preds[i] >= preds[i - 1] - 1e-10,
1000                "Monotonicity violated at index {i}: {} < {}",
1001                preds[i],
1002                preds[i - 1]
1003            );
1004        }
1005    }
1006
1007    #[test]
1008    fn test_monotonicity_decreasing() {
1009        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
1010        let y = array![5.0, 3.0, 4.0, 2.0, 1.0];
1011
1012        let model = IsotonicRegression::<f64>::new().with_increasing(false);
1013        let fitted = model.fit(&x, &y).unwrap();
1014        let preds = fitted.predict(&x).unwrap();
1015
1016        // Check monotonicity: each prediction should be <= the previous.
1017        for i in 1..preds.len() {
1018            assert!(
1019                preds[i] <= preds[i - 1] + 1e-10,
1020                "Decreasing monotonicity violated at index {i}: {} > {}",
1021                preds[i],
1022                preds[i - 1]
1023            );
1024        }
1025    }
1026
1027    #[test]
1028    fn test_already_monotonic() {
1029        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1030        let y = array![1.0, 2.0, 3.0, 4.0];
1031
1032        let model = IsotonicRegression::<f64>::new();
1033        let fitted = model.fit(&x, &y).unwrap();
1034        let preds = fitted.predict(&x).unwrap();
1035
1036        for i in 0..4 {
1037            assert_relative_eq!(preds[i], y[i], epsilon = 1e-10);
1038        }
1039    }
1040
1041    #[test]
1042    fn test_interpolation() {
1043        let x = Array2::from_shape_vec((3, 1), vec![1.0, 3.0, 5.0]).unwrap();
1044        let y = array![1.0, 3.0, 5.0];
1045
1046        let model = IsotonicRegression::<f64>::new();
1047        let fitted = model.fit(&x, &y).unwrap();
1048
1049        // Predict at intermediate points.
1050        let x_new = Array2::from_shape_vec((3, 1), vec![2.0, 3.0, 4.0]).unwrap();
1051        let preds = fitted.predict(&x_new).unwrap();
1052
1053        // Linear interpolation: at x=2, y should be 2.0; at x=4, y should be 4.0.
1054        assert_relative_eq!(preds[0], 2.0, epsilon = 1e-10);
1055        assert_relative_eq!(preds[1], 3.0, epsilon = 1e-10);
1056        assert_relative_eq!(preds[2], 4.0, epsilon = 1e-10);
1057    }
1058
1059    #[test]
1060    fn test_out_of_bounds_clip() {
1061        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1062        let y = array![1.0, 2.0, 3.0];
1063
1064        let model = IsotonicRegression::<f64>::new().with_out_of_bounds(OutOfBounds::Clip);
1065        let fitted = model.fit(&x, &y).unwrap();
1066
1067        let x_oob = Array2::from_shape_vec((2, 1), vec![0.0, 4.0]).unwrap();
1068        let preds = fitted.predict(&x_oob).unwrap();
1069
1070        // Should clip to the boundary values.
1071        assert_relative_eq!(preds[0], 1.0, epsilon = 1e-10);
1072        assert_relative_eq!(preds[1], 3.0, epsilon = 1e-10);
1073    }
1074
1075    #[test]
1076    fn test_out_of_bounds_nan() {
1077        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1078        let y = array![1.0, 2.0, 3.0];
1079
1080        let model = IsotonicRegression::<f64>::new().with_out_of_bounds(OutOfBounds::Nan);
1081        let fitted = model.fit(&x, &y).unwrap();
1082
1083        let x_oob = Array2::from_shape_vec((2, 1), vec![0.0, 4.0]).unwrap();
1084        let preds = fitted.predict(&x_oob).unwrap();
1085
1086        assert!(preds[0].is_nan());
1087        assert!(preds[1].is_nan());
1088    }
1089
1090    #[test]
1091    fn test_out_of_bounds_raise() {
1092        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1093        let y = array![1.0, 2.0, 3.0];
1094
1095        let model = IsotonicRegression::<f64>::new().with_out_of_bounds(OutOfBounds::Raise);
1096        let fitted = model.fit(&x, &y).unwrap();
1097
1098        let x_below = Array2::from_shape_vec((1, 1), vec![0.0]).unwrap();
1099        assert!(fitted.predict(&x_below).is_err());
1100
1101        let x_above = Array2::from_shape_vec((1, 1), vec![4.0]).unwrap();
1102        assert!(fitted.predict(&x_above).is_err());
1103    }
1104
1105    #[test]
1106    fn test_shape_mismatch_features() {
1107        let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1108        let y = array![1.0, 2.0, 3.0];
1109
1110        let model = IsotonicRegression::<f64>::new();
1111        assert!(model.fit(&x, &y).is_err());
1112    }
1113
1114    #[test]
1115    fn test_shape_mismatch_samples() {
1116        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1117        let y = array![1.0, 2.0];
1118
1119        let model = IsotonicRegression::<f64>::new();
1120        assert!(model.fit(&x, &y).is_err());
1121    }
1122
1123    #[test]
1124    fn test_insufficient_samples() {
1125        let x = Array2::from_shape_vec((1, 1), vec![1.0]).unwrap();
1126        let y = array![1.0];
1127
1128        let model = IsotonicRegression::<f64>::new();
1129        assert!(model.fit(&x, &y).is_err());
1130    }
1131
1132    #[test]
1133    fn test_pav_all_equal() {
1134        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1135        let y = array![3.0, 3.0, 3.0, 3.0];
1136
1137        let model = IsotonicRegression::<f64>::new();
1138        let fitted = model.fit(&x, &y).unwrap();
1139        let preds = fitted.predict(&x).unwrap();
1140
1141        for i in 0..4 {
1142            assert_relative_eq!(preds[i], 3.0, epsilon = 1e-10);
1143        }
1144    }
1145
1146    #[test]
1147    fn test_make_unique_weighted_collapse() {
1148        // Exercises the internal weighted `make_unique` + weighted PAVA that
1149        // back `_make_unique` (REQ-8) and enable `sample_weight` (REQ-7, #568).
1150        //
1151        // Oracle (scikit-learn 1.5.2, sklearn/isotonic.py:317-319 via
1152        // _isotonic.pyx `_make_unique`):
1153        //   python3 -c "import numpy as np; from sklearn.isotonic import \
1154        //   IsotonicRegression; \
1155        //   m=IsotonicRegression(out_of_bounds='clip').fit( \
1156        //     np.array([1.,1.,2.,3.]).reshape(-1,1), np.array([1.,3.,2.,4.]), \
1157        //     sample_weight=np.array([3.,1.,1.,1.])); \
1158        //   print(m.X_thresholds_.tolist(), m.y_thresholds_.tolist())"
1159        //   # -> [1.0, 2.0, 3.0] [1.5, 2.0, 4.0]
1160        //
1161        // The X=1 run collapses to the weighted mean (3*1 + 1*3)/4 = 1.5, the
1162        // run weight is 3+1 = 4, and the already-monotone [1.5, 2, 4] is
1163        // unchanged by the pool.
1164        let xs = [1.0_f64, 1.0, 2.0, 3.0];
1165        let ys = [1.0_f64, 3.0, 2.0, 4.0];
1166        let ws = [3.0_f64, 1.0, 1.0, 1.0];
1167
1168        let (ux, uy, uw) = make_unique(&xs, &ys, &ws);
1169        assert_eq!(ux, vec![1.0, 2.0, 3.0]);
1170        assert_relative_eq!(uy[0], 1.5, epsilon = 1e-12);
1171        assert_relative_eq!(uy[1], 2.0, epsilon = 1e-12);
1172        assert_relative_eq!(uy[2], 4.0, epsilon = 1e-12);
1173        assert_eq!(uw, vec![4.0, 1.0, 1.0]);
1174
1175        let (rx, ry) = pav_increasing_unique_weighted(&ux, &uy, &uw);
1176        assert_eq!(rx, vec![1.0, 2.0, 3.0]);
1177        assert_relative_eq!(ry[0], 1.5, epsilon = 1e-12);
1178        assert_relative_eq!(ry[1], 2.0, epsilon = 1e-12);
1179        assert_relative_eq!(ry[2], 4.0, epsilon = 1e-12);
1180    }
1181
1182    #[test]
1183    fn test_unsorted_x() {
1184        // PAV should handle unsorted x by sorting internally.
1185        let x = Array2::from_shape_vec((4, 1), vec![3.0, 1.0, 4.0, 2.0]).unwrap();
1186        let y = array![3.0, 1.0, 4.0, 2.0];
1187
1188        let model = IsotonicRegression::<f64>::new();
1189        let fitted = model.fit(&x, &y).unwrap();
1190
1191        // Predict at sorted x values.
1192        let x_sorted = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1193        let preds = fitted.predict(&x_sorted).unwrap();
1194
1195        for i in 1..preds.len() {
1196            assert!(preds[i] >= preds[i - 1] - 1e-10);
1197        }
1198    }
1199}