The most important subpackage, and where mistakes cost the most. Everything here exists to stop you fooling yourself about how well the model works.
The choice of splitter encodes an assumption about your data. Get it wrong and every number downstream is optimistic.
Splitters
Pick by what your data looks like, not by habit.
train_test_split
The one-liner. Pass stratify=y for imbalanced classes
KFold
Plain cross-validation
StratifiedKFoldcommon
Classification — preserves class ratios per fold. The default for classifiers
GroupKFoldbank
Rows share an entity (customer, account) that must not span folds
StratifiedGroupKFold
Both constraints at once
TimeSeriesSplitbank
Anything time-ordered. Trains on past, tests on future
ShuffleSplit, LeaveOneOut
Situational. LOO is slow
Running cross-validation
cross_val_score
One metric per fold
cross_validatecommon
Several metrics plus fit and score times
cross_val_predict
Out-of-fold predictions — build a confusion matrix over all data
Hyperparameter search
GridSearchCV
Small discrete space. Exhaustive
RandomizedSearchCVcommon
Large or continuous space. Usually better value than grid
HalvingGridSearchCV / HalvingRandomSearchCV
Successive halving, much faster. Needs the experimental import first
Diagnostics
learning_curvecommon
Score against training set size. THE tool for 'do I need more data'
validation_curve
Score against one hyperparameter's value
Worked examples 5
Runnable, with the arguments that change the outcome
called out. Output shapes are shown as comments where they help.
sklearn.model_selection
from sklearn.model_selection import TimeSeriesSplit, cross_validate
# Expanding window: fold 1 trains on the earliest slice and tests on the
# next, fold 2 trains on both and tests on the third, and so on.
cv = TimeSeriesSplit(n_splits=5, test_size=30_000)
scores = cross_validate(
pipeline, X, y, cv=cv,
scoring=["average_precision", "roc_auc", "f1_macro"],
return_train_score=True, # gap between train and test reveals overfit
n_jobs=-1,
)
print(scores["test_average_precision"])
The right splitter for your data shape
Four situations, four splitters. Choosing by habit rather than by data shape is the most expensive mistake in this subpackage.
from sklearn.model_selection import (train_test_split, StratifiedKFold,
GroupKFold, TimeSeriesSplit)
# 1. Plain holdout. stratify=y keeps class ratios in both halves -
# without it, a rare class can vanish from the test set entirely.
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.2, stratify=y, random_state=42)
# 2. Classification cross-validation
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
# 3. Rows share an entity. The SAME CUSTOMER must not appear in both
# train and test, or you are testing memorisation.
cv = GroupKFold(n_splits=5)
for tr, te in cv.split(X, y, groups=df["customer_id"]):
pass
# 4. Time-ordered. Expanding window: always trains on the past.
cv = TimeSeriesSplit(n_splits=5, test_size=30_000, gap=1_000)
for tr, te in cv.split(X):
print(f"train rows 0-{tr[-1]}, test rows {te[0]}-{te[-1]}")
# gap= leaves a buffer between train and test, which matters when your
# label takes time to mature (a default is only known 90 days later).
Answer 'do I need more data' empirically
This is the function that settles the 10-million-rows question. Train at increasing sample sizes and look at where the curve flattens.
from sklearn.model_selection import learning_curve
import numpy as np
sizes, train_scores, val_scores = learning_curve(
pipeline, X, y,
train_sizes=np.array([0.05, 0.1, 0.2, 0.4, 0.7, 1.0]),
cv=5, scoring="f1_macro", n_jobs=-1, random_state=42,
)
for n, tr, va in zip(sizes, train_scores.mean(1), val_scores.mean(1)):
print(f"{n:>8,} rows train {tr:.3f} val {va:.3f} gap {tr-va:.3f}")
# 2,500 rows train 0.981 val 0.742 gap 0.239 <- overfitting
# 5,000 rows train 0.954 val 0.798 gap 0.156
# 10,000 rows train 0.921 val 0.844 gap 0.077
# 20,000 rows train 0.903 val 0.869 gap 0.034
# 35,000 rows train 0.897 val 0.878 gap 0.019
# 50,000 rows train 0.894 val 0.881 gap 0.013 <- flat: stop here
#
# Reading it: a large gap means more data will help. A flat validation
# curve means it will not, and you need better features or a different
# model instead.
Search hyperparameters without wasting a weekend
RandomizedSearchCV beats GridSearchCV on anything but a tiny space, because most hyperparameters barely matter and grid search spends equal effort on all of them.
from sklearn.model_selection import RandomizedSearchCV
from scipy.stats import loguniform, randint
search = RandomizedSearchCV(
pipeline,
param_distributions={
# Double underscore addresses a step inside the Pipeline
"tfidf__ngram_range": [(1, 1), (1, 2), (1, 3)],
"tfidf__min_df": randint(2, 10),
"clf__C": loguniform(1e-2, 1e2), # sample on a log scale,
# because C spans orders of
# magnitude
},
n_iter=40, # 40 random draws, not every combination
cv=StratifiedKFold(5, shuffle=True, random_state=42),
scoring="f1_macro",
n_jobs=-1,
random_state=42,
refit=True, # refits the best on all data
return_train_score=True,
)
search.fit(X_train, y_train)
print(search.best_params_, round(search.best_score_, 4))
import pandas as pd
res = pd.DataFrame(search.cv_results_)
print(res.nlargest(5, "mean_test_score")[
["mean_test_score", "std_test_score", "mean_fit_time", "params"]])
# Look at std_test_score too. A high mean with a high std is a lucky
# fold, not a better model.
Cross-validate with several metrics at once
One fit, many numbers. Also surfaces the train/test gap, which single-metric scoring hides.
from sklearn.model_selection import cross_validate
scores = cross_validate(
pipeline, X, y,
cv=TimeSeriesSplit(n_splits=5),
scoring=["average_precision", "roc_auc", "f1_macro", "balanced_accuracy"],
return_train_score=True,
n_jobs=-1,
)
for k in sorted(scores):
if k.startswith(("test_", "train_")):
print(f"{k:32} {scores[k].mean():.4f} +/- {scores[k].std():.4f}")
# Note how much lower average_precision is than roc_auc on imbalanced
# data. That difference is the whole argument for using PR AUC.
Worth knowing
A random split on time-ordered data leaks the future. It is the single most common error in applied work.
learning_curve answers the '10 million rows' question empirically instead of by guessing.