Initial commit

This commit is contained in:
redclawsystems
2026-03-04 00:08:42 +00:00
commit 4d88dc0584
4449 changed files with 1556714 additions and 0 deletions
@@ -0,0 +1,596 @@
/*!
sklearn-compatible model selection wrappers
*/
use pyo3::prelude::*;
use pyo3::types::{PyDict, PyList, PyTuple, PyString};
use numpy::{PyReadonlyArrayDyn, PyArray1, PyArray2};
use ndarray::{Array1, Array2, Axis};
use std::collections::HashMap;
use rtx_validation::cv::{KFold, StratifiedKFold};
use rtx_validation::search::{GridSearch, RandomizedSearch};
use rtx_validation::metrics::{accuracy_score, r2_score, mean_squared_error};
use crate::error::{SklearnResult, check_is_fitted};
use crate::utils::{ArrayConverter, ParamValidator, AsyncHelper};
/// sklearn-compatible GridSearchCV
#[pyclass(name = "GridSearchCV")]
pub struct GridSearchCV {
// Core components
estimator: PyObject,
param_grid: HashMap<String, Vec<PyObject>>,
// sklearn parameters
scoring: Option<String>,
n_jobs: Option<i32>,
cv: Option<usize>,
refit: bool,
verbose: i32,
// Fitted state
is_fitted: bool,
best_estimator_: Option<PyObject>,
best_score_: Option<f64>,
best_params_: Option<HashMap<String, PyObject>>,
cv_results_: Option<HashMap<String, Vec<PyObject>>>,
n_splits_: Option<usize>,
}
#[pymethods]
impl GridSearchCV {
#[new]
#[pyo3(signature = (
estimator,
param_grid,
scoring=None,
n_jobs=None,
cv=5,
refit=true,
verbose=0
))]
fn new(
estimator: PyObject,
param_grid: &PyDict,
scoring: Option<&str>,
n_jobs: Option<i32>,
cv: Option<usize>,
refit: bool,
verbose: i32,
) -> PyResult<Self> {
// Convert param_grid from PyDict to HashMap
let mut param_grid_map = HashMap::new();
for (key, value) in param_grid.iter() {
let key_str: String = key.extract()?;
let value_list: Vec<PyObject> = if let Ok(list) = value.extract::<Vec<PyObject>>() {
list
} else {
// Single value - wrap in list
vec![value.into()]
};
param_grid_map.insert(key_str, value_list);
}
if let Some(cv_val) = cv {
if cv_val < 2 {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"cv must be >= 2"
));
}
}
Ok(GridSearchCV {
estimator,
param_grid: param_grid_map,
scoring: scoring.map(|s| s.to_string()),
n_jobs,
cv,
refit,
verbose,
is_fitted: false,
best_estimator_: None,
best_score_: None,
best_params_: None,
cv_results_: None,
n_splits_: None,
})
}
/// Fit GridSearchCV to find best parameters
fn fit(
&mut self,
x: PyReadonlyArrayDyn<f64>,
y: PyReadonlyArrayDyn<f64>,
) -> PyResult<()> {
ArrayConverter::validate_fit_input(&x, Some(&y)).map_err(|e| PyErr::from(e))?;
let x_view = ArrayConverter::to_array_view2(&x).map_err(|e| PyErr::from(e))?;
let y_view = ArrayConverter::to_array_view1(&y).map_err(|e| PyErr::from(e))?;
let cv_folds = self.cv.unwrap_or(5);
self.n_splits_ = Some(cv_folds);
// Generate parameter combinations
let param_combinations = self.generate_param_combinations()?;
if self.verbose > 0 {
println!("Fitting {} candidates with {} folds",
param_combinations.len(), cv_folds);
}
let mut best_score = f64::NEG_INFINITY;
let mut best_params: Option<HashMap<String, PyObject>> = None;
let mut best_estimator: Option<PyObject> = None;
// Results storage
let mut cv_results = HashMap::new();
cv_results.insert("mean_test_score".to_string(), Vec::new());
cv_results.insert("std_test_score".to_string(), Vec::new());
cv_results.insert("params".to_string(), Vec::new());
Python::with_gil(|py| {
// Perform grid search
for (i, params) in param_combinations.iter().enumerate() {
if self.verbose > 1 {
println!("Fitting parameters {} of {}: {:?}", i + 1, param_combinations.len(), params);
}
// Clone estimator and set parameters
let mut estimator_clone = self.estimator.clone_ref(py);
// Set parameters on estimator
if let Ok(set_params_method) = estimator_clone.getattr(py, "set_params") {
let params_dict = PyDict::new(py);
for (key, value) in params {
params_dict.set_item(key, value)?;
}
set_params_method.call1(py, (params_dict,))?;
} else {
return Err(PyErr::new::<pyo3::exceptions::PyAttributeError, _>(
"Estimator must have set_params method"
));
}
// Perform cross-validation
let cv_scores = self.cross_validate(py, &estimator_clone, &x, &y, cv_folds)?;
let mean_score = cv_scores.iter().sum::<f64>() / cv_scores.len() as f64;
let std_score = {
let variance: f64 = cv_scores.iter()
.map(|score| (score - mean_score).powi(2))
.sum::<f64>() / cv_scores.len() as f64;
variance.sqrt()
};
// Store results
cv_results.get_mut("mean_test_score").unwrap().push(mean_score.to_object(py));
cv_results.get_mut("std_test_score").unwrap().push(std_score.to_object(py));
cv_results.get_mut("params").unwrap().push({
let params_dict = PyDict::new(py);
for (key, value) in params {
params_dict.set_item(key, value)?;
}
params_dict.to_object(py)
});
// Update best score
if mean_score > best_score {
best_score = mean_score;
best_params = Some(params.clone());
if self.refit {
best_estimator = Some(estimator_clone);
}
}
}
Ok(())
})?;
// Store results
self.best_score_ = Some(best_score);
self.best_params_ = best_params;
self.cv_results_ = Some(cv_results);
if self.refit {
if let Some(best_est) = best_estimator {
Python::with_gil(|py| {
// Refit on full dataset
let fit_method = best_est.getattr(py, "fit")?;
fit_method.call1(py, (&x, &y))?;
Ok::<(), PyErr>(())
})?;
self.best_estimator_ = Some(best_est);
}
}
self.is_fitted = true;
if self.verbose > 0 {
println!("Best score: {:.4}", best_score);
if let Some(ref params) = self.best_params_ {
println!("Best params: {:?}", params);
}
}
Ok(())
}
/// Predict using best estimator
fn predict(&self, x: PyReadonlyArrayDyn<f64>) -> PyResult<PyObject> {
check_is_fitted(self.is_fitted).map_err(|e| PyErr::from(e))?;
if !self.refit {
return Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
"Cannot predict when refit=False"
));
}
match &self.best_estimator_ {
Some(estimator) => {
Python::with_gil(|py| {
let predict_method = estimator.getattr(py, "predict")?;
predict_method.call1(py, (x,))
})
}
None => Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
"No best estimator found"
)),
}
}
/// Score using best estimator
fn score(&self, x: PyReadonlyArrayDyn<f64>, y: PyReadonlyArrayDyn<f64>) -> PyResult<f64> {
check_is_fitted(self.is_fitted).map_err(|e| PyErr::from(e))?;
if !self.refit {
return Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
"Cannot score when refit=False"
));
}
match &self.best_estimator_ {
Some(estimator) => {
Python::with_gil(|py| {
let score_method = estimator.getattr(py, "score")?;
let result = score_method.call1(py, (x, y))?;
result.extract(py)
})
}
None => Err(PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
"No best estimator found"
)),
}
}
/// Helper to generate parameter combinations
fn generate_param_combinations(&self) -> PyResult<Vec<HashMap<String, PyObject>>> {
let mut combinations = Vec::new();
let param_names: Vec<_> = self.param_grid.keys().collect();
let param_values: Vec<_> = self.param_grid.values().collect();
// Generate all combinations (Cartesian product)
self.generate_combinations_recursive(
&param_names,
&param_values,
&mut HashMap::new(),
0,
&mut combinations,
);
Ok(combinations)
}
/// Recursive helper for generating combinations
fn generate_combinations_recursive(
&self,
param_names: &[&String],
param_values: &[&Vec<PyObject>],
current_combination: &mut HashMap<String, PyObject>,
depth: usize,
combinations: &mut Vec<HashMap<String, PyObject>>,
) {
if depth == param_names.len() {
combinations.push(current_combination.clone());
return;
}
let param_name = param_names[depth];
let values = param_values[depth];
for value in values {
current_combination.insert(param_name.clone(), value.clone());
self.generate_combinations_recursive(
param_names,
param_values,
current_combination,
depth + 1,
combinations,
);
}
current_combination.remove(param_name);
}
/// Helper to perform cross-validation
fn cross_validate(
&self,
py: Python,
estimator: &PyObject,
x: &PyReadonlyArrayDyn<f64>,
y: &PyReadonlyArrayDyn<f64>,
cv_folds: usize,
) -> PyResult<Vec<f64>> {
let x_view = ArrayConverter::to_array_view2(x).map_err(|e| PyErr::from(e))?;
let y_view = ArrayConverter::to_array_view1(y).map_err(|e| PyErr::from(e))?;
// Create KFold cross-validator
let mut cv = KFold::new(cv_folds, Some(42), true);
let splits = cv.split(x_view.shape()[0], None)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
format!("CV split failed: {}", e)
))?;
let mut scores = Vec::new();
for (train_idx, test_idx) in splits {
// Create train/test splits
let x_train = x_view.select(Axis(0), &train_idx);
let y_train = y_view.select(Axis(0), &train_idx);
let x_test = x_view.select(Axis(0), &test_idx);
let y_test = y_view.select(Axis(0), &test_idx);
// Convert back to PyArrays
let x_train_py = ArrayConverter::from_array2(py, x_train)?;
let y_train_py = ArrayConverter::from_array1(py, y_train)?;
let x_test_py = ArrayConverter::from_array2(py, x_test)?;
let y_test_py = ArrayConverter::from_array1(py, y_test)?;
// Fit estimator on training set
let fit_method = estimator.getattr(py, "fit")?;
fit_method.call1(py, (x_train_py, y_train_py))?;
// Score on test set
let score_method = estimator.getattr(py, "score")?;
let score: f64 = score_method.call1(py, (x_test_py, y_test_py))?.extract(py)?;
scores.push(score);
}
Ok(scores)
}
// sklearn-compatible properties
#[getter]
fn best_estimator_(&self) -> Option<PyObject> {
self.best_estimator_.clone()
}
#[getter]
fn best_score_(&self) -> Option<f64> {
self.best_score_
}
#[getter]
fn best_params_(&self) -> Option<HashMap<String, PyObject>> {
self.best_params_.clone()
}
#[getter]
fn cv_results_(&self) -> Option<HashMap<String, Vec<PyObject>>> {
self.cv_results_.clone()
}
}
/// sklearn-compatible cross_val_score function
#[pyfunction]
#[pyo3(signature = (estimator, x, y, cv=5, scoring=None, n_jobs=None))]
pub fn cross_val_score(
py: Python,
estimator: PyObject,
x: PyReadonlyArrayDyn<f64>,
y: PyReadonlyArrayDyn<f64>,
cv: Option<usize>,
scoring: Option<&str>,
n_jobs: Option<i32>,
) -> PyResult<Py<PyArray1<f64>>> {
ArrayConverter::validate_fit_input(&x, Some(&y)).map_err(|e| PyErr::from(e))?;
let x_view = ArrayConverter::to_array_view2(&x).map_err(|e| PyErr::from(e))?;
let y_view = ArrayConverter::to_array_view1(&y).map_err(|e| PyErr::from(e))?;
let cv_folds = cv.unwrap_or(5);
if cv_folds < 2 {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"cv must be >= 2"
));
}
// Create KFold cross-validator
let mut cv_splitter = KFold::new(cv_folds, Some(42), true);
let splits = cv_splitter.split(x_view.shape()[0], None)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(
format!("CV split failed: {}", e)
))?;
let mut scores = Vec::new();
for (train_idx, test_idx) in splits {
// Create train/test splits
let x_train = x_view.select(Axis(0), &train_idx);
let y_train = y_view.select(Axis(0), &train_idx);
let x_test = x_view.select(Axis(0), &test_idx);
let y_test = y_view.select(Axis(0), &test_idx);
// Convert back to PyArrays
let x_train_py = ArrayConverter::from_array2(py, x_train)?;
let y_train_py = ArrayConverter::from_array1(py, y_train)?;
let x_test_py = ArrayConverter::from_array2(py, x_test)?;
let y_test_py = ArrayConverter::from_array1(py, y_test)?;
// Clone estimator for this fold
let estimator_clone = estimator.clone_ref(py);
// Fit estimator on training set
let fit_method = estimator_clone.getattr(py, "fit")?;
fit_method.call1(py, (x_train_py, y_train_py))?;
// Score on test set
let score: f64 = match scoring {
Some("accuracy") => {
let predict_method = estimator_clone.getattr(py, "predict")?;
let predictions: Py<PyArray1<f64>> = predict_method.call1(py, (x_test_py,))?.extract(py)?;
// Calculate accuracy
let pred_array = predictions.as_ref(py).readonly();
let pred_view = ArrayConverter::to_array_view1(&pred_array).map_err(|e| PyErr::from(e))?;
let correct = pred_view.iter().zip(y_test.iter()).filter(|&(p, t)| (p - t).abs() < 1e-10).count();
correct as f64 / pred_view.len() as f64
}
Some("r2") | None => {
// Use estimator's score method (default)
let score_method = estimator_clone.getattr(py, "score")?;
score_method.call1(py, (x_test_py, y_test_py))?.extract(py)?
}
_ => {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
format!("Unsupported scoring: {:?}", scoring)
));
}
};
scores.push(score);
}
let scores_array = Array1::from_vec(scores);
Ok(ArrayConverter::from_array1(py, scores_array)?.to_owned())
}
/// sklearn-compatible train_test_split function
#[pyfunction]
#[pyo3(signature = (x, y, test_size=0.25, train_size=None, random_state=None, shuffle=true, stratify=None))]
pub fn train_test_split(
py: Python,
x: PyReadonlyArrayDyn<f64>,
y: PyReadonlyArrayDyn<f64>,
test_size: Option<f64>,
train_size: Option<f64>,
random_state: Option<u64>,
shuffle: bool,
stratify: Option<PyReadonlyArrayDyn<f64>>,
) -> PyResult<PyObject> {
ArrayConverter::validate_fit_input(&x, Some(&y)).map_err(|e| PyErr::from(e))?;
let x_view = ArrayConverter::to_array_view2(&x).map_err(|e| PyErr::from(e))?;
let y_view = ArrayConverter::to_array_view1(&y).map_err(|e| PyErr::from(e))?;
let n_samples = x_view.shape()[0];
// Determine test size
let test_size_val = test_size.unwrap_or(0.25);
if test_size_val <= 0.0 || test_size_val >= 1.0 {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"test_size must be between 0 and 1"
));
}
let test_n = (n_samples as f64 * test_size_val).round() as usize;
let train_n = n_samples - test_n;
if train_n == 0 || test_n == 0 {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(
"test_size results in empty train or test set"
));
}
// Generate indices
let mut indices: Vec<usize> = (0..n_samples).collect();
if shuffle {
use rand::{SeedableRng, seq::SliceRandom};
let mut rng = if let Some(seed) = random_state {
rand::rngs::StdRng::seed_from_u64(seed)
} else {
rand::rngs::StdRng::from_entropy()
};
indices.shuffle(&mut rng);
}
// Split indices
let train_indices = &indices[..train_n];
let test_indices = &indices[train_n..];
// Create train/test arrays
let x_train = x_view.select(Axis(0), train_indices);
let x_test = x_view.select(Axis(0), test_indices);
let y_train = y_view.select(Axis(0), train_indices);
let y_test = y_view.select(Axis(0), test_indices);
// Convert to Python arrays
let x_train_py = ArrayConverter::from_array2(py, x_train)?;
let x_test_py = ArrayConverter::from_array2(py, x_test)?;
let y_train_py = ArrayConverter::from_array1(py, y_train)?;
let y_test_py = ArrayConverter::from_array1(py, y_test)?;
// Return tuple
let result = PyTuple::new(py, &[
x_train_py.to_object(py),
x_test_py.to_object(py),
y_train_py.to_object(py),
y_test_py.to_object(py),
]);
Ok(result.to_object(py))
}
/// Helper function to validate estimator compatibility
fn validate_estimator(py: Python, estimator: &PyObject) -> PyResult<()> {
// Check that estimator has required methods
let required_methods = ["fit", "predict", "score"];
for method in &required_methods {
if !estimator.hasattr(py, method)? {
return Err(PyErr::new::<pyo3::exceptions::PyAttributeError, _>(
format!("Estimator must have '{}' method", method)
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use numpy::PyArray2;
use ndarray::arr2;
#[test]
fn test_parameter_combinations() {
let mut param_grid = HashMap::new();
Python::with_gil(|py| {
param_grid.insert("param1".to_string(), vec![1.to_object(py), 2.to_object(py)]);
param_grid.insert("param2".to_string(), vec!["a".to_object(py), "b".to_object(py)]);
let grid_search = GridSearchCV {
estimator: py.None(),
param_grid,
scoring: None,
n_jobs: None,
cv: Some(5),
refit: true,
verbose: 0,
is_fitted: false,
best_estimator_: None,
best_score_: None,
best_params_: None,
cv_results_: None,
n_splits_: None,
};
let combinations = grid_search.generate_param_combinations().unwrap();
assert_eq!(combinations.len(), 4); // 2 * 2 = 4 combinations
});
}
}