Skip to content

Cross-validation

Chapter 10 11 code blocks 2851 words

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

The scene: 42 and 7

Your student built a model and got accuracy = 0.81. You say: "change random_state to 7 and try again."

They change it. 0.76.

The student is puzzled: "I didn't change the model. Why is the number different?"

Because what they measured was not the model, it was the split. This chapter fixes that.

Column names

The data is synthetic Tashkent rental listings, 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. The target ijaraga_berildi_30k means rented within 30 days.

Let me show you the problem

python35 lines
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score

df = pd.read_csv(DATA / "uy_ijara.csv")
num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel", "qurilgan_yil"]
X, y = df[num + ["tuman"]], df["ijaraga_berildi_30k"]

def build_pipe():
    return Pipeline([
        ("prep", ColumnTransformer([
            ("num", Pipeline([("impute", SimpleImputer(strategy="median")),
                              ("scale", StandardScaler())]), num),
            ("cat", OneHotEncoder(handle_unknown="ignore"), ["tuman"]),
        ])),
        ("model", LogisticRegression(max_iter=2000)),
    ])

results = []
for rs in [0, 1, 7, 13, 42, 99, 123, 2026]:
    X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=rs, stratify=y)
    a = accuracy_score(y_te, build_pipe().fit(X_tr, y_tr).predict(X_te))
    results.append(a)
    print(f"  random_state={rs:<5} accuracy = {a:.4f}")

print()
print(f"Lowest : {min(results):.4f}")
print(f"Highest: {max(results):.4f}")
print(f"Spread : {max(results) - min(results):.4f}")
Output
  random_state=0     accuracy = 0.6944
  random_state=1     accuracy = 0.7361
  random_state=7     accuracy = 0.7083
  random_state=13    accuracy = 0.7250
  random_state=42    accuracy = 0.7778
  random_state=99    accuracy = 0.7056
  random_state=123   accuracy = 0.7056
  random_state=2026  accuracy = 0.7139

Lowest : 0.6944
Highest: 0.7778
Spread : 0.0833

One model. One dataset. A whole range of results.

If you pick random_state=42 and write "my model gets 0.78" in a report, that isn't a lie, but it isn't complete. Another split gives another number.

The fix: test every row exactly once

The idea of cross-validation is simple. Split the data into k parts. Then train k times: each time one part is the test, the rest is train.

In the end every row is tested exactly once.

python14 lines
diagramma(
    {
        "fold 1:  TEST | train | train | train | train": (0, 0, 7, 0.9, "xizmat"),
        "fold 2:  train | TEST | train | train | train": (0, 1.2, 7, 0.9, "qayta"),
        "fold 3:  train | train | TEST | train | train": (0, 2.4, 7, 0.9, "qayta"),
        "fold 4:  train | train | train | TEST | train": (0, 3.6, 7, 0.9, "qayta"),
        "fold 5:  train | train | train | train | TEST": (0, 4.8, 7, 0.9, "qayta"),
        "5 scores -> mean ± spread": (1.5, 6.2, 4, 0.9, "saqlash"),
    },
    [("fold 1:  TEST | train | train | train | train", "5 scores -> mean ± spread"),
     ("fold 5:  train | train | train | train | TEST", "5 scores -> mean ± spread")],
    nom="cv.png",
    izoh="5-fold cross-validation: the model is trained 5 times, and every row is tested once.",
)

5-fold cross-validation: the model is trained 5 times, and every row is tested once.

5-fold cross-validation: the model is trained 5 times, and every row is tested once.

python9 lines
from sklearn.model_selection import cross_val_score

scores = cross_val_score(build_pipe(), X, y, cv=5, scoring="accuracy")
print("Each fold:", scores.round(4).tolist())
print()
print(f"Mean   : {scores.mean():.4f}")
print(f"Spread : ±{scores.std():.4f}")
print()
print(f"In a report you write: {scores.mean():.3f} ± {scores.std():.3f}")
Output
Each fold: [0.7292, 0.7042, 0.7, 0.7042, 0.7458]

Mean   : 0.7167
Spread : ±0.0179

In a report you write: 0.717 ± 0.018

That is the right answer: not one number, but a number and its spread.

Always two numbers in a report

accuracy = 0.78 is incomplete. accuracy = 0.781 ± 0.019 (5-fold CV) is complete.

The second number matters as much as the first. It answers "how much can I trust this number".

StratifiedKFold: the default for classification

For classification, cross_val_score automatically uses StratifiedKFold, so the class ratio is kept in every fold. Let's show it.

python11 lines
from sklearn.model_selection import KFold, StratifiedKFold

print("Share of 'yes' in each fold:")
print(f"{'fold':>5} {'KFold':>9} {'StratifiedKFold':>17}")
print("-" * 34)
kf = list(KFold(n_splits=5, shuffle=True, random_state=42).split(X, y))
sk = list(StratifiedKFold(n_splits=5, shuffle=True, random_state=42).split(X, y))
for i in range(5):
    print(f"{i+1:>5} {y.iloc[kf[i][1]].mean():>8.1%} {y.iloc[sk[i][1]].mean():>16.1%}")
