learn-intro-ml-with-phoebe / Builder session 10 of 10
Learn Intro to Machine Learning with Phoebe · Builder session 10 of 10

Capstone: compare fairly, pick with CV, score once, write it up

This is where the whole builder track comes together. You have a leak-proof Pipeline, honest metrics, and cross-validation - now you run the protocol a real team runs: compare several candidate models on the same cross-validation splits, pick the winner by that estimate, then score the winner exactly once on the held-out test set, and write a model card so someone else can trust and maintain it. Then a taste of what comes next - clustering customers with no labels at all, the doorway to unsupervised learning and the experimentation course.

🔴 Capstone Builders · DA / DE / DS Python · scikit-learn 45 min
0-3 · The protocol 3-16 · Compare & pick 16-40 · Build it in Python 40-45 · Wrap
Part 0

Picking a model is a protocol, not a vibe

Beginners pick the model that scored best on the test set - which quietly turns the test set into a tuning set and inflates every number they report. Professionals separate the two jobs: use cross-validation to choose, and reserve the held-out test set for one final, honest estimate of the winner. This capstone walks that protocol end to end on Lumen, then writes the model card that makes the result shippable.

Live - built in session Self-study - run after ★ The one big idea The case: Lumen Skincare
★ What you walk out with today The full comparison protocol: cross-validate three candidate models on the same splits and the same metric, pick the winner, score it once on held-out test, and write a model card (task, data, metric, limits, monitoring). Plus a first taste of unsupervised learning - KMeans customer segments that need no labels at all - and a clear map of the two courses that come next.
Part 1 · the comparison protocol

Compare, pick, then score once 6 min live

The order of operations is the whole discipline. You compare candidates on cross-validation, because a single split can be lucky; you pick the winner from those CV estimates; and only then do you open the test set - once - to report how the chosen model will generalise. Touch the test set earlier and it stops being an honest estimate.

Leaderboard: cross-validated ROC-AUC (same splits, same metric) Logistic Dec. tree (d3) Random forest all near the canon ROC-AUC ~0.71 Pick winner by CV mean +/- std Held-out test scored EXACTLY once Ship + monitor The test gate opens once. Score a model there, then tweak and re-score, and the estimate is no longer honest.
🔍 Click to zoom - CV leaderboard, one test gate, then ship
LiveCompare fairly - same CV, same metric3 min▶

A fair comparison changes only the model. Everything else is held constant:

  • Same splits. Every candidate is cross-validated on the identical folds, so a lucky split cannot flatter one model over another.
  • Same metric. Pick the metric that matches the business question first (ROC-AUC for imbalanced conversion), then judge every model by it - not accuracy for one and F1 for the next.
  • Same prep. Each candidate sits inside the same b9 Pipeline, so no model gets an unfair prep advantage or a leakage shortcut.
Why this matters A model comparison is only as trustworthy as its fairness. Change the splits, the metric, or the prep between candidates and you are comparing conditions, not models - and you will pick a winner for the wrong reason.
Self-studyScore the test set exactly once3 min▶

The held-out test set is a one-time resource. Its whole value is that the chosen model has never touched it, so the score is an unbiased estimate of real-world performance. The moment you score, look, adjust, and score again, you have used the test set to tune - and the estimate inflates.

  • Do all choosing on cross-validation or a separate validation split.
  • Refit the single winner on all the training data.
  • Score on the test set once, report that number, and stop.

If the test result disappoints, the honest move is to gather more data or rethink features - not to keep poking the same test set until it smiles back.

★ everyone builds · the full protocol

Run it end to end, then peek at unsupervised 22 min · everyone builds

We classify converted using the b9 Pipeline, compare three models on cross-validation, score the winner once, write the model card, and finish with a taste of clustering. Your exact numbers will be near the canon ROC-AUC ~0.71 - teach the relationships, not a false fourth decimal.

Reload and rebuild the prep. Same ColumnTransformer as b9 - the model is the only thing we will swap.

setup.py
import numpy as np, pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder

df = pd.read_parquet("lumen_sessions.parquet")
y = df["converted"]
X = df.drop(columns=["converted", "order_value"])

num = ["prior_30d_spend", "pages_viewed", "session_hour"]
cat = ["channel", "device", "new_vs_returning", "product_category"]
pre = ColumnTransformer([
    ("num", StandardScaler(), num),
    ("cat", OneHotEncoder(handle_unknown="ignore"), cat),
])
X_tr, X_te, y_tr, y_te = train_test_split(
    X, y, test_size=0.25, random_state=42, stratify=y)

Cross-validate three candidates. Same splits, same metric (ROC-AUC), each inside the same Pipeline. Print mean and spread per model.

A fair comparison changes only the model same splits same metric same prep FAIR COMPARISON one thing changes: the model CHANGE ANY OF THEM comparing conditions, not models = trust the winner picked Change the splits, metric or prep between candidates and you compare conditions, not models.
🔍 Click to zoom - the winner only means something if nothing else moved
compare.py
from sklearn.pipeline import Pipeline
from sklearn.model_selection import cross_val_score
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier

models = {
    "logistic": LogisticRegression(max_iter=1000, class_weight="balanced"),
    "tree_d3":  DecisionTreeClassifier(max_depth=3, class_weight="balanced"),
    "forest":   RandomForestClassifier(n_estimators=200, class_weight="balanced"),
}
for name, m in models.items():
    pipe = Pipeline([("pre", pre), ("model", m)])
    s = cross_val_score(pipe, X_tr, y_tr, cv=5, scoring="roc_auc")
    print("%-9s ROC-AUC %.2f +/- %.2f" % (name, s.mean(), s.std()))
