Lesson 6 of 6
Blend & final reveal
Averaging models, then the private leaderboard is revealed.
06 · Ensembling and the final submission
Different models make different mistakes. If we average their predicted probabilities, the mistakes partly cancel out. This is ensembling (or blending), and nearly every winning Kaggle solution does it.
Plan:
- get out-of-fold (OOF) probabilities from 5 tuned models (same CV folds for all),
- check how often they disagree,
- blend and measure the blend with CV,
- train on everything, predict test, submit,
- 🎉 the competition "ends": reveal the private leaderboard for every submission we made.
import warnings import numpy as np import pandas as pd from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import accuracy_score from lightgbm import LGBMClassifier from xgboost import XGBClassifier from catboost import CatBoostClassifier from utils import make_xy, cv_score, save_submission, SEED from score import score warnings.filterwarnings("ignore") X, y, X_test, test = make_xy()
The five models, with the best settings found in 05:
models = { "logreg": lambda: make_pipeline(StandardScaler(), LogisticRegression(C=1.0, max_iter=3000)), "rf": lambda: RandomForestClassifier(n_estimators=500, min_samples_leaf=5, random_state=SEED, n_jobs=-1), "lgb": lambda: LGBMClassifier(n_estimators=300, learning_rate=0.03, num_leaves=4, min_child_samples=10, subsample=0.8, subsample_freq=1, colsample_bytree=0.7, random_state=SEED, verbose=-1), "xgb": lambda: XGBClassifier(n_estimators=300, learning_rate=0.03, max_depth=2, subsample=0.8, colsample_bytree=0.7, random_state=SEED), "cat": lambda: CatBoostClassifier(iterations=500, learning_rate=0.03, depth=4, random_seed=SEED, verbose=0), } oof, cv = {}, {} for name, make in models.items(): cv[name], std, oof[name] = cv_score(make, X, y, verbose=False) print(f"{name:7s} CV {cv[name]:.4f} ± {std:.4f}")
logreg CV 0.7190 ± 0.0481
rf CV 0.7214 ± 0.0429
lgb CV 0.7296 ± 0.0397
xgb CV 0.7296 ± 0.0324
cat CV 0.7345 ± 0.0308
Do the models disagree?
If two models always predicted the same thing, averaging them would change nothing. Fraction of houses where each pair disagrees (using the OOF predictions):
preds = pd.DataFrame({k: v.argmax(1) for k, v in oof.items()}) pd.DataFrame({a: {b: round((preds[a] != preds[b]).mean(), 3) for b in preds} for a in preds})
| logreg | rf | lgb | xgb | cat | |
|---|---|---|---|---|---|
| logreg | 0.000 | 0.189 | 0.169 | 0.179 | 0.159 |
| rf | 0.189 | 0.000 | 0.081 | 0.074 | 0.069 |
| lgb | 0.169 | 0.081 | 0.000 | 0.022 | 0.051 |
| xgb | 0.179 | 0.074 | 0.022 | 0.000 | 0.054 |
| cat | 0.159 | 0.069 | 0.051 | 0.054 | 0.000 |
Logistic Regression thinks completely differently from the tree models (straight-line rule vs yes/no questions), so it disagrees with them the most. The three boosting models are close cousins and mostly agree with each other. Different kinds of model bring the most diversity.
Blending
Average the probabilities, then pick the most likely tier. Measured on the OOF predictions, so it's still an honest CV score:
def blend_acc(names): proba = np.mean([oof[n] for n in names], axis=0) return accuracy_score(y, proba.argmax(1)) combos = [["cat"], ["logreg", "cat"], ["lgb", "xgb", "cat"], ["logreg", "lgb", "xgb", "cat"], ["logreg", "rf", "lgb", "xgb", "cat"]] blend_df = pd.DataFrame({"blend": [" + ".join(c) for c in combos], "oof_acc": [round(blend_acc(c), 4) for c in combos]}) blend_df
| blend | oof_acc | |
|---|---|---|
| 0 | cat | 0.7377 |
| 1 | logreg + cat | 0.7328 |
| 2 | lgb + xgb + cat | 0.7377 |
| 3 | logreg + lgb + xgb + cat | 0.7377 |
| 4 | logreg + rf + lgb + xgb + cat | 0.7377 |
⚠️ Two honesty notes:
- These numbers are accuracy on the OOF predictions averaged over 3 repeats. That's slightly kinder than the per-fold numbers above, so compare blends with each other, not with the single-model CV.
- Picking the best of many blends is itself a mild form of fitting to validation data. With 5 candidates it's fine. With hundreds you'd start overfitting your CV.
We keep it simple and robust: equal weights over all 5 models. Even when it isn't the very top row, an all-model average is the least likely to be a lucky pick.
Final submission
Train each model on all training data, average their test probabilities, submit.
test_proba = {} for name, make in models.items(): test_proba[name] = make().fit(X, y).predict_proba(X_test) save_submission(test, test_proba[name].argmax(1), f"06_{name}") final_proba = np.mean([test_proba[n] for n in models], axis=0) path = save_submission(test, final_proba.argmax(1), "06_final_blend") print("final blend - public leaderboard:", score(path))
final blend - public leaderboard: {'public': 0.6935}
🎉 The competition ends: private leaderboard revealed
Until now we only saw the public score (~60 houses). The final ranking uses the private part (~75 houses), which we've never seen. Here is every submission we made, next to its CV score (repeated 5-fold):
cv_known = {"02_majority": None, "02_random_forest": 0.6961, "04_leaky_target_encoding": 0.7998, "04_features_te": 0.7001, "05_best_single": 0.7345} for name in models: cv_known[f"06_{name}"] = round(cv[name], 4) cv_known["06_final_blend"] = round(blend_acc(list(models)), 4) rows = [] for name, c in cv_known.items(): s = score(f"submissions/{name}.csv", show_private=True) rows.append({"submission": name, "cv": c, "public": s["public"], "private": s["private"]}) board = pd.DataFrame(rows) board
| submission | cv | public | private | |
|---|---|---|---|---|
| 0 | 02_majority | NaN | 0.4032 | 0.2933 |
| 1 | 02_random_forest | 0.6961 | 0.6290 | 0.6533 |
| 2 | 04_leaky_target_encoding | 0.7998 | 0.5323 | 0.4533 |
| 3 | 04_features_te | 0.7001 | 0.7258 | 0.6000 |
| 4 | 05_best_single | 0.7345 | 0.6935 | 0.7067 |
| 5 | 06_logreg | 0.7190 | 0.7097 | 0.7333 |
| 6 | 06_rf | 0.7214 | 0.6935 | 0.7333 |
| 7 | 06_lgb | 0.7296 | 0.7097 | 0.7333 |
| 8 | 06_xgb | 0.7296 | 0.6935 | 0.7333 |
| 9 | 06_cat | 0.7345 | 0.6935 | 0.7067 |
| 10 | 06_final_blend | 0.7377 | 0.6935 | 0.7467 |
How to read this table
- The leaky submission has by far the highest CV, and the worst score of every real model on both leaderboards. Leakage makes CV lie.
- Public vs private for the same submission can differ by 5+ points. With only ~60 and ~75 houses, a handful of houses decides the score. On a small test set the leaderboard is mostly luck, and your CV is the only steady signal.
- CV is noisy too (±0.8%), so models within ~1–2% of each other are effectively tied. Don't over-read small gaps anywhere.
- The final blend is what we'd pick in a real competition: built on honest CV, combining different kinds of model, with no tuning to the leaderboard.
Full journey
majority guess → Random Forest → honest (repeated) CV → feature ideas tested (and rejected) → leak caught → regularised models → blend.
That's the whole workflow. For the real competition, it is the same steps with a different CSV. See GUIDE.md.