MODELCATALOG

Splitting, cross-validation, hyperparameter search.

sklearn.model_selection

model_selection

Splitting, cross-validation, hyperparameter search.

1 Get data Something to practise on
2 Split & tune An honest test set
3 Prepare Into numbers a model accepts
4 Compose One object, no leakage
5 Model The algorithm itself
6 Unsupervised No labels available
7 Measure Is it any good, and why
8 Utilities Glue and extension

What it is for

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
StratifiedKFold common Classification — preserves class ratios per fold. The default for classifiers
GroupKFold bank Rows share an entity (customer, account) that must not span folds
StratifiedGroupKFold Both constraints at once
TimeSeriesSplit bank 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_validate common 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
RandomizedSearchCV common Large or continuous space. Usually better value than grid
HalvingGridSearchCV / HalvingRandomSearchCV Successive halving, much faster. Needs the experimental import first

Diagnostics

learning_curve common 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

  1. A random split on time-ordered data leaks the future. It is the single most common error in applied work.

  2. learning_curve answers the '10 million rows' question empirically instead of by guessing.