Course contents36 lessons

Crash course / Modelling, honestly

Validation that matches deployment

Cross-validation is not one technique. Choosing the wrong variant is how models get shipped with an offline score that has no relationship to what happens next.

Lesson 28 of 36 · 3 min read ·Machine learning

Your validation scheme should simulate the situation the model will be in when it runs for real. If it does not, your offline metric is measuring something else, and the gap will surface as a "model degradation" incident that is actually a validation bug.

The question to ask#

When this model runs in production, what will it know, and what will it be asked to predict?

Then build a split that reproduces exactly that. Everything below follows from taking that question literally.

Random k-fold: the default, and when it is wrong#

Random k-fold assumes rows are independent and exchangeable. It is right for genuinely independent observations and wrong in three common situations.

Time matters. Randomly splitting a time series lets the model train on Thursday to predict Wednesday. In production it will only ever have the past.

Rows are grouped. Multiple rows per user, per device, per session. A random split puts the same user in train and test, so the model memorises the user.

Classes are rare. A random split may leave a fold with almost no positives. Use stratification.

Time series: forward chaining#

For anything with a time dimension, split by time and always train on the past.

python
from sklearn.model_selection import TimeSeriesSplit

splitter = TimeSeriesSplit(n_splits=5, test_size=24 * 7, gap=24)
for train_idx, test_idx in splitter.split(X):
    ...

Two details that matter more than the technique:

Include a gap matching your real prediction lag. If the model predicts 24 hours out, leave 24 hours between train and test — otherwise you validate on a horizon you will never have.

Retrain per fold exactly as you will retrain in production. Validating one model against five time slices, when production retrains weekly, measures the wrong thing.

Groups: never split a group#

python
from sklearn.model_selection import GroupKFold, StratifiedGroupKFold

for train_idx, test_idx in StratifiedGroupKFold(n_splits=5).split(X, y, groups=user_ids):
    ...

If the same user appears in both sides, your metric is inflated and you will not find out until production. The extreme case is recommendation systems, where user leakage can turn a mediocre model into an apparently excellent one.

Distribution shift: validate on the future you expect#

Sometimes the deployment population differs from the training population — a new market, a new segment, a new season. Validating on a random slice of the past overstates performance.

If you expect shift, build a validation set that resembles the target: hold out the most recent period, or a whole geography, or a whole customer segment. It will score worse, and it will be honest.

Nested cross-validation, and why the flat version lies#

If you tune hyperparameters using the same folds you report your score from, that score is optimistic — you have selected the configuration that happened to do best on those folds.

python
from sklearn.model_selection import GridSearchCV, cross_val_score, KFold

inner = KFold(5, shuffle=True, random_state=0)
outer = KFold(5, shuffle=True, random_state=1)

search = GridSearchCV(model, param_grid, cv=inner, scoring="roc_auc")
scores = cross_val_score(search, X, y, cv=outer, scoring="roc_auc")   # honest estimate

The practical shortcut, when nested CV is too expensive: a held-out test set touched exactly once, at the very end, after every decision is locked. If you look at it twice, it is a validation set and you need a new one.

Pipelines prevent preprocessing leakage#

Fitting a scaler or imputer on the full dataset before splitting leaks test-set statistics into training. It is subtle, universal, and completely avoidable — put every transformation inside the pipeline so it is refit per fold.

python
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression

pipeline = Pipeline([
    ("impute", SimpleImputer(strategy="median")),   # median from TRAIN fold only
    ("scale", StandardScaler()),
    ("model", LogisticRegression(max_iter=1000)),
])
cross_val_score(pipeline, X, y, cv=outer)

This is not stylistic. Fitting the imputer outside the pipeline is a real leak with a real effect on your reported score.

The metric needs an interval too#

A single cross-validation number without a spread is not a result. Report the mean and the standard deviation across folds — or better, the individual fold scores.

text
ROC AUC: 0.741 ± 0.028   (folds: 0.712, 0.735, 0.744, 0.757, 0.757)

If model A scores 0.741 ± 0.028 and model B scores 0.748 ± 0.031, they are indistinguishable. Choose the simpler, faster, more maintainable one. A very large amount of modelling effort goes into differences smaller than fold variance.

Backtesting for anything that acts over time#

For a model that drives a repeated decision, simulate the whole loop: at each historical point, train on data available then, predict, apply the decision rule, and accumulate the outcome. This catches things a static metric cannot — feedback loops, threshold drift, the fact that the retention offer changes the churn you were predicting.

Patterns from this lesson