print()
print(f"True ratio: {y.mean():.1%}")
Output
Share of 'yes' in each fold:
 fold     KFold   StratifiedKFold
----------------------------------
    1    49.2%            50.4%
    2    53.3%            50.4%
    3    47.9%            50.8%
    4    53.8%            50.8%
    5    49.2%            50.8%

True ratio: 50.7%

Our data is large and balanced, so the difference is small. But on imbalanced or small data, KFold can leave not a single positive example in some fold, and recall can't be computed for that fold.

Pipeline inside CV: why it's mandatory

This is the most important practical point. CV has to redo the preprocessing in every fold. Otherwise leakage comes back.

python14 lines
from sklearn.preprocessing import StandardScaler as SS

# WRONG: preprocess all the data first, then CV
imp = SimpleImputer(strategy="median").fit(X[num])
scaler = SS().fit(imp.transform(X[num]))
X_before = pd.DataFrame(scaler.transform(imp.transform(X[num])), columns=num, index=X.index)
bad = cross_val_score(LogisticRegression(max_iter=2000), X_before, y, cv=5)

# RIGHT: preprocessing inside the Pipeline, redone in every fold
good = cross_val_score(build_pipe(), X, y, cv=5)

print(f"WRONG (preprocess first, then CV): {bad.mean():.4f} ±{bad.std():.4f}")
print(f"RIGHT (Pipeline inside CV)       : {good.mean():.4f} ±{good.std():.4f}")
print(f"Difference                       : {bad.mean() - good.mean():+.4f}")
Output
WRONG (preprocess first, then CV): 0.7108 ±0.0155
RIGHT (Pipeline inside CV)       : 0.7167 ±0.0179
Difference                       : -0.0058

The difference is small again, because StandardScaler "sees" very little. But with SelectKBest or target encoding, the difference is large and you'll be fooled.

Give CV a Pipeline, not a model object

cross_val_score(model, X_cleaned, y) is leakage. cross_val_score(pipeline, X_raw, y) is correct.

To remember the difference: CV gets raw data. The cleaning happens inside it.

One number is not enough: cross_validate

cross_val_score gives you one metric. In practice you need several.

python14 lines
from sklearn.model_selection import cross_validate

result = cross_validate(build_pipe(), X, y, cv=5,
                        scoring=["accuracy", "precision", "recall", "f1", "roc_auc"],
                        return_train_score=True)
rows = []
for m in ["accuracy", "precision", "recall", "f1", "roc_auc"]:
    te = result[f"test_{m}"]
    tr = result[f"train_{m}"]
    rows.append({"Metric": m,
                 "Test mean": round(te.mean(), 4), "Test ±": round(te.std(), 4),
                 "Train mean": round(tr.mean(), 4),
                 "Train - Test": round(tr.mean() - te.mean(), 4)})
show(pd.DataFrame(rows))
MetricTest meanTest ±Train meanTrain - Test
accuracy0.7170.0180.717-0
precision0.7120.0280.709-0.003
recall0.7450.0170.7470.002
f10.7270.0120.7280
roc_auc0.7830.0220.7930.01

The last column is the measure of overfitting. If the gap between train and test is large, the model memorised.

Ours is small, so the model didn't memorise; it really generalised. (Bias-variance comes in Chapter 12.)

Comparing two models: where people go wrong most

Now the most important practical question: how do we compare two models?

python27 lines
from sklearn.ensemble import RandomForestClassifier

def with_pipe(model):
    return Pipeline([
        ("prep", ColumnTransformer([
            ("num", Pipeline([("impute", SimpleImputer(strategy="median")),
                              ("scale", StandardScaler())]), num),
            ("cat", OneHotEncoder(handle_unknown="ignore"), ["tuman"]),
        ])),
        ("model", model),
    ])

lr = cross_val_score(with_pipe(LogisticRegression(max_iter=2000)), X, y, cv=5)
rf = cross_val_score(with_pipe(RandomForestClassifier(n_estimators=200, random_state=42)), X, y, cv=5)

print("Logistic Regression:", lr.round(4).tolist())
print(f"  mean {lr.mean():.4f} ±{lr.std():.4f}")
print()
print("Random Forest      :", rf.round(4).tolist())
print(f"  mean {rf.mean():.4f} ±{rf.std():.4f}")
print()
print(f"Gap between means  : {abs(rf.mean() - lr.mean()):.4f}")
print(f"Largest spread     : ±{max(lr.std(), rf.std()):.4f}")
print()
leader = "Random Forest" if rf.mean() > lr.mean() else "Logistic Regression"
confidence = "gap is LARGER than the spread" if abs(rf.mean()-lr.mean()) > max(lr.std(), rf.std()) else "gap is WITHIN the spread"
print(f"Leader: {leader}  |  {confidence}")
Output
Logistic Regression: [0.7292, 0.7042, 0.7, 0.7042, 0.7458]
  mean 0.7167 ±0.0179

