What You'll Build
In this exercise you will build a rigorous model evaluation framework that applies every concept from this module: paradigm identification, bias-variance diagnosis, a proper three-way split with stratification, cross-validation of full preprocessing pipelines, and metric selection aligned with the business objective. The scenario is a cricket talent-scouting tool — predicting whether a junior player will become a professional cricketer within five years based on their early performance metrics. This is a real-world imbalanced classification problem (few juniors make it to professional level), which demands stratified splits, AUC-based evaluation, and careful threshold tuning to balance false positives against false negatives.
Prerequisites
- Python 3.10 or later with NumPy, pandas, and scikit-learn installed.
- Familiarity with supervised learning paradigms from Lesson 01.
- Understanding of bias-variance trade-off and its diagnostics from Lesson 02.
- Mastery of train/validation/test split strategies from Lesson 03.
- Command of cross-validation variants and their implementation from Lesson 04.
- Understanding of evaluation metrics and their alignment with business objectives from Lesson 05.
Setup & Dataset
You will generate a synthetic junior-cricketer dataset with realistic imbalance and evaluate multiple models against it. Install the required packages, seed for reproducibility, and inspect the class distribution before any modelling — the class imbalance informs every subsequent decision, from split strategy to metric choice to threshold setting.
import numpy as np
import pandas as pd
from sklearn.datasets import make_classification
# Synthetic junior-cricketer dataset: 10% make professional level (realistic imbalance)
X, y = make_classification(
n_samples=1000, n_features=15, n_informative=8, n_redundant=4,
weights=[0.90, 0.10], flip_y=0.02, random_state=42
)
feature_names = [
"batting_avg", "strike_rate", "bowling_economy", "fielding_runs",
"fitness_score", "mental_score", "coachability", "match_wins",
"team_contribution", "technique_score", "consistency", "aggression",
"game_iq", "form_trend", "injury_history"
]
df = pd.DataFrame(X, columns=feature_names)
df["professional"] = y
print(f"Dataset: {df.shape}")
print(f"Class distribution:\n{df['professional'].value_counts(normalize=True).round(3)}")
print(f"Positive (professional) rate: {y.mean():.1%}")
print("Imbalanced dataset -> stratified splits and AUC-based metrics are required.")
Step 1 — Foundation: Proper Data Splitting
Step 1 establishes the correct data split before any modelling. With an imbalanced target, stratification is mandatory. The test set is locked away immediately and will be used only once at the very end. All modelling decisions — feature selection, hyperparameter tuning, model comparison — will use cross-validation on the training set only.
from sklearn.model_selection import train_test_split
X_trainval, X_test, y_trainval, y_test = train_test_split(
df.drop("professional", axis=1).values, df["professional"].values,
test_size=0.20, stratify=df["professional"].values, random_state=42
)
print(f"Train+val: {len(X_trainval)} samples "
f"({y_trainval.mean():.1%} positive)")
print(f"Test: {len(X_test)} samples "
f"({y_test.mean():.1%} positive)")
print("Test set locked. No peeking until ALL decisions are finalised.")
print(f"Stratification confirmed: both splits preserve the ~10% positive rate.")
Step 2 — Core Logic: Cross-Validated Pipeline Evaluation
Step 2 builds the core evaluation logic: a function that cross-validates any sklearn-compatible pipeline on the training data and returns mean AUC-ROC, AUC-PR, and their standard deviations. Using both AUC-ROC and AUC-PR is essential for imbalanced data — AUC-ROC measures overall ranking quality, while AUC-PR specifically measures minority-class performance and is harder to inflate. The function cross-validates a full pipeline (preprocessing + model) to prevent leakage.
from sklearn.model_selection import cross_validate, StratifiedKFold
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier
def evaluate_pipeline(pipeline, X, y, n_splits=5):
cv = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42)
results = cross_validate(
pipeline, X, y, cv=cv,
scoring={"roc_auc": "roc_auc",
"avg_precision": "average_precision", # AUC-PR
"f1": "f1"},
return_train_score=True
)
return {
"train_auc": results["train_roc_auc"].mean(),
"val_auc": results["test_roc_auc"].mean(),
"val_auc_std": results["test_roc_auc"].std(),
"val_pr_auc": results["test_avg_precision"].mean(),
"val_f1": results["test_f1"].mean(),
"bias_var_gap": results["train_roc_auc"].mean() - results["test_roc_auc"].mean(),
}
def make_pipe(model):
return Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
("model", model),
])
models = {
"Logistic Regression": make_pipe(LogisticRegression(max_iter=500, C=1.0)),
"Decision Tree (deep)": make_pipe(DecisionTreeClassifier(max_depth=None)),
"Decision Tree (d=4)": make_pipe(DecisionTreeClassifier(max_depth=4)),
"Random Forest": make_pipe(RandomForestClassifier(n_estimators=100, random_state=42)),
}
print(f"{'Model':30} | {'Train AUC':>10} | {'Val AUC':>10} | {'Val PR AUC':>11} | {'Diagnosis'}")
for name, pipe in models.items():
r = evaluate_pipeline(pipe, X_trainval, y_trainval)
if r["train_auc"] < 0.70: diag = "High Bias"
elif r["bias_var_gap"] > 0.15: diag = "High Variance"
else: diag = "Balanced"
print(f"{name:30} | {r['train_auc']:>10.3f} | {r['val_auc']:>10.3f} | "
f"{r['val_pr_auc']:>11.3f} | {diag}")
Step 3 — Integration: Bias-Variance Diagnosis and Threshold Tuning
Step 3 selects the best model based on cross-validation, diagnoses its bias-variance position using a learning curve, and tunes the classification threshold to maximise F1 (or a domain-specific metric). In talent scouting, missing a future star (false negative) is more costly than an extra tryout invitation (false positive), so the threshold should be tuned to favour recall over precision.
from sklearn.model_selection import learning_curve
from sklearn.metrics import precision_recall_curve, roc_auc_score, average_precision_score
import numpy as np
# Select best model by val AUC (Random Forest assumed)
best_pipe = make_pipe(RandomForestClassifier(n_estimators=100, random_state=42))
# LEARNING CURVE: diagnose bias vs variance
train_sizes, train_scores, val_scores = learning_curve(
best_pipe, X_trainval, y_trainval,
cv=StratifiedKFold(5, shuffle=True, random_state=42),
scoring="roc_auc",
train_sizes=np.linspace(0.1, 1.0, 6)
)
print("Learning curve (AUC):")
for sz, tr, va in zip(train_sizes, train_scores.mean(1), val_scores.mean(1)):
gap = tr - va
diagnosis = "High Variance (overfit)" if gap > 0.15 else "OK" if gap > 0.05 else "High Bias"
print(f" n={sz:4.0f}: train={tr:.3f}, val={va:.3f}, gap={gap:.3f} -> {diagnosis}")
# THRESHOLD TUNING using out-of-fold predictions
from sklearn.model_selection import cross_val_predict
y_oof_prob = cross_val_predict(best_pipe, X_trainval, y_trainval,
cv=StratifiedKFold(5, shuffle=True, random_state=42),
method="predict_proba")[:,1]
prec, rec, thr = precision_recall_curve(y_trainval, y_oof_prob)
f1 = 2*prec*rec/(prec+rec+1e-9)
best_thr = thr[f1[:-1].argmax()]
print(f"\nOptimal threshold (max F1 on OOF): {best_thr:.3f}")
print(f"At this threshold: precision={prec[f1[:-1].argmax()]:.3f}, "
f"recall={rec[f1[:-1].argmax()]:.3f}")
print("Lower threshold -> higher recall (fewer future stars missed)")
Step 4 — Testing & Final Report
Now use the test set — exactly once — to report the final performance. Train the best model on the full training set, apply the tuned threshold, and compute all metrics. Compare the test performance against the CV estimate to check they are close, confirming the CV was an honest estimate. A large discrepancy signals the CV was somehow contaminated.
from sklearn.metrics import (roc_auc_score, average_precision_score,
precision_score, recall_score, f1_score, confusion_matrix)
import numpy as np
# Train final model on ALL trainval data
best_pipe.fit(X_trainval, y_trainval)
# TEST SET evaluation — one look only
y_test_prob = best_pipe.predict_proba(X_test)[:,1]
y_test_pred = (y_test_prob > best_thr).astype(int)
print("=" * 55)
print("FINAL TEST SET RESULTS (one-time, uncontaminated)")
print("=" * 55)
print(f"AUC-ROC: {roc_auc_score(y_test, y_test_prob):.3f}")
print(f"AUC-PR: {average_precision_score(y_test, y_test_prob):.3f}")
print(f"F1: {f1_score(y_test, y_test_pred):.3f}")
print(f"Precision: {precision_score(y_test, y_test_pred):.3f}")
print(f"Recall: {recall_score(y_test, y_test_pred):.3f} (fraction of future stars caught)")
print(f"\nConfusion matrix:")
cm = confusion_matrix(y_test, y_test_pred)
print(f" TN={cm[0,0]:3d} FP={cm[0,1]:3d}")
print(f" FN={cm[1,0]:3d} TP={cm[1,1]:3d}")
print(f"\nCV AUC (training estimate): ~0.XXX vs Test AUC: {roc_auc_score(y_test, y_test_prob):.3f}")
print("Close agreement confirms the CV was an honest, non-contaminated estimate.")
Warning: The exercise design requires strict test-set discipline — compute the test-set metrics only in Step 4, after all decisions in Steps 1-3 are finalised. If you peek at the test set to guide threshold selection or model choice in Step 3, the test result in Step 4 is no longer an honest evaluation. If accidental peeking occurs, document it and generate a new random seed for the test split so the reported result remains honest.
Extension Challenge: Replace the default threshold-maximising F1 with a threshold that maximises expected monetary value using domain costs: assume each missed future star (false negative) costs the board INR 50 lakhs in future contracts, and each unnecessary trial invitation (false positive) costs INR 50,000. Compute the expected value at each threshold and find the business-optimal threshold, which will likely be much lower than the F1-optimal threshold, favouring high recall over high precision.
- Imbalanced classification requires stratified splits, AUC-based metrics, and threshold tuning — accuracy alone is meaningless.
- Cross-validate full pipelines (imputer + scaler + model) to prevent preprocessing leakage from test folds.
- Both AUC-ROC and AUC-PR should be reported for imbalanced problems; AUC-PR is more sensitive to minority-class performance.
- The learning curve diagnoses bias versus variance before tuning; diagnosing before acting prevents applying the wrong remedy.
- Out-of-fold predictions provide an honest dataset for threshold tuning without touching the test set.
- The test set is used exactly once at the end; close agreement between CV AUC and test AUC validates the evaluation.
- Threshold selection is a business decision based on the relative cost of false positives and false negatives, not a fixed 0.5.