Splitting data correctly
Train, validation and test roles, stratified, grouped and time-series splits, leakage through preprocessing, and nested cross-validation.
Three sets, three jobs
| Split | Used for | Touched how often |
|---|---|---|
| Training | Fitting parameters and preprocessing statistics | Every experiment |
| Validation / CV | Comparing models and tuning hyperparameters | Repeatedly, freely |
| Test | One final honest number before shipping | Once, at the end |
from sklearn.model_selection import train_test_split
# hold out the test set first and lock it away
X_dev, X_test, y_dev, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y)
# then split development data into train and validation inside every experiment
X_train, X_val, y_train, y_val = train_test_split(
X_dev, y_dev, test_size=0.25, random_state=42, stratify=y_dev)
print(X_train.shape, X_val.shape, X_test.shape)With little data, skip the fixed validation set and use cross-validation on the development split. What you must not do is tune against the test set — after twenty comparisons it is just another training set with extra steps.
Split by structure, not by row
from sklearn.model_selection import GroupKFold, StratifiedKFold, TimeSeriesSplit
# several rows per customer: no customer may appear on both sides
for tr, va in GroupKFold(n_splits=5).split(X, y, groups=df["customer_id"]):
...
# future-only training, for anything with a time signal
for tr, va in TimeSeriesSplit(n_splits=5, gap=1).split(X):
...
# keep the class ratio stable in every fold
StratifiedKFold(n_splits=5, shuffle=True, random_state=42)- Random row splits assume rows are independent. Session logs, patients, and repeated orders violate that assumption, and the model will look far better than it is.
gapinTimeSeriesSplitleaves a buffer between train and validation, which matters when the label is only known a few days after the event.- Split by entity or by time before any preprocessing that learns statistics.
- Check that the label rate in every fold is close to the overall rate; a wildly different fold means the split is measuring something else.
Nested cross-validation
If you tune hyperparameters on the same folds you use to report the score, the reported number is optimistic — the search picked the configuration that happened to fit those folds. Nested CV separates the two jobs: an inner loop tunes, an outer loop scores.
from sklearn.model_selection import GridSearchCV, cross_val_score
inner = GridSearchCV(model, {"clf__C": [0.1, 1, 10]}, cv=3, scoring="roc_auc")
outer_scores = cross_val_score(inner, X_dev, y_dev, cv=5, scoring="roc_auc")
print(outer_scores.mean(), outer_scores.std()) # the number you may quote⚠️
Any statistic computed before the split — imputation median, scaler mean, target encoding, feature selection, SMOTE — has already seen the validation rows. Put every one of those steps inside the pipeline so it is refitted within each fold.
FAQ
How large should the test set be?
Enough to make the metric precise for your use: a few hundred positives for classification, or about 20% of rows for a typical tabular dataset. If you cannot spare 20%, you probably need more data before more modelling.
Can I reuse the test set after changing the model?
Not honestly. Every time you look at it and adjust, it leaks into your decisions. Either keep a locked-away set for the final report or accept that the number is now a validation score.
Related
Data collection, cleaning and feature engineering Hyperparameter tuning and experiment tracking
Last refreshed 2026-09-18.