Random Forest      : [0.7042, 0.6917, 0.6792, 0.6875, 0.725]
  mean 0.6975 ±0.0159

Gap between means  : 0.0192
Largest spread     : ±0.0179

Leader: Logistic Regression  |  gap is LARGER than the spread

The rule for reaching a verdict:

python5 lines
show(pd.DataFrame([
    {"Case": "Gap > 2 x spread", "Conclusion": "The gap is reliable: take the better one"},
    {"Case": "Gap ~ spread", "Conclusion": "Doubtful: compare the fold ranges"},
    {"Case": "Gap < spread", "Conclusion": "There is NO difference: take the simpler/faster model"},
]))
CaseConclusion
Gap > 2 x spreadThe gap is reliable: take the better one
Gap ~ spreadDoubtful: compare the fold ranges
Gap < spreadThere is NO difference: take the simpler/faster model

The last row matters most. If the gap is smaller than the spread, you pick the simpler model: it's faster, understandable and breaks less.

Look at the fold ranges too, not just the means

In Chapter 42 I made this mistake on titanic. The gap between the means was 0.0303 and the spread ±0.0248, so the gap looked bigger. But when I put the folds side by side, the ranges overlapped: the worse model's best fold was higher than the better model's worst fold.

So: if min(a) > max(b), it's reliable. Otherwise be careful.

How many folds?

python8 lines
print(f"{'cv':>4} {'mean':>10} {'spread':>11} {'time (rel.)':>14}")
print("-" * 42)
import time
for k in [3, 5, 10]:
    t0 = time.perf_counter()
    b = cross_val_score(build_pipe(), X, y, cv=k)
    dt = time.perf_counter() - t0
    print(f"{k:>4} {b.mean():>10.4f} {b.std():>10.4f} {dt:>13.2f}s")
Output
  cv       mean      spread    time (rel.)
------------------------------------------
   3     0.7175     0.0054          0.02s
   5     0.7167     0.0179          0.04s
  10     0.7175     0.0478          0.07s

The practical choice:

  • cv=5: the default. Enough in most cases.
  • cv=10: small data (< 1000 rows), or when you need a more precise estimate.
  • cv=3: when the data is very large or the model is slow.

Data with time: TimeSeriesSplit

Ordinary CV shuffles the folds, so the model learns from the future to predict the past. With time-ordered data, that is forbidden.

python7 lines
from sklearn.model_selection import TimeSeriesSplit

tss = TimeSeriesSplit(n_splits=4)
fake = np.arange(20)
print("TimeSeriesSplit: train always comes BEFORE test:")
for i, (tr_i, te_i) in enumerate(tss.split(fake), 1):
    print(f"  fold {i}: train={tr_i.min()}..{tr_i.max()}   test={te_i.min()}..{te_i.max()}")
Output
TimeSeriesSplit: train always comes BEFORE test:
  fold 1: train=0..3   test=4..7
  fold 2: train=0..7   test=8..11
  fold 3: train=0..11   test=12..15
  fold 4: train=0..15   test=16..19

Notice that the train set keeps growing and the test always comes after it. That's how real life works: you train on today's data and predict tomorrow.

What we learned today

python10 lines
show(pd.DataFrame([
    {"Concept": "One split isn't enough", "In one sentence": "Change random_state and the number changes"},
    {"Concept": "cross_val_score", "In one sentence": "Trains k times; every row is tested once"},
    {"Concept": "Mean ± spread", "In one sentence": "Two numbers in a report, not one"},
    {"Concept": "StratifiedKFold", "In one sentence": "Automatic for classification; keeps the class ratio"},
    {"Concept": "Pipeline inside CV", "In one sentence": "CV gets RAW data, not cleaned data"},
    {"Concept": "cross_validate", "In one sentence": "Several metrics + the train/test gap (overfitting)"},
    {"Concept": "The comparison rule", "In one sentence": "If the gap is smaller than the spread, there is no gap"},
    {"Concept": "TimeSeriesSplit", "In one sentence": "Don't shuffle when there is time"},
]))
ConceptIn one sentence
One split isn't enoughChange random_state and the number changes
cross_val_scoreTrains k times; every row is tested once
Mean ± spreadTwo numbers in a report, not one
StratifiedKFoldAutomatic for classification; keeps the class ratio
Pipeline inside CVCV gets RAW data, not cleaned data
cross_validateSeveral metrics + the train/test gap (overfitting)
The comparison ruleIf the gap is smaller than the spread, there is no gap
TimeSeriesSplitDon't shuffle when there is time

Next chapter, the most important question: there's a number, now what do I do?

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