Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Training-job rows now have input size, heartbeat age, backlog, priority class, and a later label for whether the job missed its SLA (service-level agreement). Those rows still aren't a decision. You need a score for upcoming jobs and a published rule for when to intervene.
A gradient boosted tree is a strong default on this kind of table.[1] You already saw the mechanics in Decision Trees, Forests, and Boosting: each new tree fits leftover error. A candidate ships only when it beats a declared baseline on later jobs, survives required slices, and travels with enough evidence to reproduce the call.
Freeze the clock before training
Suppose missed_sla = 1 means a training job missed its promised finish time. Before any fitting, publish a time-ordered split:
| Split | Calendar range | Purpose |
|---|---|---|
| train | January through March | fit model |
| validation | April | select round count and threshold |
| test | May | final evidence after those choices freeze |
A random shuffle can put the same disruption into train and test. Calendar order is closer to facing tomorrow's jobs. It still doesn't erase every dependency. If one incident can contribute several rows, document the split unit and keep those rows together whenever splitting them would make holdout look easier than production.
scikit-learn's leakage page says the same thing: don't train on information that wouldn't be available at prediction time.[2] The point-in-time replay from Batch and Streaming Feature Pipelines is how this table stays honest. A row created after its decision timestamp is feature leakage and doesn't belong in training.

