← All cheatsheets
Data Scientist · #019 · August 11, 2026 · 2 min read

Why does your model ace the backtest and fail in production?

A 0.94 backtest that becomes 0.51 live is almost always a split problem. The three sets and what each is for, the order of operations that prevents leakage, and the four split strategies that cover nearly every dataset.

Get the free PDF

One page, print-ready, free to share. No signup needed.

Download the PDF

The backtest said 0.94. Production says 0.51. Nothing about the model changed, so the model was never the problem:

df = df.sort_values("date")
X_tr, X_te = train_test_split(X, shuffle=True)
# backtest 0.94, live 0.51 -- trained on the future

Shuffling a time series hands the model rows from the future of its own test set. The score was fiction from the start.

Three sets, not two

setsharewhat it is for
train~60%the model learns here
validation~20%you tune hyperparameters here
test~20%you look once, at the end

The third set exists because of a subtle failure: every time you change a hyperparameter after reading the test score, you are fitting yourself to the test set. After ten rounds the test number measures your persistence, not the model. Tune on validation. Open test once.

Split first. Always.

The order of operations is the part that silently goes wrong:

from sklearn.pipeline import Pipeline

# 1. split BEFORE anything touches the data
X_tr, X_te, y_tr, y_te = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

# 2. the scaler learns from train rows only
pipe = Pipeline([("scale", StandardScaler()),
                 ("model", LogisticRegression())])
pipe.fit(X_tr, y_tr)

Fit a scaler on the full dataset and the test mean leaks into training. The symptom is a score that improves while the model worsens, which is the worst possible direction to be wrong in. The Pipeline makes the leak structurally impossible: transforms only ever see what fit received.

Pick the split that matches the data

your datasplitwhy
independent rowsrandomorder carries no information
imbalanced classesstratifiedkeeps the class ratio in every set
repeated subjectsgroupall rows of one user stay on one side
anything with a datetime-basedtrain on the past, test on the future

The group split is the one most people meet too late. If the same customer appears in train and test, the model recognises that customer instead of generalising to new ones, and the validation score inflates for a reason that vanishes with real users.

The set you peeked at

A test set you have read ten times is not a test set. If the tuning loop already burned it, the honest options are to say so, or to collect a fresh holdout. There is no third option where the number becomes trustworthy again.

The takeaway

Three sets, split before any preprocessing, strategy matched to the data, and a test set that gets opened exactly once. The print-ready PDF above fits all of it on one page.

Frequently asked questions

What is the difference between a validation set and a test set?
The validation set is the one you are allowed to look at repeatedly: you tune hyperparameters against it. The test set is opened once, at the end, to estimate real performance. The moment you tune against the test set it stops being a test set and becomes a second validation set with a misleading name.
Should I split the data before or after scaling?
Before, always. If a scaler or encoder is fit on the full dataset, statistics from the test rows leak into training. The reported score goes up while the real model gets worse. Split first, then fit every transform on the training set only. An sklearn Pipeline makes this ordering automatic.
When should I use a time-based split instead of a random one?
Whenever rows have a date and the model will predict the future: sales, churn, prices, sensor readings. A shuffled split lets the model train on data from after its own test period, which is why backtests on shuffled time series look spectacular and fail immediately in production.
What split ratio should I use for train, validation and test?
60/20/20 is a sane default for datasets in the thousands of rows. With very large datasets the validation and test shares can shrink, because ten thousand held-out rows estimate performance well no matter what fraction they represent. The ratio matters far less than splitting before any preprocessing.

Get the free PDF

One page, print-ready, free to share. No signup needed.

Download the PDF

More cheatsheets