House Price Tiers an ML project, end to end

Lesson 3 of 6

Honest validation

Hold-out, overfitting curves, repeated K-fold. The score you can trust.

Download the notebook

Left: what & whyRight: the code and its real output

03 · Validation: an honest score we can compute ourselves

In 02 the model scored ~1.00 on training data but much less on the leaderboard. Here we:

  1. hide part of our own training data and grade the model on it (hold-out validation),
  2. watch overfitting happen in a picture,
  3. see how noisy one split is on a small dataset,
  4. upgrade to repeated K-fold cross-validation (CV), the score we'll trust for the rest of the project.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split, StratifiedKFold
from utils import load_data, basic_prep, cv_score, TARGET, TIER_TO_NUM, SEED
from score import score

train, test = load_data()
tr, te = basic_prep(train, test)
FEATURES = [c for c in tr.columns if c not in ("id", TARGET)]
X, y = tr[FEATURES], tr[TARGET].map(TIER_TO_NUM)

def rf():
    return RandomForestClassifier(n_estimators=300, random_state=SEED, n_jobs=-1)

1. Hold-out validation

Split train into 80% "study material" and 20% "practice exam". The model never sees the practice exam while learning.

stratify=y keeps the same tier mix (⅓ each) in both parts.

X_tr, X_va, y_tr, y_va = train_test_split(X, y, test_size=0.2, random_state=SEED, stratify=y)
print(X_tr.shape, X_va.shape)

m = rf().fit(X_tr, y_tr)
print(f"train accuracy:      {accuracy_score(y_tr, m.predict(X_tr)):.4f}")
print(f"validation accuracy: {accuracy_score(y_va, m.predict(X_va)):.4f}")
print("leaderboard (from 02):", score("submissions/02_random_forest.csv"))
(326, 12) (82, 12)
train accuracy:      1.0000
validation accuracy: 0.6707
leaderboard (from 02): {'public': 0.629}

The validation score is far below the training score and much closer to the leaderboard. Now we have a leaderboard-like score without submitting anything.

2. Overfitting, in a picture

A single decision tree asks yes/no questions ("area > 6000?"). max_depth = how many questions in a row it may ask. Shallow tree = too simple (underfitting). Very deep tree = memorises every row (overfitting).

depths = range(1, 16)
train_scores, val_scores = [], []
for d in depths:
    tree = DecisionTreeClassifier(max_depth=d, random_state=SEED).fit(X_tr, y_tr)
    train_scores.append(accuracy_score(y_tr, tree.predict(X_tr)))
    val_scores.append(accuracy_score(y_va, tree.predict(X_va)))

plt.figure(figsize=(8, 4))
plt.plot(depths, train_scores, label="train")
plt.plot(depths, val_scores, label="validation")
plt.xlabel("max_depth"); plt.ylabel("accuracy"); plt.legend(); plt.title("Train vs validation accuracy");
plot
  • Left side: both scores are low → the tree is too simple.
  • Right side: train shoots up towards 1.0 while validation stays flat or drops → it is memorising noise.

The gap between train and validation is how you spot overfitting.

3. How noisy is one split? (very, on small data)

Same model, 10 different random 80/20 splits:

split_scores = []
for seed in range(10):
    a, b, c, d = train_test_split(X, y, test_size=0.2, random_state=seed, stratify=y)
    split_scores.append(accuracy_score(d, rf().fit(a, c).predict(b)))
    print(f"split {seed}: {split_scores[-1]:.4f}")
print(f"range: {min(split_scores):.3f} – {max(split_scores):.3f}")
split 0: 0.6829
split 1: 0.7439
split 2: 0.7317
split 3: 0.7683
split 4: 0.7805
split 5: 0.6585
split 6: 0.7073
split 7: 0.7317
split 8: 0.7439
split 9: 0.7195
range: 0.659 – 0.780

The validation part has only ~80 houses, so each house is worth more than 1% of the score. Just which houses land in the 20% moves the result by several percent. If a new idea "improves" the score by 2%, one split can't tell us if it's real or luck.

4. K-fold cross-validation

Cut the training data into 5 equal folds. Train on 4, score on the 5th, and repeat so every fold gets a turn as the exam:

fold 0: [VAL][ tr ][ tr ][ tr ][ tr ]
fold 1: [ tr ][VAL][ tr ][ tr ][ tr ]
...
fold 4: [ tr ][ tr ][ tr ][ tr ][VAL]

CV score = the average of the 5. Every row is used for validation exactly once.

skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=SEED)
scores = []
for fold, (tr_idx, va_idx) in enumerate(skf.split(X, y)):
    m = rf().fit(X.iloc[tr_idx], y.iloc[tr_idx])
    scores.append(accuracy_score(y.iloc[va_idx], m.predict(X.iloc[va_idx])))
    print(f"fold {fold}: {scores[-1]:.4f}")
print(f"CV accuracy: {np.mean(scores):.4f} ± {np.std(scores):.4f}")
fold 0: 0.6951
fold 1: 0.7195
fold 2: 0.7561
fold 3: 0.6914
fold 4: 0.6543
CV accuracy: 0.7033 ± 0.0336

Repeated K-fold: for small data

Because our data is tiny, we go one step further: run the 5-fold 3 times with different shuffles, and average all 15 scores. This is cv_score() in utils.py, and every later notebook calls it in one line:

mean, std, oof = cv_score(rf, X, y)
  repeat 0 fold 0: 0.6951
  repeat 0 fold 1: 0.7195
  repeat 0 fold 2: 0.7561
  repeat 0 fold 3: 0.6914
  repeat 0 fold 4: 0.6543
  repeat 1 fold 0: 0.6951
  repeat 1 fold 1: 0.6829
  repeat 1 fold 2: 0.6829
  repeat 1 fold 3: 0.6420
  repeat 1 fold 4: 0.7160
  repeat 2 fold 0: 0.6829
  repeat 2 fold 1: 0.6585
  repeat 2 fold 2: 0.7195
  repeat 2 fold 3: 0.7037
  repeat 2 fold 4: 0.7407
CV accuracy: 0.6961 ± 0.0303

(oof = "out-of-fold" predictions: every training row predicted by models that never saw it. We'll use these in 06.)

The rule for the rest of the project

Trust your CV score, not the public leaderboard.

Here the public leaderboard is only ~60 houses, so one house = ~1.6%. Our CV uses all ~400 training houses, 3 times over. If CV improves but public drops a little, believe CV. Teams that chase the public leaderboard often fall when the private leaderboard is revealed (a "shake-up").

Next: 04_feature_engineering, to push the CV score up.