10 · Capstone — Operate a Production ML Service¶
This capstone ties Modules 01-09 together into one small but complete service: a fraud-scoring API that is trained, tested, served, monitored for drift, gated by CI/CD, and evaluated with an A/B test before a full rollout — the same shape as a real production ML system, just small enough to build end-to-end yourself.
The service: a fraud-scoring API¶
# train.py — reproducible training with pinned deps (Module 03/05 pattern)
import joblib
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score
import pandas as pd
def train(data_path: str, output_path: str) -> dict:
df = pd.read_parquet(data_path)
X = df.drop(columns=["is_fraud"])
y = df["is_fraud"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)
model = GradientBoostingClassifier(n_estimators=150, max_depth=4, random_state=42)
model.fit(X_train, y_train)
auc = roc_auc_score(y_test, model.predict_proba(X_test)[:, 1])
joblib.dump(model, output_path)
return {"auc": auc, "n_train": len(X_train), "n_test": len(X_test)}
# serve.py — FastAPI serving with logging for drift detection (Module 02/05)
from fastapi import FastAPI
from pydantic import BaseModel
import joblib
import numpy as np
from datetime import datetime, timezone
import json
app = FastAPI()
model = joblib.load("model.pkl")
MODEL_VERSION = "v7"
class ScoreRequest(BaseModel):
amount: float
merchant_category_risk: float
hours_since_last_transaction: float
is_new_device: bool
class ScoreResponse(BaseModel):
fraud_probability: float
flagged: bool
model_version: str
@app.post("/score", response_model=ScoreResponse)
def score(req: ScoreRequest):
x = np.array([[req.amount, req.merchant_category_risk,
req.hours_since_last_transaction, float(req.is_new_device)]])
proba = float(model.predict_proba(x)[0][1])
response = ScoreResponse(fraud_probability=proba, flagged=proba > 0.5, model_version=MODEL_VERSION)
with open("prediction_log.jsonl", "a") as f:
f.write(json.dumps({
"timestamp": datetime.now(timezone.utc).isoformat(),
"model_version": MODEL_VERSION,
"features": req.model_dump(),
"fraud_probability": proba,
}) + "\n")
return response
Testing before it ships (Module 04)¶
# tests/test_service.py
def test_no_regression_vs_current_production(candidate_model, prod_model, X_test, y_test):
from sklearn.metrics import roc_auc_score
candidate_auc = roc_auc_score(y_test, candidate_model.predict_proba(X_test)[:, 1])
prod_auc = roc_auc_score(y_test, prod_model.predict_proba(X_test)[:, 1])
assert candidate_auc >= prod_auc - 0.005, f"candidate {candidate_auc:.4f} regresses vs prod {prod_auc:.4f}"
def test_amount_monotonicity(candidate_model):
# a sanity/invariance check: all else equal, a much larger transaction
# amount should not produce a LOWER fraud score
base = np.array([[50.0, 0.3, 12.0, 0.0]])
large = np.array([[5000.0, 0.3, 12.0, 0.0]])
assert candidate_model.predict_proba(large)[0][1] >= candidate_model.predict_proba(base)[0][1]
def test_fairness_gap_within_bound(candidate_model, X_test, y_test, customer_region):
report = fairness_report(y_test.values, candidate_model.predict(X_test), customer_region)
assert demographic_parity_difference(report) <= 0.10
CI/CD pipeline wiring it together (Module 04)¶
# .github/workflows/fraud-service.yml
name: Fraud Service CI/CD
on:
push: { branches: [main] }
schedule: [{ cron: "0 4 * * *" }]
jobs:
train-test-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install -r requirements.txt
- name: Data validation
run: pytest tests/test_data.py -v
- name: Train candidate
run: python train.py --output models/candidate.pkl
- name: Quality + fairness gate
run: pytest tests/test_service.py -v
- name: Register candidate
run: python platform/register_model.py --name fraud-detector --path models/candidate.pkl
canary-rollout:
needs: train-test-gate
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh --model fraud-detector --stage staging --traffic-pct 5
Monitoring drift once it's live (Module 05)¶
# monitor_job.py — scheduled nightly (matches the CI/CD cron)
def nightly_drift_check():
prod_logs = load_recent_predictions("prediction_log.jsonl", days=1)
training_reference = load_training_reference("data/train_reference.parquet")
report = check_drift_and_decide(
feature_logs={col: prod_logs[col].values for col in ["amount", "merchant_category_risk"]},
training_reference={col: training_reference[col].values for col in ["amount", "merchant_category_risk"]},
)
if report["recommend_retrain"]:
trigger_retraining_pipeline(reason=report["reason"])
return report
A/B testing the new model before full cutover (Module 06)¶
def run_fraud_model_experiment(candidate_version: str, prod_version: str,
daily_transactions: int = 8000):
n_needed = required_sample_size(baseline_rate=0.012, min_detectable_effect=0.002)
days_needed = math.ceil(n_needed / (daily_transactions * 0.5))
print(f"Need {n_needed} transactions/variant -> ~{days_needed} days at current traffic")
# after the experiment runs for days_needed days:
result = analyze_ab_test(
control_conversions=142, control_total=32_000, # "conversion" = correctly caught fraud
treatment_conversions=168, treatment_total=32_010,
)
return result
Worked example: the full promotion decision, end to end¶
def full_promotion_decision(candidate_metrics, prod_metrics, fairness_reports,
ab_test_result, drift_report) -> tuple[bool, list[str]]:
"""Every gate from every module, combined into one ship/no-ship call."""
blockers = []
promote_ok, reason = should_promote(candidate_metrics, prod_metrics) # Module 04
if not promote_ok:
blockers.append(f"CI/CD gate: {reason}")
dp_gap = demographic_parity_difference(fairness_reports["candidate"]) # Module 07
if dp_gap > 0.10:
blockers.append(f"fairness gate: demographic parity gap {dp_gap:.3f} > 0.10")
if not ab_test_result["significant"] or ab_test_result["absolute_lift"] <= 0: # Module 06
blockers.append(f"A/B test: no significant positive lift (p={ab_test_result['p_value']})")
if drift_report["recommend_retrain"]: # Module 05 -- stale reference data invalidates the comparison
blockers.append(f"drift gate: {drift_report['reason']} -- re-run comparison on fresh reference data")
return len(blockers) == 0, blockers
ship, blockers = full_promotion_decision(
candidate_metrics={"accuracy": 0.958, "p99_latency_ms": 38, "demographic_parity_diff": 0.04},
prod_metrics={"accuracy": 0.951, "p99_latency_ms": 40, "demographic_parity_diff": 0.04},
fairness_reports={"candidate": fairness_report(y_true, y_pred_candidate, region)},
ab_test_result={"significant": True, "absolute_lift": 0.008, "p_value": 0.021},
drift_report={"recommend_retrain": False, "reason": "no feature exceeded drift threshold"},
)
print("SHIP" if ship else f"BLOCKED: {blockers}")
Cheat sheet: the full lifecycle¶
| Stage | Module | Artifact in this capstone |
|---|---|---|
| Design the system, avoid train/serve skew | 01 | Shared feature computation in train.py/serve.py |
| Serve with a validated API | 02 | serve.py FastAPI endpoint |
| Feature consistency at scale | 03 | (would move to a feature store beyond this size) |
| Test data + model, gate promotion | 04 | tests/test_service.py, fraud-service.yml |
| Detect drift, trigger retraining | 05 | nightly_drift_check |
| Validate the change with real traffic | 06 | run_fraud_model_experiment |
| Check fairness before shipping | 07 | test_fairness_gap_within_bound |
| Control serving cost | 08 | (quantize model.pkl if latency/cost demands it) |
| Multi-team infrastructure | 09 | register_model.py against a shared registry |
| All of the above, one decision | — | full_promotion_decision |
How It Actually Works¶
Every module in this level turned out to be one gate in a single
pipeline, and the capstone's real lesson is that none of those gates is
sufficient alone. A model can pass the CI/CD accuracy gate (Module 04)
while failing fairness (Module 07); it can pass both while the A/B test
shows no real user-facing improvement (Module 06); it can pass all three
while drift monitoring (Module 05) reveals the comparison itself was run
against stale reference data and needs to be redone. full_promotion_decision
makes this explicit by checking all four independently and refusing to
ship if any one fails — this mirrors why real ML platforms accumulate
gates over time rather than picking one "good enough" metric: each new
production incident (a fairness complaint, a drift-driven silent
regression, a canary that looked fine offline but hurt a real experiment)
tends to be exactly the failure mode the next gate was added to catch.
The order of the pipeline matters as much as its contents, and it follows the same "cheap checks first, expensive commitments last" principle from Module 09. Data validation runs before training (no point training on bad data); the CI/CD quality+fairness gate runs before any deployment (no point serving a model that will get rolled back); the canary rollout happens before a full A/B test at scale (limit exposure before investing in a multi-week experiment); and the A/B test's own result only counts if drift monitoring confirms the comparison data is still valid (no point trusting an experiment run against a reference distribution that's since shifted). Reordering any of these — say, running the expensive A/B test before the cheap fairness check — would waste weeks of traffic on a candidate that a five-line assertion could have rejected in seconds.
The prediction log is the single artifact that makes every later stage
possible, which is why it's written at serve time rather than reconstructed
after the fact. serve.py logs every request's features and prediction
the moment it's made — this is the only way nightly_drift_check can later
compare actual production inputs against the training reference (Module
05 needs real logged data, not a synthetic proxy), and it's the same log
format an A/B test's variant-level outcome tracking (Module 06) and a
platform-wide audit trail (Module 09) would both build on. A service that
skips prediction logging "to keep serve.py simple" quietly forecloses drift
detection, experiment analysis, and incident debugging months later, once
the gap in historical data can no longer be filled in retroactively.
Exercise¶
Wire full_promotion_decision into the canary-rollout GitHub Actions job
as an actual gate (pseudocode is fine): the job should call it with real
metrics pulled from the training run, the fairness report, the most recent
completed A/B test, and the latest drift check, and fail the workflow step
if ship is False, printing every blocker. Then describe, in a few
sentences, which of the four gates you would keep as a hard blocker versus
which you'd make a warning that still allows a human to override and
ship anyway — and justify the difference using the trade-offs discussed
across Modules 04-07.