Build a deterministic fixture that follows that calendar. Labels depend on heartbeat age, backlog, service tier, a large-model flag derived from input size, and a little noise. May also gets a small drift term, so the test month isn't a copy of training.
1import json
2from hashlib import sha256
3
4import numpy as np
5from sklearn.ensemble import GradientBoostingClassifier
6from sklearn.metrics import log_loss, roc_auc_score
7
8rng = np.random.default_rng(11)
9FEATURES = [
10 "input_gb",
11 "heartbeat_age_hours",
12 "backlog",
13 "priority",
14 "large_model",
15]
16
17def sigmoid(value):
18 return 1 / (1 + np.exp(-value))
19
20def make_jobs(month, count, drift=0.0):
21 input_gb = rng.integers(40, 1601, count)
22 heartbeat_age = rng.integers(1, 41, count)
23 backlog = rng.integers(0, 31, count)
24 priority = rng.integers(0, 2, count)
25 large_model = (input_gb >= 900).astype(int)
26 logit = (
27 -5.2
28 + 0.13 * heartbeat_age
29 + 0.07 * backlog
30 + 0.85 * priority
31 + 0.75 * large_model
32 + drift
33 )
34 missed_sla = rng.binomial(1, sigmoid(logit))
35 X = np.column_stack([input_gb, heartbeat_age, backlog, priority, large_model])
36 return {"month": month, "X": X, "y": missed_sla}
37
38train = make_jobs("Jan-Mar", 360)
39valid = make_jobs("April", 140)
40test = make_jobs("May", 140, drift=0.18)
41
42for split in (train, valid, test):
43 print(f"{split['month']}: rows={len(split['y'])} missed_sla={int(split['y'].sum())}")1Jan-Mar: rows=360 missed_sla=119
2April: rows=140 missed_sla=58
3May: rows=140 missed_sla=55The fixture is synthetic so you can rerun the whole lab. In a real job, X has to come from that point-in-time contract, not from a table snapshot taken after the decision.
Publish the cheapest baseline
Your first candidate can be a rule: predict an SLA miss when heartbeat_age_hours >= 18. It's weak, and that's the point. The boosted model has to beat something you'd actually run, not get credit for any nonzero score.
Define one cost policy before comparing models:
- A missed priority job costs
150. - A missed standard job costs
60. - A false alarm costs
8.
The exact numbers will differ by product. Publishing them matters because model quality is inseparable from the action the score triggers.
1def confusion(y, predicted):
2 return {
3 "tp": int(np.sum((y == 1) & (predicted == 1))),
4 "fp": int(np.sum((y == 0) & (predicted == 1))),
5 "fn": int(np.sum((y == 1) & (predicted == 0))),
6 "tn": int(np.sum((y == 0) & (predicted == 0))),
7 }
8
9def cost_stats(split, scores, threshold):
10 predicted = (scores >= threshold).astype(int)
11 priority = split["X"][:, FEATURES.index("priority")] == 1
12 missed = (split["y"] == 1) & (predicted == 0)
13 false_alarm = (split["y"] == 0) & (predicted == 1)
14 return {
15 "threshold": threshold,
16 "cost": int(
17 150 * np.sum(missed & priority)
18 + 60 * np.sum(missed & ~priority)
19 + 8 * np.sum(false_alarm)
20 ),
21 "priority_misses": int(np.sum(missed & priority)),
22 **confusion(split["y"], predicted),
23 }
24
25heartbeat_age_index = FEATURES.index("heartbeat_age_hours")
26rule_valid = (valid["X"][:, heartbeat_age_index] >= 18).astype(int)
27print("rule validation:", cost_stats(valid, rule_valid.astype(float), threshold=0.50))1rule validation: {'threshold': 0.5, 'cost': 1560, 'priority_misses': 8, 'tp': 48, 'fp': 30, 'fn': 10, 'tn': 52}The rule already catches 48 of April's 58 SLA misses, at cost 1560. Extra trees have to improve that decision, not just emit a prettier probability.
Why publish the heartbeat-age rule before evaluating the boosted model?
Answer
The cheap rule establishes the decision quality already available without model complexity. The booster must beat that baseline under the same held-out slice and cost policy.
Rebuild one boosting step by hand
A shallow decision tree partitions rows into a few rules. Gradient boosting adds those trees one at a time: each new tree moves predictions toward errors left by the current ensemble. Friedman describes this as stage-wise function approximation using loss gradients.[3]
For intuition, switch from the binary SLA label to delay hours on three jobs:
| Lane | Actual delay | First prediction | Residual |
|---|---|---|---|
| local standard | 2 | 6 | -4 |
| regional standard | 8 | 6 | +2 |
| large-model economy | 20 | 6 | +14 |
A small correction tree might add little for local jobs and more for long economy workloads. For squared-error regression, its training target is the residual actual - prediction. A learning rate applies only part of that correction, so one noisy tree can't dominate.
1actual_delay = np.array([2.0, 8.0, 20.0])
2prediction = np.array([6.0, 6.0, 6.0])
3large_model = np.array([0, 0, 1])
4residual = actual_delay - prediction
5
6correction = np.where(
7 large_model == 1,
8 residual[large_model == 1].mean(),
9 residual[large_model == 0].mean(),
10)
11learning_rate = 0.25
12updated = prediction + learning_rate * correction
13
14print("residuals:", residual.tolist())
15print("tree correction:", correction.tolist())
16print("before mae:", round(float(np.mean(np.abs(actual_delay - prediction))), 2))
17print("after mae:", round(float(np.mean(np.abs(actual_delay - updated))), 2))1residuals: [-4.0, 2.0, 14.0]
2tree correction: [-1.0, -1.0, 14.0]
3before mae: 6.67
4after mae: 5.5The tree proposed -1 hour for shorter workloads and 14 hours for the large-model job. Shrinkage kept only a quarter of that move, so the large-model prediction went from 6 to 9.5, not all the way to 20. One round lowers mean absolute error without pretending the first correction is finished.
Friedman writes that update as stage-wise addition with shrinkage. If is the current score and is the new tree,
where is the learning rate (here ).[3]
Classification needs one extra translation. An SLA-risk classifier doesn't fit delay-hour residuals. With log loss, each new tree fits the negative gradient of that loss. At an initial probability of 0.50, that negative binary log-loss gradient is gold - probability. Equivalently, the score gradient is probability - gold.
1gold = np.array([0.0, 1.0, 1.0])
2probability = np.full(3, 0.50)
3negative_gradient = gold - probability
4
5print("initial probability:", probability.tolist())
6print("gold - probability:", negative_gradient.tolist())1initial probability: [0.5, 0.5, 0.5]
2gold - probability: [-0.5, 0.5, 0.5]Negative values push SLA-risk scores down. Positive values push them up. A real classification booster repeats this across many rows and trees.
When the table gets large or contains high-cardinality categoricals, production teams often choose between three battle-tested boosting systems:
- XGBoost: Chen and Guestrin built it with a regularized objective (), sparsity-aware split finding that learns default directions for missing values, weighted quantile sketching for approximate splits, and cache-aware column blocks for parallel split finding.[4]
- LightGBM: Ke et al. optimized training throughput using histogram-based feature binning, leaf-wise (best-first) tree growth with a max-depth limit, and native integer categorical handling via optimal histogram partitioning without one-hot expansion.[5]
- CatBoost: Prokhorenkova et al. implemented symmetric (oblivious) trees for fast inference, ordered boosting to prevent target leakage during target encoding, and automated feature combinations for high-cardinality categoricals.[6]
The evidence contract doesn't change with the library. When downstream decision policies depend on well-calibrated probabilities rather than rank order alone, evaluate calibration (reliability curves, Brier score) or apply post-hoc calibration like isotonic regression or Platt scaling on the validation split before freezing the threshold policy.[7]
Fit a booster with an explicit stopping rule
This lab uses scikit-learn's GradientBoostingClassifier so the code stays small. It builds an additive model stage by stage and fits each tree to the negative gradient of the chosen loss, here log_loss. A larger job might use XGBoost. You'd still freeze rounds on April, not on training loss.
Fix tree depth and learning rate first. Train a generous maximum number of rounds on January through March, then inspect April loss after each stage.
Don't use sklearn's validation_fraction helper for this split. That flag randomly holds out rows from the training matrix. Here the validation slice is a later month, so you own the loop with staged_predict_proba on April.
1round_search = GradientBoostingClassifier(
2 loss="log_loss",
3 n_estimators=160,
4 learning_rate=0.05,
5 max_depth=2,
6 random_state=11,
7)
8round_search.fit(train["X"], train["y"])
9print("trained stage cap:", round_search.n_estimators_)1trained stage cap: 1601train_losses = [
2 log_loss(train["y"], probabilities[:, 1], labels=[0, 1])
3 for probabilities in round_search.staged_predict_proba(train["X"])
4]
5validation_losses = [
6 log_loss(valid["y"], probabilities[:, 1], labels=[0, 1])
7 for probabilities in round_search.staged_predict_proba(valid["X"])
8]
9best_round = int(np.argmin(validation_losses) + 1)
10
11print("round 1 train / april:", round(train_losses[0], 4), round(validation_losses[0], 4))
12print("best round:", best_round)
13print("round 50 train / april:", round(train_losses[best_round - 1], 4), round(validation_losses[best_round - 1], 4))
14print("round 160 train / april:", round(train_losses[-1], 4), round(validation_losses[-1], 4))1round 1 train / april: 0.6163 0.6805
2best round: 50
3round 50 train / april: 0.3491 0.5643
4round 160 train / april: 0.2684 0.5922Training loss keeps falling through round 160. April loss bottoms at round 50, then gets worse. Extra trees still helped the training objective. They stopped helping future jobs. That's the overfitting symptom early stopping is for.

