Train / validation / test and leakage
The scene: 0.95 in the notebook, 0.61 on the server
Picture this. You worked for two weeks. The model scores 0.95 in your notebook. You show the manager, who is delighted. You ship the model to the server.
A month later the report arrives: real-world accuracy is 0.61.
What happened? The model did not get worse. It was never 0.95. You tested it after showing it the exam questions.
This chapter teaches you how to prevent that. It is step 5 of the protocol from Chapter 1.
Column names
The data is the same Tashkent rental listings used throughout the book, and its column names are Uzbek. tuman district, xonalar rooms, maydon_m2 area in m², qavat floor, qavatlar_soni floors in the building, metro_km distance to the metro, mebel furnished (0/1), qurilgan_yil year built, narx_matn price as text, narx_usd price in USD, agent_komissiyasi agent fee, elon_id listing id. The target ijaraga_berildi_30k means rented within 30 days.
Why split at all
A model can do two different things:
- Memorise: remember the rows it has seen
- Generalise: find a rule and apply it to a new row
You need the second one. But if you test the model on data it has already seen, you cannot tell the two apart.
So the data gets split: the model learns on one part and is tested on another.
import pandas as pd from sklearn.model_selection import train_test_split df = pd.read_csv(DATA / "uy_ijara.csv") X = df.drop(columns=["ijaraga_berildi_30k", "narx_usd", "narx_matn", "elon_id", "agent_komissiyasi"]) y = df["ijaraga_berildi_30k"] X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y) print(f"total : {len(X)} rows") print(f"train : {len(X_tr)} rows ({len(X_tr)/len(X):.0%}) -> the model learns HERE") print(f"test : {len(X_te)} rows ({len(X_te)/len(X):.0%}) -> the model NEVER SEES this")
total : 1200 rows train : 840 rows (70%) -> the model learns HERE test : 360 rows (30%) -> the model NEVER SEES this
stratify: forget it and the numbers lie
stratify=y keeps the class ratio the same in train and test. Without it, chance gets a vote.
import numpy as np print(f"{'random_state':>13} {'no stratify':>14} {'stratify':>14}") print("-" * 45) for rs in [0, 1, 42, 7, 99]: _, _, _, y1 = train_test_split(X, y, test_size=0.3, random_state=rs) _, _, _, y2 = train_test_split(X, y, test_size=0.3, random_state=rs, stratify=y) print(f"{rs:>13} {y1.mean():>13.1%} {y2.mean():>14.1%}") print() print(f"True ratio: {y.mean():.1%}")
random_state no stratify stratify
---------------------------------------------
0 49.2% 50.6%
1 52.5% 50.6%
42 48.9% 50.6%
7 50.0% 50.6%
99 48.3% 50.6%
True ratio: 50.7%
With stratify the ratio is the same every time. Without it the ratio jumps around, and your model's score starts to depend on which split you happened to get.
With imbalanced classes this is a disaster. If you have 2% fraud and the test set happens to get 0% fraud, you cannot even measure recall.
In classification, stratify=y is not optional
Write it down: train_test_split(X, y, test_size=0.3, random_state=42, stratify=y).
The only time you leave stratify out is regression, where the target is continuous.
Leakage: the most expensive mistake
Now the most important part. Leakage means test data seeping into the learning process.
What makes it dangerous: it raises no error. The code runs and the number comes out looking good.
Let me show it to you by measuring it. We do the same job in two ways.
from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression from sklearn.impute import SimpleImputer from sklearn.metrics import accuracy_score num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel", "qurilgan_yil"] # ===== WRONG: the scaler learns from ALL the data ===== imp_bad = SimpleImputer(strategy="median").fit(X[num]) # <-- all of X scale_bad = StandardScaler().fit(imp_bad.transform(X[num])) # <-- all of X Xs = scale_bad.transform(imp_bad.transform(X[num])) Xs = pd.DataFrame(Xs, columns=num, index=X.index) m_bad = LogisticRegression(max_iter=2000).fit(Xs.loc[X_tr.index], y_tr) acc_bad = accuracy_score(y_te, m_bad.predict(Xs.loc[X_te.index])) # ===== RIGHT: the scaler learns from train ONLY ===== imp = SimpleImputer(strategy="median").fit(X_tr[num]) scaler = StandardScaler().fit(imp.transform(X_tr[num])) m_good = LogisticRegression(max_iter=2000).fit(scaler.transform(imp.transform(X_tr[num])), y_tr) acc_good = accuracy_score(y_te, m_good.predict(scaler.transform(imp.transform(X_te[num])))) print(f"WRONG (scaler on all the data) : {acc_bad:.4f}") print(f"RIGHT (scaler on train only) : {acc_good:.4f}") print(f"Difference : {acc_bad - acc_good:+.4f}")
WRONG (scaler on all the data) : 0.7694 RIGHT (scaler on train only) : 0.7694 Difference : +0.0000
Look at the difference. It is tiny.
And that is exactly the most dangerous thing about leakage. In this example StandardScaler only "saw" a mean and a standard deviation, so the damage is barely noticeable. You might conclude "so it doesn't matter".
Don't. The damage is small here because the preprocessing is simple. But:
show(pd.DataFrame([ {"Step": "StandardScaler", "What it 'sees'": "Mean and standard deviation", "Damage": "Small, barely visible in this example"}, {"Step": "SimpleImputer(median)", "What it 'sees'": "The median of the test rows", "Damage": "Small to medium"}, {"Step": "Feature selection (on all data)", "What it 'sees'": "Which columns relate to the target", "Damage": "LARGE, the most common disaster"}, {"Step": "Target encoding (on all data)", "What it 'sees'": "The target mean of each category", "Damage": "FATAL: gives 0.99, collapses on the server"}, {"Step": "SMOTE / oversampling (before the split)", "What it 'sees'": "Copies of test rows land in train", "Damage": "FATAL"}, ]))
| Step | What it 'sees' | Damage |
|---|---|---|
| StandardScaler | Mean and standard deviation | Small, barely visible in this example |
| SimpleImputer(median) | The median of the test rows | Small to medium |
| Feature selection (on all data) | Which columns relate to the target | LARGE, the most common disaster |
| Target encoding (on all data) | The target mean of each category | FATAL: gives 0.99, collapses on the server |
| SMOTE / oversampling (before the split) | Copies of test rows land in train | FATAL |
The last three give 0.99 in a notebook and collapse on the server. That is why the rule has to be without exceptions: every fit() happens on train only.
The fix: Pipeline enforces the rule by structure
A rule is hard to remember. So we make it impossible to forget.
from sklearn.pipeline import Pipeline from sklearn.compose import ColumnTransformer from sklearn.preprocessing import OneHotEncoder pipe = Pipeline([ ("prep", ColumnTransformer([ ("num", Pipeline([("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler())]), num), ("cat", OneHotEncoder(handle_unknown="ignore"), ["tuman"]), ])), ("model", LogisticRegression(max_iter=2000)), ]) pipe.fit(X_tr, y_tr) print(f"Pipeline result: {accuracy_score(y_te, pipe.predict(X_te)):.4f}") print() print("Why this is safe:") print(" pipe.fit(X_tr, y_tr) -> every step INSIDE sees ONLY X_tr") print(" pipe.predict(X_te) -> transform only, NO refitting")
Pipeline result: 0.7778 Why this is safe: pipe.fit(X_tr, y_tr) -> every step INSIDE sees ONLY X_tr pipe.predict(X_te) -> transform only, NO refitting
Now you cannot get it wrong: when fit is called, the whole chain only sees the data it was given.
One rule for the whole book
If you call fit, fit_transform or fit_resample before the split, that is leakage.
Remember that one sentence and you avoid the most expensive mistake in ML.
The third set: validation
So far we used two parts. In practice you need three.
Why? Because you don't test a model once. You test it dozens of times: another model, another feature, another parameter. If every decision is made by looking at test, you yourself have leaked the test data into the model.
show(pd.DataFrame([ {"Set": "train", "Share": "~60%", "What for": "The model learns here", "How many times you use it": "Unlimited"}, {"Set": "validation", "Share": "~20%", "What for": "CHOOSING models and parameters", "How many times you use it": "Unlimited, decisions are made here"}, {"Set": "test", "Share": "~20%", "What for": "The FINAL score", "How many times you use it": "ONCE, at the very end"}, ]))
| Set | Share | What for | How many times you use it |
|---|---|---|---|
| train | ~60% | The model learns here | Unlimited |
| validation | ~20% | CHOOSING models and parameters | Unlimited, decisions are made here |
| test | ~20% | The FINAL score | ONCE, at the very end |
# Split in two stages: first set the test aside, then split what is left X_rest, X_test, y_rest, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y) X_train, X_val, y_train, y_val = train_test_split( X_rest, y_rest, test_size=0.25, random_state=42, stratify=y_rest) print(f"train : {len(X_train):4d} rows ({len(X_train)/len(X):.0%})") print(f"validation : {len(X_val):4d} rows ({len(X_val)/len(X):.0%})") print(f"test : {len(X_test):4d} rows ({len(X_test)/len(X):.0%}) <-- IN THE SAFE")
train : 720 rows (60%) validation : 240 rows (20%) test : 240 rows (20%) <-- IN THE SAFE
In practice, cross-validation is often used instead of a validation set: it saves data and gives a steadier score. Chapter 10 covers it.
How many times have you looked at the test set?
If you have ever thought "test came out at 0.79, let's try another model", you used test as validation. Its score is now optimistic.
Kaggle has a name for this: leaderboard overfitting. People change their model hundreds of times while watching the public leaderboard, then collapse on the private one.
Data with time: no shuffling
If your data has time in it (sales, prices, logs), using train_test_split at random means learning from the future to predict the past.
days = pd.date_range("2026-01-01", periods=10, freq="D") print("RANDOM split (WRONG):") print(" train: days 1, 3, 4, 7, 9, 10") print(" test : days 2, 5, 6, 8 <-- train has seen the future") print() print("Split BY TIME (RIGHT):") print(" train: days 1-7") print(" test : days 8-10 <-- learns only from the past") print() print("In sklearn: TimeSeriesSplit (Chapter 10)")
RANDOM split (WRONG): train: days 1, 3, 4, 7, 9, 10 test : days 2, 5, 6, 8 <-- train has seen the future Split BY TIME (RIGHT): train: days 1-7 test : days 8-10 <-- learns only from the past In sklearn: TimeSeriesSplit (Chapter 10)
What we learned today
show(pd.DataFrame([ {"Rule": "Split first", "Detail": "BEFORE cleaning, before charts, before everything"}, {"Rule": "stratify=y", "Detail": "Mandatory in classification"}, {"Rule": "fit on train only", "Detail": "Scaler, imputer, encoder, selector: all of them"}, {"Rule": "Use a Pipeline", "Detail": "Don't rely on memory, enforce the rule by structure"}, {"Rule": "Test once", "Detail": "Decisions are made on validation or in CV"}, {"Rule": "No shuffling when there is time", "Detail": "TimeSeriesSplit"}, ]))
| Rule | Detail |
|---|---|
| Split first | BEFORE cleaning, before charts, before everything |
| stratify=y | Mandatory in classification |
| fit on train only | Scaler, imputer, encoder, selector: all of them |
| Use a Pipeline | Don't rely on memory, enforce the rule by structure |
| Test once | Decisions are made on validation or in CV |
| No shuffling when there is time | TimeSeriesSplit |
Next chapter: we built a model, but how do we know its result is any good? For that we need a baseline number.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.