use crate::linear::common::{
core_array1_to_py, core_array2_to_py, pyarray_to_core_array1, pyarray_to_core_array2,
PyValueError,
};
use numpy::{PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2, PyUntypedArrayMethods};
use pyo3::prelude::*;
use scirs2_core::ndarray::{Array1, Array2};
use sklears_model_selection::{train_test_split as core_train_test_split, CrossValidator, KFold};
type TrainTestSplitResult = (
Py<PyArray2<f64>>,
Py<PyArray2<f64>>,
Py<PyArray1<f64>>,
Py<PyArray1<f64>>,
);
type CoreTrainTestSplitResult = (Array2<f64>, Array2<f64>, Array1<f64>, Array1<f64>);
pub fn train_test_split_core(
x: &Array2<f64>,
y: &Array1<f64>,
test_size: Option<f64>,
random_state: Option<u64>,
) -> PyResult<CoreTrainTestSplitResult> {
core_train_test_split(x, y, test_size.unwrap_or(0.25), random_state)
.map_err(|e| PyValueError::new_err(format!("train_test_split failed: {e}")))
}
#[pyfunction]
#[pyo3(signature = (x, y, test_size=None, random_state=None))]
pub fn train_test_split(
py: Python<'_>,
x: PyReadonlyArray2<f64>,
y: PyReadonlyArray1<f64>,
test_size: Option<f64>,
random_state: Option<u64>,
) -> PyResult<TrainTestSplitResult> {
let x_arr = pyarray_to_core_array2(x)?;
let y_arr = pyarray_to_core_array1(y)?;
let (x_train, x_test, y_train, y_test) =
train_test_split_core(&x_arr, &y_arr, test_size, random_state)?;
Ok((
core_array2_to_py(py, &x_train)?,
core_array2_to_py(py, &x_test)?,
core_array1_to_py(py, &y_train),
core_array1_to_py(py, &y_test),
))
}
#[pyclass(name = "KFold")]
pub struct PyKFold {
inner: KFold,
}
impl PyKFold {
pub fn split_core(&self, n_samples: usize) -> PyResult<Vec<(Vec<usize>, Vec<usize>)>> {
let n_splits = self.inner.n_splits();
if n_splits > n_samples {
return Err(PyValueError::new_err(format!(
"Cannot have number of splits n_splits={n_splits} greater than the \
number of samples n_samples={n_samples}"
)));
}
Ok(self.inner.split(n_samples, None))
}
}
#[pymethods]
impl PyKFold {
#[new]
#[pyo3(signature = (n_splits=5, shuffle=false, random_state=None))]
pub fn new(n_splits: usize, shuffle: bool, random_state: Option<u64>) -> PyResult<Self> {
if n_splits < 2 {
return Err(PyValueError::new_err(format!(
"n_splits must be at least 2, got {n_splits}"
)));
}
let mut inner = KFold::new(n_splits).shuffle(shuffle);
if let Some(seed) = random_state {
inner = inner.random_state(seed);
}
Ok(Self { inner })
}
fn get_n_splits(&self) -> usize {
self.inner.n_splits()
}
#[pyo3(signature = (x, y=None))]
fn split(
&self,
x: PyReadonlyArray2<f64>,
y: Option<Bound<'_, PyAny>>,
) -> PyResult<Vec<(Vec<usize>, Vec<usize>)>> {
let _ = y;
let n_samples = x.shape()[0];
self.split_core(n_samples)
}
fn __repr__(&self) -> String {
format!("KFold(n_splits={})", self.inner.n_splits())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_indexed_dataset(n_samples: usize, n_features: usize) -> (Array2<f64>, Array1<f64>) {
let x_data: Vec<f64> = (0..n_samples * n_features).map(|v| v as f64).collect();
let x = Array2::from_shape_vec((n_samples, n_features), x_data)
.expect("shape matches data length");
let y = Array1::from_vec((0..n_samples).map(|i| i as f64).collect());
(x, y)
}
#[test]
fn train_test_split_respects_requested_test_size() {
let (x, y) = make_indexed_dataset(100, 2);
let (x_train, x_test, y_train, y_test) =
train_test_split_core(&x, &y, Some(0.3), Some(7)).expect("split should succeed");
assert_eq!(x_test.nrows(), 30);
assert_eq!(x_train.nrows(), 70);
assert_eq!(y_test.len(), 30);
assert_eq!(y_train.len(), 70);
}
#[test]
fn train_test_split_train_and_test_are_disjoint_and_cover_everything() {
let (x, y) = make_indexed_dataset(100, 2);
let (_, _, y_train, y_test) =
train_test_split_core(&x, &y, Some(0.3), Some(7)).expect("split should succeed");
let mut seen: Vec<usize> = y_train
.iter()
.chain(y_test.iter())
.map(|&v| v as usize)
.collect();
seen.sort_unstable();
assert_eq!(seen, (0..100).collect::<Vec<_>>());
}
#[test]
fn train_test_split_same_random_state_is_deterministic() {
let (x, y) = make_indexed_dataset(50, 3);
let (_, _, y_train1, y_test1) =
train_test_split_core(&x, &y, Some(0.25), Some(123)).expect("split should succeed");
let (_, _, y_train2, y_test2) =
train_test_split_core(&x, &y, Some(0.25), Some(123)).expect("split should succeed");
assert_eq!(y_train1, y_train2);
assert_eq!(y_test1, y_test2);
}
#[test]
fn train_test_split_defaults_test_size_to_a_quarter() {
let (x, y) = make_indexed_dataset(40, 2);
let (_, x_test, _, _) =
train_test_split_core(&x, &y, None, Some(1)).expect("split should succeed");
assert_eq!(x_test.nrows(), 10);
}
#[test]
fn kfold_produces_n_splits_folds_covering_every_index_exactly_once() {
let kfold = PyKFold::new(5, false, None).expect("n_splits=5 is valid");
let folds = kfold.split_core(100).expect("split should succeed");
assert_eq!(folds.len(), 5);
let mut all_test_indices: Vec<usize> = folds
.iter()
.flat_map(|(_, test)| test.iter().copied())
.collect();
all_test_indices.sort_unstable();
assert_eq!(all_test_indices, (0..100).collect::<Vec<_>>());
for (train, test) in &folds {
assert_eq!(train.len() + test.len(), 100);
}
}
#[test]
fn kfold_new_rejects_n_splits_below_two_with_value_error_not_panic() {
assert!(PyKFold::new(1, false, None).is_err());
assert!(PyKFold::new(0, false, None).is_err());
}
}