50, from 0.3491 to 0.2684. April log loss turns up from 0.5643 to 0.5922, so the production freeze is the April minimum, not the training cap.Some libraries expose built-in early stopping flags. A production job still needs to record the exact validation slice, monitored metric, patience policy, and selected round count. Here the rule is visible: choose the stage with minimum April log loss, then fit the candidate with that frozen count.
1candidate = GradientBoostingClassifier(
2 loss="log_loss",
3 n_estimators=best_round,
4 learning_rate=0.05,
5 max_depth=2,
6 random_state=11,
7)
8candidate.fit(train["X"], train["y"])
9valid_scores = candidate.predict_proba(valid["X"])[:, 1]
10
11print("frozen rounds:", candidate.n_estimators_)
12print("rule validation auc:", round(roc_auc_score(valid["y"], rule_valid), 3))
13print("boosted validation auc:", round(roc_auc_score(valid["y"], valid_scores), 3))1frozen rounds: 50
2rule validation auc: 0.731
3boosted validation auc: 0.792The booster improves April AUC from 0.731 to 0.792. Area under the receiver operating characteristic curve (AUC) summarizes ranking quality across thresholds. It doesn't choose an operations policy.
Validation loss is lowest at round 50 and rises through round 160. Which round count should the frozen candidate use?
Answer
Use round 50, selected on the declared validation slice. Continuing to the training cap would keep trees that improved training fit but worsened held-out loss.
Measure threshold cost, not AUC alone
The classifier outputs an SLA-risk score. Operations still needs a choice: intervene, notify, or leave the job on its normal path. A missed SLA on priority jobs may be more expensive than an unnecessary proactive notification.
Search candidate thresholds on April only. The sort key minimizes declared cost and uses the lower threshold as a tie-breaker.
1thresholds = [0.15, 0.20, 0.25, 0.30, 0.40]
2for threshold in thresholds:
3 stats = cost_stats(valid, valid_scores, threshold)
4 print(
5 f"threshold={threshold:.2f} cost={stats['cost']} "
6 f"priority_misses={stats['priority_misses']}"
7 )
8
9selected_threshold = min(
10 thresholds,
11 key=lambda threshold: (cost_stats(valid, valid_scores, threshold)["cost"], threshold),
12)
13april_rule = cost_stats(valid, rule_valid.astype(float), threshold=0.50)
14april_boosted = cost_stats(valid, valid_scores, selected_threshold)
15print("selected threshold:", selected_threshold)
16print("april rule cost:", april_rule["cost"])
17print("april boosted cost:", april_boosted["cost"])1threshold=0.15 cost=1316 priority_misses=6
2threshold=0.20 cost=1884 priority_misses=10
3threshold=0.25 cost=1912 priority_misses=10
4threshold=0.30 cost=2038 priority_misses=11
5threshold=0.40 cost=2604 priority_misses=14
6selected threshold: 0.15
7april rule cost: 1560
8april boosted cost: 1316Threshold 0.15 beats the heartbeat-age rule on April (1316 vs 1560) and sits far below the common default of 0.50. Missing a priority SLA is expensive in this policy, so the model accepts more false alarms. If alert fatigue mattered more, the cost table would need another term.
Now freeze the round count, threshold, and cost policy. May can be opened once for final candidate evidence.
1test_scores = candidate.predict_proba(test["X"])[:, 1]
2test_stats = cost_stats(test, test_scores, selected_threshold)
3rule_test = (test["X"][:, heartbeat_age_index] >= 18).astype(int)
4may_rule = cost_stats(test, rule_test.astype(float), threshold=0.50)
5
6print("test auc:", round(roc_auc_score(test["y"], test_scores), 3))
7print("may rule cost:", may_rule["cost"])
8print("test policy:", test_stats)1test auc: 0.882
2may rule cost: 1044
3test policy: {'threshold': 0.15, 'cost': 728, 'priority_misses': 2, 'tp': 50, 'fp': 31, 'fn': 5, 'tn': 54}May can look better or worse than April. Here the frozen booster still beats the same heartbeat-age rule (728 vs 1044). You don't retune. The job of May is to confirm the frozen policy, not to pick a new one. Aggregate AUC and cost can still hide a serious workload failure.
Gate slices before promotion
Pick required slices before reading test results. A slice gate also needs enough positive cases to mean anything: perfect recall on one SLA-miss job is weak evidence, and an empty slice can't produce recall at all. This lab requires at least 10 SLA-miss jobs per slice, recall of at least 0.90 for priority jobs, and recall of at least 0.85 for large-model jobs.
1def slice_gate(split, scores, threshold, name, mask, minimum_recall, minimum_positives):
2 predicted = (scores >= threshold).astype(int)
3 stats = confusion(split["y"][mask], predicted[mask])
4 positives = stats["tp"] + stats["fn"]
5 measured_recall = stats["tp"] / positives if positives else 0.0
6 enough_support = positives >= minimum_positives
7 passed = enough_support and measured_recall >= minimum_recall
8 result = {
9 "name": name,
10 "rows": int(np.sum(mask)),
11 "positives": positives,
12 "recall": round(measured_recall, 3),
13 "minimum_recall": minimum_recall,
14 "minimum_positives": minimum_positives,
15 "confusion": stats,
16 "passed": passed,
17 }
18 print(
19 f"{name}: positives={positives} recall={measured_recall:.3f} "
20 f"minimum_recall={minimum_recall:.2f} "
21 f"minimum_positives={minimum_positives} pass={passed}"
22 )
23 return result
24
25priority_index = FEATURES.index("priority")
26large_model_index = FEATURES.index("large_model")
27required_slice_results = [
28 slice_gate(
29 test,
30 test_scores,
31 selected_threshold,
32 "priority",
33 test["X"][:, priority_index] == 1,
34 minimum_recall=0.90,
35 minimum_positives=10,
36 ),
37 slice_gate(
38 test,
39 test_scores,
40 selected_threshold,
41 "large_model",
42 test["X"][:, large_model_index] == 1,
43 minimum_recall=0.85,
44 minimum_positives=10,
45 ),
46]
47required_slices_pass = all(result["passed"] for result in required_slice_results)
48print("all required slices pass:", required_slices_pass)1priority: positives=36 recall=0.944 minimum_recall=0.90 minimum_positives=10 pass=True
2large_model: positives=31 recall=0.903 minimum_recall=0.85 minimum_positives=10 pass=True
3all required slices pass: TrueBoth required slices pass for the frozen candidate. Keep the gate anyway. A future retrain, feature drift, or threshold edit can break one segment while aggregate metrics still look acceptable.
To see the failure symptom, test a careless threshold edit from 0.15 to 0.50.
1bad_threshold = 0.50
2bad_slice_results = [
3 slice_gate(
4 test,
5 test_scores,
6 bad_threshold,
7 "priority",
8 test["X"][:, priority_index] == 1,
9 minimum_recall=0.90,
10 minimum_positives=10,
11 ),
12 slice_gate(
13 test,
14 test_scores,
15 bad_threshold,
16 "large_model",
17 test["X"][:, large_model_index] == 1,
18 minimum_recall=0.85,
19 minimum_positives=10,
20 ),
21]
22print("release blocked:", not all(result["passed"] for result in bad_slice_results))1priority: positives=36 recall=0.722 minimum_recall=0.90 minimum_positives=10 pass=False
2large_model: positives=31 recall=0.645 minimum_recall=0.85 minimum_positives=10 pass=False
3release blocked: TrueNothing about the fitted trees changed. The threshold edit alone drops priority recall from 0.944 to 0.722. Versioning only the model file would miss the regression.
Why doesn't a higher AUC choose the production intervention threshold?
Answer
AUC measures ordering across thresholds. Operations still needs a threshold chosen against concrete false-alarm and missed-SLA costs, including critical slices such as priority jobs.
Ship a model that can be operated
The candidate should export:
| Artifact | Why it matters |
|---|---|
feature_contract.json | proves column meanings and time boundary |
split_manifest.json | proves evaluation wasn't random or leaky |
model.skops or model.onnx | versioned fitted model |
threshold_policy.json | turns score into action |
slice_metrics.json | records required-slice support and regressions |
serving_schema.json | validates incoming row shape |
Create a compact receipt for the candidate. The real deployment bundle would persist the estimator with a format you can defend. scikit-learn's current guidance is: use ONNX when you only need scores and don't need the Python object back; use skops.io when you do need the object and want to inspect untrusted types before loading; treat pickle, joblib, and cloudpickle as trusted-source-only because loading them can execute code. None of those Python formats support loading an estimator trained on a different scikit-learn version, so pin Python, scikit-learn, NumPy, SciPy, and the serializer together.[8]
1receipt = {
2 "artifact": "sla-risk-candidate-v1",
3 "feature_contract": "point-in-time-snapshot-v1",
4 "model": {
5 "family": "gradient_boosted_trees",
6 "learning_rate": 0.05,
7 "max_depth": 2,
8 "rounds": best_round,
9 },
10 "policy": {
11 "sla_risk_threshold": selected_threshold,
12 "costs": {
13 "false_alarm": 8,
14 "standard_miss": 60,
15 "priority_miss": 150,
16 },
17 },
18 "split_manifest": {"train": "Jan-Mar", "validation": "April", "test": "May"},
19 "test_auc": round(roc_auc_score(test["y"], test_scores), 3),
20 "test_policy": test_stats,
21 "required_slice_metrics": required_slice_results,
22 "required_slices_pass": required_slices_pass,
23 "release_status": "candidate_for_shadow" if required_slices_pass else "blocked",
24}
25payload = json.dumps(receipt, sort_keys=True)
26print("release status:", receipt["release_status"])
27print("frozen rounds and threshold:", best_round, selected_threshold)
28print("receipt sha256:", sha256(payload.encode()).hexdigest()[:12])1release status: candidate_for_shadow
2frozen rounds and threshold: 50 0.15
3receipt sha256: fd121184d2ffThe receipt binds feature semantics, fitted-tree choices, threshold policy, split dates, measured holdout policy, and support-aware slice evidence. Shadow traffic and production monitoring still come next. Don't mutate the live policy in place when retraining changes those values.