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}