# logistic and forest around ROC-AUC ~0.71; the shallow tree a touch lower

Logistic and random forest land around the ~0.71 AUC region; the shallow tree sits a touch lower. Keep the language qualitative - your numbers will be near the canon, and the ranking is the point, not the fourth decimal.

Pick the winner, refit, score once. Choose by the CV estimate, refit on all training data, then open the held-out test set a single time.

final_score.py
from sklearn.metrics import roc_auc_score

best = Pipeline([("pre", pre),
    ("model", LogisticRegression(max_iter=1000, class_weight="balanced"))])
best.fit(X_tr, y_tr)                              # refit on ALL train

proba = best.predict_proba(X_te)[:, 1]
print("held-out test ROC-AUC %.2f" % roc_auc_score(y_te, proba))   # ~ 0.71, scored ONCE

One number, reported once. It sits near the CV estimate, which is exactly what a clean protocol should produce - no nasty surprise, because we never tuned on the test set.

Write the model card. A model nobody can maintain is not shipped. Capture task, data, metric, limits, and monitoring in one small table.

model_card.py
card = pd.DataFrame([
    ("Task",       "Predict session conversion (binary)"),
    ("Data",       "40k Lumen checkout sessions, ~3.2% convert"),
    ("Model",      "LogisticRegression in a leak-proof Pipeline"),
    ("Metric",     "ROC-AUC ~0.71 (held-out test, scored once)"),
    ("Limits",     "Modest signal; threshold is a business choice"),
    ("Monitoring", "Watch AUC + base rate drift monthly"),
], columns=["field", "value"])
print(card.to_string(index=False))
FieldValue
TaskPredict session conversion (binary)
Data40k Lumen checkout sessions, ~3.2% convert
ModelLogisticRegression in a leak-proof Pipeline
MetricROC-AUC ~0.71 (held-out test, scored once)
LimitsModest signal; classification threshold is a business choice
MonitoringWatch AUC and the base rate for drift, monthly

A taste of unsupervised. Everything so far had a label. KMeans has none - it groups similar customers on its own, from spend and order value alone.

SUPERVISED (SO FAR) every row has a label y converted or order_value known model learns to predict it UNSUPERVISED (KMEANS) no label to learn from groups sessions by similarity spend and order value only KMeans needs no labelled target - it groups similar Lumen sessions on its own.
🔍 Click to zoom - the same dataset, minus the answer column, is a different kind of problem
kmeans.py
from sklearn.cluster import KMeans

buyers = df[df.converted == 1][["prior_30d_spend", "order_value"]]
Xs = StandardScaler().fit_transform(buyers)       # scale before distance-based clustering

km = KMeans(n_clusters=3, n_init=10, random_state=42).fit(Xs)
buyers = buyers.assign(segment=km.labels_)
print(buyers.groupby("segment").mean().round(0))
# three natural segments emerge, e.g. budget / core / high-value
Real world

On the final comparison, random forest edged logistic regression on AUC by a hair - and Lumen shipped logistic regression anyway. The reason was interpretability: the marketing team needed to explain to finance why a session scored the way it did, and coefficients they could read beat a small AUC gain they could not. They added monthly monitoring on AUC and the base rate, and moved on. A slightly better score is not always the better business decision.

Your capstone

This week ◐ 40 min total

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What is the right order for choosing and reporting a model?

Choosing on the test set turns it into a tuning set and inflates the reported number. The honest protocol chooses on cross-validation, refits the single winner, and opens the held-out test set exactly once for the final estimate.

2 · What does KMeans do?

KMeans is unsupervised: it finds groups of similar rows from the features alone, with no target column to learn from. That is a different job from the supervised classification and regression the rest of this course covered.

3 · Why score the held-out test set only once?

The test set is an honest estimate only while the model has never seen it. Score, adjust, and re-score, and you are tuning on the test set - the number climbs but no longer reflects real-world performance. Choose on CV, spend the test set once.

Source material

What this session covers

This capstone puts the whole workflow together - compare, pick, score once, document - and opens the door to unsupervised learning and the courses that come next.

✓The comparison protocol; scoring the test set once; the model card; a first KMeans clusteringPart 1 + demo
◐scikit-learn - model selection (cross_val_score) and KMeans clusteringthe model-choice spine
◐Deeper unsupervised learning -> learn-unsupervised-with-phoebethe sibling course, named not taught
◐From prediction to cause -> learn-experimentation-with-phoebethe causal follow-up, the sequel

Builder Session 10 cheat sheet · pin this

Compare fairlysame CV splits, same metric, same prep for every candidate. Change only the model, or you are comparing conditions, not models.
Pick by CVchoose the winner on the cross-validation mean and spread - a single split can be lucky. The spread across folds is itself information.
Score oncerefit the winner on all train, then open the held-out test set exactly once. Peek repeatedly and it becomes a tuning set that lies.
Model cardtask, data, metric, limits, monitoring. If you cannot write limits and monitoring, the model is not ready to ship.
KMeansunsupervised - groups similar rows with no labels. Scale first (it is distance-based). A taste of learn-unsupervised-with-phoebe.
Best score != best decisionLumen shipped logistic over a slightly better forest for interpretability. Next: prediction to cause in learn-experimentation-with-phoebe, the sequel.