Week 15 — The Pickle: A Training Script Is Not Production¶
Course: Applied ML Foundations for SaaS Analytics
Who this is for: Engineers about to pickle a model and get paged for it.
This is still a training script. We will be explicit about what it is not: no feature store, no canary, no GDPR review, no CI. The GPU coding-specialist capstone is a different page (docs/ml/capstone.md).
🎯 What you will be able to do¶
- Wire
build_features→ time-based split → GBT → a versioned artifact - Call
pipelines.contract.validate/predict— extra keys and NaN fail loud - Pick a threshold from CS capacity, not from 0.5
- Draw a crude drift picture (
tenure_so_far, this month vs train) - Time 80
predict()calls so you have a latency number, not a vibe - List what you are not deploying
Think of it like… a build artifact + an API contract.
The model file is a binary. The scaler is part of that binary. The request body is FEATURE_COLS. If any of those three drift independently, production is a silent wrong-number generator.
If you already write software¶
A training script is a scratchpad. Production is a contract.
This week is the one that separates “I trained a thing” from “we can call it on Tuesday.”
Explore (weeks 6–14)
│ time-based split, pick a model, write predict()
▼
Ship
predict(payload) -> {churn_score, flag_for_cs, model_version}
the same features as training (tenure_so_far, not lifetime tenure_days)
the scaler travels with the joblib
a latency budget (we time 80 calls)
a drift picture (did live traffic stop looking like train?)
Time-based split is not optional¶
train_test_split(..., shuffle=True) is fine for a homework iris set. It is a lie for SaaS. Customers in the “test” set would include people from the same week as train — and tomorrow’s traffic is next week. Split on signup or on event time. Train on the past. Test on the future. Same rule as backtesting a trading strategy, or as not using tomorrow’s logs to tune today’s alert.
Picture the contract¶
from pipelines.contract import predict
# payload keys == FEATURE_COLS
# extra key → ValueError, missing field → ValueError, NaN → ValueError
# returns {"churn_score": float, "flag_for_cs": bool, "model_version": str}
If you cannot call that function without reaching back into a throwaway script, you do not have a model. You have a souvenir.
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 time
from datetime import datetime
from pathlib import Path
import joblib
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import (
average_precision_score,
precision_recall_curve,
roc_auc_score,
)
from sklearn.pipeline import Pipeline
from pipelines.contract import predict, validate
from pipelines.features import (
AS_OF_DEFAULT,
FEATURE_COLS,
build_features,
make_preprocessor,
)
from pipelines.labels import drop_unlabelled, label_eventual_churn
Architecture (the only diagram that matters)¶
warehouse CSVs
│ nightly job
▼
build_features(as_of) ──► time split ──► train pipeline ──► artifact (joblib + metrics)
│ │
└── holdout report ▼
predict(payload) {score, flag, version}
│
▼
monitor: score volume,
feature histograms, weekly PR-AUC
Engineer mental model
Train on the past, test on the more recent past. Random shuffle is a unit test. A time wall is an integration test against reality.
as_of = AS_OF_DEFAULT
df = build_features(as_of=as_of, n=None, at_risk_only=True)
y = label_eventual_churn(df, as_of)
df, y = drop_unlabelled(df, y)
# This file only has tens of 30-day cancels. Eventual-after-as_of is the
# question it can supervise. Write that on the artifact. Horizon is still
# the product question (Week 8 / pipelines.train --label).
df = df.sort_values("signup_date")
cutoff = df["signup_date"].quantile(0.80)
train_df = df[df["signup_date"] <= cutoff]
test_df = df[df["signup_date"] > cutoff]
y_train, y_test = y.loc[train_df.index], y.loc[test_df.index]
print(f"Time wall at {cutoff.date()} train={len(train_df):,} test={len(test_df):,}")
print("Train rate", float(y_train.mean()), "Test rate", float(y_test.mean()))
pipe = Pipeline(
[
("prep", make_preprocessor()),
("model", GradientBoostingClassifier(
n_estimators=40, learning_rate=0.1, max_depth=2, random_state=42
)),
]
)
pipe.fit(train_df[FEATURE_COLS], y_train)
proba = pipe.predict_proba(test_df[FEATURE_COLS])[:, 1]
print(f"Holdout AUC: {roc_auc_score(y_test, proba):.3f}")
print(f"Holdout PR-AUC: {average_precision_score(y_test, proba):.3f}")
print(f"dummy PR-AUC: {float(y_test.mean()):.3f}")
Threshold from a staffing number¶
CS can call 80 accounts from this test window. We take the 80 highest scores and measure precision. That is the meeting.
BUDGET = 80
order = np.argsort(-proba)
top = order[:BUDGET]
picked = y_test.to_numpy()[top]
hits = picked.sum()
print(f"Calling {BUDGET} highest-risk test users catches {int(hits)} actuals "
f"({hits/BUDGET:.0%} precision at this budget).")
print(f"There were {int(y_test.sum())} events in the window; "
f"recall={hits / max(float(y_test.sum()), 1):.0%}.")
threshold = float(np.partition(proba, -BUDGET)[-BUDGET]) if len(proba) >= BUDGET else 1.0
prec, rec, _thr = precision_recall_curve(y_test, proba)
fig, ax = plt.subplots(figsize=(6.5, 3.8))
ax.plot(rec, prec, color="#1d4ed8")
ax.set_xlabel("recall (catch rate)")
ax.set_ylabel("precision (when we call, we were right)")
ax.set_title("Precision–recall — pick a point your team can staff")
ax.scatter([hits / max(float(y_test.sum()), 1)], [hits / BUDGET], color="#b91c1c", zorder=5)
ax.annotate("80-call budget", xy=(hits / max(float(y_test.sum()), 1), hits / BUDGET),
xytext=(0.35, 0.55), textcoords="axes fraction",
arrowprops={"arrowstyle": "->", "color": "#b91c1c"})
plt.tight_layout()
plt.show()
The contract: validate + predict¶
Watch out
If the API re-implements feature math differently from build_features, you have two products. One function. The handler imports pipelines.contract. Extra keys (churn_date, email) and NaN fail loud — they do not become a silent 0.
version = as_of.strftime("%Y%m%d")
artifact = {
"pipeline": pipe,
"metrics": {"threshold": round(threshold, 4), "model_version": version},
}
demo = {k: train_df.iloc[0][k] for k in FEATURE_COLS}
demo["plan_type"] = str(demo["plan_type"])
print("demo payload →", predict(demo, artifact))
try:
validate({"mrr": 10})
except Exception as exc:
print("missing keys →", type(exc).__name__, exc)
try:
validate({**demo, "churn_date": "2024-07-01"})
except Exception as exc:
print("unknown key →", type(exc).__name__, exc)
t0 = time.perf_counter()
for _ in range(80):
predict(demo, artifact)
print("80 predict() calls", round(time.perf_counter() - t0, 3), "s")
predict already returns churn_score, flag_for_cs, and model_version. Do not invent a CustomerFeatures type unless you define it.
Drift — did this month stop looking like train?¶
We will not implement a full PSI monitor. We will overlay histograms. If the orange fill walks away from the blue, someone should get a Slack.
Math, translated (optional)
Population Stability Index is a fancy “how different are two histograms.” If you want a number, compare bin shares: sum( (p − q) * log(p / q) ). If you want a decision, look at the picture first.
fig, axes = plt.subplots(1, 3, figsize=(12, 3.3))
for ax, col in zip(axes, ["mrr", "log_usage", "tenure_so_far"]):
ax.hist(train_df[col], bins=30, density=True, alpha=0.55, label="train", color="#3b82f6")
ax.hist(test_df[col], bins=30, density=True, alpha=0.55, label="later signups", color="#f59e0b")
ax.set_title(col)
ax.legend(fontsize=8)
plt.suptitle("If orange leaves blue, the world moved — re-check PR-AUC before celebrating")
plt.tight_layout()
plt.show()
dest = Path.cwd() / "artifacts" / version
dest.mkdir(parents=True, exist_ok=True)
joblib.dump({"pipeline": pipe, "features": FEATURE_COLS}, dest / "model.joblib")
print("Wrote", dest / "model.joblib")
print("Ship the joblib AND contract.py AND this week's commit hash. Same layout as pipelines.train.")
What this lesson is not¶
| Claimed in many “production” tutorials | Reality here |
|---|---|
| Feature versioning | FEATURE_COLS in a joblib |
| Error handling | validate() on types, extras, NaN |
| Drift detection | Three histograms of tenure_so_far etc. |
| Retraining triggers | “Look at the histograms + weekly PR-AUC” |
| Deployment | A directory on disk (artifacts/<date>/) |
Ship / don’t ship
You can ship a batch score from this pipeline: score tonight’s accounts, hand CS a CSV of 80 names. Do not ship a public HTTP API until the contract lives in a tested module, the artifact is in a registry, and someone owns the weekly PR-AUC dashboard.
Course recap (the actual skills)¶
| Week | How to think about it |
|---|---|
| 0 Python | Glue: dataclasses, a fit/predict class |
| 1 NumPy | SIMD / SQL on a typed column |
| 2 Pandas | Joins you already know; never explode the grain |
| 3 SQL / as_of | The warehouse is source of truth |
| 4 Charts | Pick the shape that matches the question |
| 5 Stats | “How often would luck look like this?” |
| 6 Features | API contract + a wall against the future |
| 7 Classifiers | Score, then a staffed threshold |
| 8 Labels | Horizon, imbalance, calibration |
| 9 Regression | MAE in real units |
| 10 Clusters | Personas, not APIs |
| 11 Ranking | Precision@k; beat a SQL sort |
| 12 PCA | JPEG / rotate the cloud |
| 13 Ensembles | Reviewers voting vs hunting leftovers |
| 14 Nets | Mixers + the four-line training step |
| 15 Pickle | Artifact + contract + capacity |
✍️ Write-up¶
In one page: (1) the time wall you used, (2) holdout AUC vs a dummy, (3) the 80-call precision, (4) one drift risk, (5) what you refused to over-claim.
✍️ Exercise¶
When you can explain the week out loud, do the exercises. Starter: python exercises/ml/week-15/starter.py from the repo root.
Before you leave¶
Try one self-check (Predict / Diagnose / Choose / Defend). Write the answer before you open the block.
🔗 Next: the job, then (optionally) deep learning¶
You can refuse a leak, demand a baseline, and staff a threshold. Next:
- 16 the job pipeline: train → gate → prod → tonight’s CSV
- 17 on-call + the score as a bot tool
- 18–20 optional pictures (CNN / RNN / attention) — not how CloudWave ships churn
- Capstone (optional, GPU): a coding-tool-use specialist, not this pickle