Skip to main content

sklears_python/
model_selection.rs

1//! Python bindings for model selection utilities
2//!
3//! This module provides Python bindings for sklears model selection,
4//! offering scikit-learn compatible cross-validation and data splitting utilities.
5
6use crate::linear::common::{
7    core_array1_to_py, core_array2_to_py, pyarray_to_core_array1, pyarray_to_core_array2,
8    PyValueError,
9};
10use numpy::{PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2, PyUntypedArrayMethods};
11use pyo3::prelude::*;
12use scirs2_core::ndarray::{Array1, Array2};
13use sklears_model_selection::{train_test_split as core_train_test_split, CrossValidator, KFold};
14
15/// Train-test split result: (X_train, X_test, y_train, y_test)
16type TrainTestSplitResult = (
17    Py<PyArray2<f64>>,
18    Py<PyArray2<f64>>,
19    Py<PyArray1<f64>>,
20    Py<PyArray1<f64>>,
21);
22
23/// Train-test split result using plain `ndarray` types, i.e. the
24/// Python-free counterpart of [`TrainTestSplitResult`].
25type CoreTrainTestSplitResult = (Array2<f64>, Array2<f64>, Array1<f64>, Array1<f64>);
26
27/// Core (Python-free) train/test split logic, directly unit-testable
28/// without a live Python interpreter -- this crate builds with pyo3's
29/// `extension-module` feature (required so the compiled `cdylib` can be
30/// imported from Python), which means `Python::with_gil` cannot be used
31/// from a standalone `cargo test` binary.
32///
33/// Defaults `test_size` to `0.25` (scikit-learn's default) when `None`.
34///
35/// # Known limitations
36/// The underlying `sklears_model_selection::train_test_split` always
37/// shuffles before splitting and has no `train_size`/`stratify` support
38/// (unlike scikit-learn's version). Extending the core splitting algorithm
39/// to support those is out of scope for this fix.
40///
41/// `pub` (rather than crate-private) so that
42/// `benches/core_helpers_benchmarks.rs` -- which compiles as a separate
43/// crate -- can call it directly for the same reason; this is a minimal
44/// exposed surface for benchmarking, not part of the stable Python-facing
45/// API.
46pub fn train_test_split_core(
47    x: &Array2<f64>,
48    y: &Array1<f64>,
49    test_size: Option<f64>,
50    random_state: Option<u64>,
51) -> PyResult<CoreTrainTestSplitResult> {
52    core_train_test_split(x, y, test_size.unwrap_or(0.25), random_state)
53        .map_err(|e| PyValueError::new_err(format!("train_test_split failed: {e}")))
54}
55
56/// Split arrays into random train and test subsets.
57///
58/// Notes
59/// -----
60/// The underlying implementation always shuffles before splitting and does
61/// not yet support scikit-learn's `train_size` or `stratify` parameters;
62/// only `test_size` and `random_state` are real, working parameters.
63#[pyfunction]
64#[pyo3(signature = (x, y, test_size=None, random_state=None))]
65pub fn train_test_split(
66    py: Python<'_>,
67    x: PyReadonlyArray2<f64>,
68    y: PyReadonlyArray1<f64>,
69    test_size: Option<f64>,
70    random_state: Option<u64>,
71) -> PyResult<TrainTestSplitResult> {
72    let x_arr = pyarray_to_core_array2(x)?;
73    let y_arr = pyarray_to_core_array1(y)?;
74
75    let (x_train, x_test, y_train, y_test) =
76        train_test_split_core(&x_arr, &y_arr, test_size, random_state)?;
77
78    Ok((
79        core_array2_to_py(py, &x_train)?,
80        core_array2_to_py(py, &x_test)?,
81        core_array1_to_py(py, &y_train),
82        core_array1_to_py(py, &y_test),
83    ))
84}
85
86/// K-Fold cross-validator.
87///
88/// Splits data into `n_splits` consecutive (or shuffled) folds; each fold
89/// is used once as the validation set while the remaining folds form the
90/// training set.
91#[pyclass(name = "KFold")]
92pub struct PyKFold {
93    inner: KFold,
94}
95
96impl PyKFold {
97    /// Core split logic, directly unit-testable without a live Python
98    /// interpreter (see `train_test_split_core` for why) and `pub`
99    /// (exposed for benchmarking from `benches/`, not part of the stable
100    /// Python-facing API).
101    pub fn split_core(&self, n_samples: usize) -> PyResult<Vec<(Vec<usize>, Vec<usize>)>> {
102        let n_splits = self.inner.n_splits();
103        if n_splits > n_samples {
104            return Err(PyValueError::new_err(format!(
105                "Cannot have number of splits n_splits={n_splits} greater than the \
106                 number of samples n_samples={n_samples}"
107            )));
108        }
109        Ok(self.inner.split(n_samples, None))
110    }
111}
112
113#[pymethods]
114impl PyKFold {
115    /// `pub` in addition to being reachable from Python via `#[new]`, so
116    /// `benches/core_helpers_benchmarks.rs` (a separate crate) can
117    /// construct instances to call `split_core` on; not part of the
118    /// stable Python-facing API surface.
119    #[new]
120    #[pyo3(signature = (n_splits=5, shuffle=false, random_state=None))]
121    pub fn new(n_splits: usize, shuffle: bool, random_state: Option<u64>) -> PyResult<Self> {
122        // The real `KFold::new` asserts `n_splits >= 2` and panics
123        // otherwise; guard here so bad input raises a normal Python
124        // `ValueError` instead of an uncatchable Rust panic.
125        if n_splits < 2 {
126            return Err(PyValueError::new_err(format!(
127                "n_splits must be at least 2, got {n_splits}"
128            )));
129        }
130
131        let mut inner = KFold::new(n_splits).shuffle(shuffle);
132        if let Some(seed) = random_state {
133            inner = inner.random_state(seed);
134        }
135
136        Ok(Self { inner })
137    }
138
139    fn get_n_splits(&self) -> usize {
140        self.inner.n_splits()
141    }
142
143    /// Generate train/test indices for each fold.
144    ///
145    /// `y` is accepted (but ignored) purely for scikit-learn API
146    /// compatibility: plain `KFold` does not need target labels to split
147    /// (unlike `StratifiedKFold`), matching scikit-learn's own
148    /// `KFold.split(X, y=None, groups=None)` signature. It is typed as an
149    /// untyped `PyAny` rather than a specific numpy dtype so that passing
150    /// e.g. integer class labels here (as scikit-learn users routinely do)
151    /// never fails with a dtype mismatch.
152    #[pyo3(signature = (x, y=None))]
153    fn split(
154        &self,
155        x: PyReadonlyArray2<f64>,
156        y: Option<Bound<'_, PyAny>>,
157    ) -> PyResult<Vec<(Vec<usize>, Vec<usize>)>> {
158        let _ = y;
159        let n_samples = x.shape()[0];
160        self.split_core(n_samples)
161    }
162
163    fn __repr__(&self) -> String {
164        format!("KFold(n_splits={})", self.inner.n_splits())
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn make_indexed_dataset(n_samples: usize, n_features: usize) -> (Array2<f64>, Array1<f64>) {
173        let x_data: Vec<f64> = (0..n_samples * n_features).map(|v| v as f64).collect();
174        let x = Array2::from_shape_vec((n_samples, n_features), x_data)
175            .expect("shape matches data length");
176        let y = Array1::from_vec((0..n_samples).map(|i| i as f64).collect());
177        (x, y)
178    }
179
180    #[test]
181    fn train_test_split_respects_requested_test_size() {
182        let (x, y) = make_indexed_dataset(100, 2);
183
184        let (x_train, x_test, y_train, y_test) =
185            train_test_split_core(&x, &y, Some(0.3), Some(7)).expect("split should succeed");
186
187        assert_eq!(x_test.nrows(), 30);
188        assert_eq!(x_train.nrows(), 70);
189        assert_eq!(y_test.len(), 30);
190        assert_eq!(y_train.len(), 70);
191    }
192
193    #[test]
194    fn train_test_split_train_and_test_are_disjoint_and_cover_everything() {
195        let (x, y) = make_indexed_dataset(100, 2);
196
197        let (_, _, y_train, y_test) =
198            train_test_split_core(&x, &y, Some(0.3), Some(7)).expect("split should succeed");
199
200        let mut seen: Vec<usize> = y_train
201            .iter()
202            .chain(y_test.iter())
203            .map(|&v| v as usize)
204            .collect();
205        seen.sort_unstable();
206
207        // If train/test overlapped, `seen` would contain duplicate indices
208        // and this equality would fail; if any index were dropped instead,
209        // the comparison against the full range would fail too.
210        assert_eq!(seen, (0..100).collect::<Vec<_>>());
211    }
212
213    #[test]
214    fn train_test_split_same_random_state_is_deterministic() {
215        let (x, y) = make_indexed_dataset(50, 3);
216
217        let (_, _, y_train1, y_test1) =
218            train_test_split_core(&x, &y, Some(0.25), Some(123)).expect("split should succeed");
219        let (_, _, y_train2, y_test2) =
220            train_test_split_core(&x, &y, Some(0.25), Some(123)).expect("split should succeed");
221
222        assert_eq!(y_train1, y_train2);
223        assert_eq!(y_test1, y_test2);
224    }
225
226    #[test]
227    fn train_test_split_defaults_test_size_to_a_quarter() {
228        let (x, y) = make_indexed_dataset(40, 2);
229
230        let (_, x_test, _, _) =
231            train_test_split_core(&x, &y, None, Some(1)).expect("split should succeed");
232
233        assert_eq!(x_test.nrows(), 10);
234    }
235
236    #[test]
237    fn kfold_produces_n_splits_folds_covering_every_index_exactly_once() {
238        let kfold = PyKFold::new(5, false, None).expect("n_splits=5 is valid");
239        let folds = kfold.split_core(100).expect("split should succeed");
240
241        assert_eq!(folds.len(), 5);
242
243        let mut all_test_indices: Vec<usize> = folds
244            .iter()
245            .flat_map(|(_, test)| test.iter().copied())
246            .collect();
247        all_test_indices.sort_unstable();
248        assert_eq!(all_test_indices, (0..100).collect::<Vec<_>>());
249
250        // Every fold's train/test split should also partition n_samples.
251        for (train, test) in &folds {
252            assert_eq!(train.len() + test.len(), 100);
253        }
254    }
255
256    #[test]
257    fn kfold_new_rejects_n_splits_below_two_with_value_error_not_panic() {
258        assert!(PyKFold::new(1, false, None).is_err());
259        assert!(PyKFold::new(0, false, None).is_err());
260    }
261}