Week 13 — Ensembles: A Room of Reviewers¶
Course: Applied ML Foundations for SaaS Analytics
Who this is for: Engineers who have run a design review or a CI matrix. Same idea: one opinion is brittle.
🎯 What you will be able to do¶
- Separate bagging, boosting, voting, and stacking (they are not synonyms)
- Default to a forest / gradient-boosted trees for tabular SaaS data
- Read a learning-rate × n_estimators heatmap
- Know when a committee is worth the ops cost
Think of it like… code review.
Bagging (Random Forest): several reviewers read different random pages of the PR and vote. Uncorrelated mistakes cancel. Good at calming a jittery model.
Boosting (Gradient Boosting / XGBoost): reviewer 2 is handed only the comments reviewer 1 missed, then reviewer 3 hunts what 2 missed. Great at squeezing the last points. Easier to overfit.
Voting: different algorithms (linear + forest + booster) cast a vote tonight.
Stacking: a second model learns how to listen to those votes. Not the same as voting.
If you already write software¶
One reviewer is brittle. Ensembles are a code-review process.
| Ensemble | Review process | Default vibe |
|---|---|---|
| Bagging (Random Forest) | Several reviewers read different random pages and vote | Calms jitter. Hard to overfit. |
| Boosting (GBT / XGBoost) | Reviewer 2 only sees what reviewer 1 missed | Squeezes the last points. Easier to overfit. |
| Voting | Different algorithms cast a vote tonight | Cheap committee. |
| Stacking | A second model learns how to listen to those votes | Extra pipeline. Rarely worth it on the first ship. |
They are not synonyms. Saying “we use an ensemble” is like saying “we do reviews” — which kind?
Why trees win on SaaS tables¶
Your Customer 360 is a spreadsheet: mixed types, missing values, no spatial structure. Gradient-boosted trees are the default for that shape the way Postgres is the default for a relational app. Neural nets (next week) win on images, text, and sequences — not on the 7 columns in FEATURE_COLS.
Picture the ops cost¶
A 500-tree booster that is 0.4% better than an 80-tree one is a worse product if you now need 200ms extra on /predict and a 40 MB pickle instead of a 2 MB one. Measure the committee against a single good tree and against a linear model. Ship the simplest one that beats the baseline by enough to change a staffing decision.
Laptop budget
No GPU. Aimed at ~8 GB RAM. Training uses a few thousand sampled customers (or short sequences) so this week should finish in a few minutes on CPU. The ideas are the same if you later set n=None and train on all ~49k rows.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import (
GradientBoostingClassifier,
RandomForestClassifier,
VotingClassifier,
)
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import validation_curve
from sklearn.pipeline import Pipeline
from pipelines.features import AS_OF_DEFAULT, FEATURE_COLS, build_features, make_preprocessor
from pipelines.labels import drop_unlabelled, label_eventual_churn
Picture the two committees¶
BAGGING BOOSTING
data ─┬─► tree ─┐ data ─► tree1 ─ misses ─► tree2 ─ misses ─► tree3
├─► tree ─┼─ vote ↘ add ↗ ↘ add ↗
└─► tree ─┘ final = tree1 + tree2 + tree3
Engineer mental model
For CloudWave-sized tables (thousands to hundreds of thousands of rows, mixed numbers + categories), gradient-boosted trees are the default workhorse — XGBoost / LightGBM / sklearn’s GBT. Neural nets start to win on images, text, and sequences, not on the 7-column FEATURE_COLS table.
df = build_features(as_of=AS_OF_DEFAULT, n=None, at_risk_only=True)
y = label_eventual_churn(df, AS_OF_DEFAULT)
df, y = drop_unlabelled(df, y)
X = df[FEATURE_COLS]
cut = df["signup_date"].quantile(0.80)
X_train, y_train = X[df["signup_date"] <= cut], y[df["signup_date"] <= cut]
X_test, y_test = X[df["signup_date"] > cut], y[df["signup_date"] > cut]
prep = make_preprocessor()
def auc_of(model):
p = Pipeline([("prep", prep), ("m", model)])
p.fit(X_train, y_train)
return p, roc_auc_score(y_test, p.predict_proba(X_test)[:, 1])
models = {
"logreg": LogisticRegression(max_iter=1000),
"forest (bagging)": RandomForestClassifier(n_estimators=40, max_depth=6, random_state=42, n_jobs=2),
"gbt (boosting)": GradientBoostingClassifier(n_estimators=40, learning_rate=0.1, max_depth=2, random_state=42),
}
fitted = {}
print(f"{'model':<22} AUC")
for name, m in models.items():
pipe, auc = auc_of(m)
fitted[name] = pipe
print(f"{name:<22} {auc:.3f}")
Soft voting ≠ stacking¶
Soft voting averages predicted probabilities. Stacking would train a meta-model on those probabilities. We will do voting honestly, and name it correctly.
vote = VotingClassifier(
estimators=[
("lr", LogisticRegression(max_iter=1000)),
("rf", RandomForestClassifier(n_estimators=30, max_depth=6, random_state=42, n_jobs=2)),
("gb", GradientBoostingClassifier(n_estimators=30, max_depth=2, random_state=42)),
],
voting="soft",
)
vote_pipe, vote_auc = auc_of(vote)
print(f"soft voting AUC: {vote_auc:.3f}")
print("A 0.002 lift that costs 3× latency is usually not a win.")
gbt = fitted["gbt (boosting)"]
names = gbt.named_steps["prep"].get_feature_names_out()
imp = pd.Series(gbt.named_steps["m"].feature_importances_, index=names)
print(imp.sort_values(ascending=False).round(3).to_string())
The only hyperparameter picture you need this week¶
learning_rate is how hard each new tree is allowed to shove the answer. More trees + smaller steps ≈ same work, often stabler. There is no magic pair — there is a ridge on a heatmap.
rates = [0.05, 0.15]
trees = [20, 40]
grid = np.zeros((len(rates), len(trees)))
for i, lr in enumerate(rates):
for j, n in enumerate(trees):
_, grid[i, j] = auc_of(
GradientBoostingClassifier(learning_rate=lr, n_estimators=n,
max_depth=2, random_state=42)
)
fig, ax = plt.subplots(figsize=(6.2, 3.6))
im = ax.imshow(grid, cmap="YlGn", vmin=grid.min() - 0.005, vmax=grid.max())
ax.set_xticks(range(len(trees)), trees)
ax.set_yticks(range(len(rates)), rates)
ax.set_xlabel("n_estimators"); ax.set_ylabel("learning_rate")
ax.set_title("Holdout AUC — look for a plateau, not a spike")
for i in range(grid.shape[0]):
for j in range(grid.shape[1]):
ax.text(j, i, f"{grid[i, j]:.3f}", ha="center", va="center", fontsize=9)
fig.colorbar(im, ax=ax, fraction=0.046)
plt.tight_layout()
plt.show()
Bias–variance on purpose¶
Bagging (a forest) is a variance reducer: many jittery trees, averaged. Boosting is a bias reducer: each tree hunts what the last one still misses — and will overfit if you let it run forever.
The diagnostic is always the same pair of curves.
# 2.5k-row picture is enough to see the two curves; a full-file × 12-depth CV is a coffee break
sample = np.random.default_rng(0).choice(len(X), size=min(2500, len(X)), replace=False)
depths = np.arange(1, 8)
train_s, test_s = validation_curve(
RandomForestClassifier(n_estimators=25, random_state=42, n_jobs=2),
prep.fit_transform(X.iloc[sample]), y.iloc[sample],
param_name="max_depth", param_range=depths,
cv=2, scoring="roc_auc", n_jobs=2,
)
fig, ax = plt.subplots(figsize=(8, 3.6))
ax.plot(depths, train_s.mean(axis=1), marker="o", label="train AUC", color="#1d4ed8")
ax.plot(depths, test_s.mean(axis=1), marker="o", label="holdout AUC", color="#b45309")
ax.set_xlabel("max_depth (capacity)")
ax.set_ylabel("AUC")
ax.set_title("Left = underfit (both low). Right = overfit (train ↑ holdout ↓)")
ax.legend()
plt.tight_layout()
plt.show()
print("Pick the depth where orange peaks, not where blue is 1.0.")
Watch out
Boosting will happily memorize noise if trees get deep and many. A validation curve that keeps rising on train and dies on test is not “more learning.” It is a student who memorized last year’s exam.
Ship / don’t ship
Start with a random forest (forgiving). Move to gradient boosting when you need the last points and can monitor it. Do not stack five models to brag. XGBoost is usually faster than sklearn’s GBT and similar in accuracy — “faster vs more accurate” is the wrong question.
✍️ Exercise¶
When you can explain the week out loud, do the exercises. Starter: python exercises/ml/week-13/starter.py from the repo root.
🤔 Reflection¶
- Why do diverse models help a vote more than three copies of the same forest?
- What is the ops cost of an ensemble (latency, pickle size, explainability)?
- When would you keep logistic regression in production anyway? (regulated audit, need coefficients)
🔗 Next week¶
Neural nets — and an honest answer about whether CloudWave should use one.