Skip to content

Train / validation / test and leakage

Chapter 6 9 code blocks 2394 words

The code in this chapter uses these filesrun it on your own machine

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:

  1. Memorise: remember the rows it has seen
  2. 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.

python11 lines
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")
Output
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.

python10 lines
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%}")
Output
 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.

python24 lines
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}")
Output
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:

python12 lines
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"},
]))
StepWhat it 'sees'Damage
StandardScalerMean and standard deviationSmall, barely visible in this example
SimpleImputer(median)The median of the test rowsSmall to medium
Feature selection (on all data)Which columns relate to the targetLARGE, the most common disaster
Target encoding (on all data)The target mean of each categoryFATAL: gives 0.99, collapses on the server
SMOTE / oversampling (before the split)Copies of test rows land in trainFATAL

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.

python19 lines
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")
Output
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.

python8 lines
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"},
]))
SetShareWhat forHow many times you use it
train~60%The model learns hereUnlimited
validation~20%CHOOSING models and parametersUnlimited, decisions are made here
test~20%The FINAL scoreONCE, at the very end
python9 lines
# 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")
Output
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.

python10 lines
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)")
Output
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

python8 lines
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"},
]))
RuleDetail
Split firstBEFORE cleaning, before charts, before everything
stratify=yMandatory in classification
fit on train onlyScaler, imputer, encoder, selector: all of them
Use a PipelineDon't rely on memory, enforce the rule by structure
Test onceDecisions are made on validation or in CV
No shuffling when there is timeTimeSeriesSplit

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.

Write on Telegram Contents