| name | scikit-learn |
| description | [Applies to: **/*.py] Definitive guidelines for writing robust, maintainable, and performant scikit-learn code, emphasizing consistent preprocessing, API adherence, and data leakage prevention. |
| source | cursor_mdc |
scikit-learn Best Practices
This guide outlines our team's definitive best practices for using and extending scikit-learn. Adhering to these rules ensures consistent, reproducible, and production-ready machine learning code.
1. Code Organization and Structure
1.1. Always Use Pipelines for Preprocessing and Models
Pipelines are mandatory. They prevent data leakage, ensure consistent transformations across training and inference, and simplify hyperparameter tuning.
❌ BAD: Inconsistent manual transformations
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
import numpy as np
X, y = np.random.rand(100, 5), np.random.rand(100)
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
model = LinearRegression().fit(X_train_scaled, y_train)
y_pred = model.predict(X_test)
print(f"MSE (BAD): {mean_squared_error(y_test, y_pred):.2f}")
✅ GOOD: Encapsulate all steps in a Pipeline
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
import numpy as np
X, y = np.random.rand(100, 5), np.random.rand(100)
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
model = make_pipeline(StandardScaler(), LinearRegression())
model.fit(X_train, y_train)
y_pred = model.predict(X_test)
print(f"MSE (GOOD): {mean_squared_error(y_test, y_pred):.2f}")
1.2. Custom Estimators Must Adhere to the scikit-learn API
When creating custom transformers or models, strictly follow the scikit-learn estimator API for seamless integration with pipelines and model selection tools.
- Inherit from
BaseEstimator and relevant mixins (TransformerMixin, ClassifierMixin, RegressorMixin).
__init__ stores only hyperparameters as keyword arguments with defaults. No logic or data processing here.
fit(self, X, y=None) learns parameters from data. Store learned attributes with a trailing underscore (e.g., self.mean_). Return self.
transform(self, X) (for transformers) or predict(self, X)/predict_proba(self, X) (for predictors) applies the learned transformation/prediction.
- Implement
get_params and set_params (usually inherited from BaseEstimator).
❌ BAD: Non-compliant custom estimator
class BadCustomScaler:
def __init__(self, scale_factor):
if scale_factor <= 0:
raise ValueError("Scale factor must be positive")
self.scale_factor = scale_factor
self.mean_ = None
def fit(self, X):
self.mean_ = X.mean(axis=0)
return self
def transform(self, X):
if self.mean_ is None:
raise RuntimeError("Fit not called yet.")
return (X - self.mean_) * self.scale_factor
✅ GOOD: API-compliant custom estimator
from sklearn.base import BaseEstimator, TransformerMixin
import numpy as np
class GoodCustomScaler(BaseEstimator, TransformerMixin):
def __init__(self, scale_factor: float = 1.0):
self.scale_factor = scale_factor
def fit(self, X: np.ndarray, y=None):
X = self._validate_data(X)
if self.scale_factor <= 0:
raise ValueError("Scale factor must be positive")
self.mean_ = X.mean(axis=0)
return self
def transform(self, X: np.ndarray) -> np.ndarray:
X = self._validate_data(X)
if not hasattr(self, "mean_"):
raise RuntimeError("Estimator not fitted. Call fit() first.")
return (X - self.mean_) * self.scale_factor
2. Common Patterns and Anti-patterns
2.1. Prevent Data Leakage: Split First, Fit on Train Only
Always split your data into training and test sets before any preprocessing. fit and fit_transform methods must only be called on the training data. transform can then be called on both training and test data.
❌ BAD: Data leakage by fitting on all data
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
import numpy as np
X, y = np.random.rand(100, 5), np.random.rand(100)
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
X_train_scaled = X_scaled[:len(X_train)]
X_test_scaled = X_scaled[len(X_train):]
✅ GOOD: Correct data splitting and transformation
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
import numpy as np
X, y = np.random.rand(100, 5), np.random.rand(100)
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
2.2. Reproducibility: Always Set random_state
For any estimator or utility that involves randomness (e.g., train_test_split, RandomForestClassifier, KMeans), explicitly set the random_state parameter for reproducible results.
❌ BAD: Non-reproducible results
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
X_train, X_test, y_train, y_test = train_test_split(X, y)
model = RandomForestClassifier()
✅ GOOD: Reproducible results
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
model = RandomForestClassifier(random_state=42)
3. Performance Considerations
3.1. Leverage Cython for Performance-Critical Components
When extending scikit-learn with custom, performance-critical algorithms, use Cython and follow scikit-learn's internal conventions.
- Disable bounds checking and wraparound for production code.
- Use
sklearn.utils._typedefs for explicit type declarations.
- Use
sklearn.utils._openmp_helpers for OpenMP routines.
- Prefer memoryviews over
cnp.ndarray when possible.
from cython.parallel import prange
from sklearn.utils._typedefs cimport float64
from sklearn.utils._openmp_helpers cimport _get_num_threads
cdef void my_fast_function(float64[:] data, int n_threads) nogil:
cdef int i
with nogil:
for i in prange(data.shape[0], num_threads=n_threads, schedule='static'):
data[i] *= 2.0
4. Common Pitfalls and Gotchas
4.1. Avoid Overfitting on Validation Data During Hyperparameter Tuning
Use GridSearchCV or RandomizedSearchCV with a pipeline to ensure that cross-validation folds are correctly handled and preprocessing steps are refitted for each fold, preventing data leakage.
❌ BAD: Tuning hyperparameters outside a pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, train_test_split
import numpy as np
X, y = np.random.rand(100, 5), np.random.randint(0, 2, 100)
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
scaler = StandardScaler().fit(X_train)
X_train_scaled = scaler.transform(X_train)
param_grid = {'C': [0.1, 1.0, 10.0]}
grid_search = GridSearchCV(LogisticRegression(random_state=42), param_grid, cv=3)
grid_search.fit(X_train_scaled, y_train)
✅ GOOD: Tuning hyperparameters with a pipeline
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import GridSearchCV, train_test_split
import numpy as np
X, y = np.random.rand(100, 5), np.random.randint(0, 2, 100)
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
pipeline = Pipeline([
('scaler', StandardScaler()),
('logreg', LogisticRegression(random_state=42))
])
param_grid = {'logreg__C': [0.1, 1.0, 10.0]}
grid_search = GridSearchCV(pipeline, param_grid, cv=3)
grid_search.fit(X_train, y_train)
5. Type Hints
5.1. Use Type Hints Extensively
All function signatures, class attributes, and complex variable assignments must include type hints. This improves code readability, enables static analysis, and reduces bugs.
❌ BAD: Untyped function
def calculate_metric(y_true, y_pred):
return (y_true == y_pred).mean()
✅ GOOD: Clearly typed function
import numpy as np
from typing import Union
def calculate_metric(y_true: Union[np.ndarray, list], y_pred: Union[np.ndarray, list]) -> float:
"""Calculates accuracy for binary classification."""
y_true_arr = np.asarray(y_true)
y_pred_arr = np.asarray(y_pred)
return (y_true_arr == y_pred_arr).mean()
6. Virtual Environments
6.1. Isolate Project Dependencies with Virtual Environments
Always use a dedicated virtual environment (venv, conda, poetry, etc.) for each project. This prevents dependency conflicts and ensures consistent environments.
❌ BAD: Global pip install
pip install scikit-learn pandas numpy
✅ GOOD: Project-specific virtual environment
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
conda create -n my_ml_env python=3.9 scikit-learn pandas numpy
conda activate my_ml_env
7. Packaging
7.1. Use scikit-learn-contrib Template for Shareable Estimators
If you develop a custom estimator intended for public use or sharing across projects, leverage the scikit-learn-contrib template. It provides a robust structure, testing, and CI/CD setup, ensuring your estimator is fully compliant and easily consumable.
❌ BAD: Ad-hoc custom estimator distribution
✅ GOOD: Structured project using scikit-learn-contrib
8. Testing Approaches
8.1. Implement Comprehensive Unit and Integration Tests
For custom estimators, write unit tests to verify individual methods and use sklearn.utils.estimator_checks.check_estimator to ensure API compliance. For pipelines, write integration tests that cover the entire workflow.
❌ BAD: No tests or only manual verification
✅ GOOD: Automated testing with check_estimator
import pytest
from sklearn.utils.estimator_checks import check_estimator
from sklearn.base import BaseEstimator, TransformerMixin
import numpy as np
class MyCustomTransformer(BaseEstimator, TransformerMixin):
def __init__(self, offset: float = 0.0):
self.offset = offset
def fit(self, X, y=None):
self._validate_data(X)
return self
def transform(self, X):
self._validate_data(X, reset=False)
return X + self.offset
@pytest.mark.parametrize(
"estimator", [MyCustomTransformer()]
)
def test_all_estimators(estimator):
return check_estimator(estimator)
def test_my_custom_transformer_offset():
X = np.array([[1, 2], [3, 4]])
transformer = MyCustomTransformer(offset=10)
transformer.fit(X)
transformed_X = transformer.transform(X)
assert np.array_equal(transformed_X, np.array([[, ], [, ]]))