Train a boosted SLA-risk baseline from tabular features, evaluate slices, and package deployment evidence.
The feature pipeline now gives you honest rows: input size, heartbeat age, backlog, priority class, and a later label saying whether a job missed its SLA. The next job is to turn those rows into a useful decision: which jobs need intervention before they miss their SLA?
A gradient boosted tree classifier is a strong candidate for this kind of table. It can learn nonlinear rules without replacing the evaluation discipline from the previous chapters. A fitted model earns promotion only when it beats a declared baseline on later jobs, survives important slices, and travels with enough evidence to reproduce the decision.
50 minimizes validation loss and threshold 0.15 minimizes declared cost. Only then does May provide final evidence, where priority recall 0.944 and large-model recall 0.903 pass their support-aware gates.Suppose missed_sla = 1 means a training job missed its SLA. Before training, publish a split:
| Split | Calendar range | Purpose |
|---|---|---|
| train | January through March | fit model |
| validation | April | select round count and threshold |
| test | May | final evidence after choices freeze |
Random rows could place near-identical traffic patterns from the same disruption into train and test. Time order better represents a model facing tomorrow's jobs.
Time order doesn't erase every dependency. If one job incident can contribute several rows, document the split unit and keep linked rows together whenever those correlations would make holdout artificially easy.
Build a deterministic fixture that follows that calendar. The generated labels depend on heartbeat age, backlog, service tier, input size, and a small amount of noise. May also gets a slight drift term, so the test month isn't identical to 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 training job, X must come from the point-in-time replay contract you built in Batch and Streaming Feature Pipelines. A row created after its decision timestamp doesn't belong in training.
Your first candidate can be a rule: predict an SLA miss when heartbeat_age_hours >= 18. It's weak, but it makes the boosted model prove its added complexity rather than receiving credit for any nonzero result.
Define one cost policy before comparing models:
150.60.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}This baseline is intentionally simple. It already catches 48 April SLA misses. A boosted model has to improve the decision policy, rather than produce a decimal score that changes no action.
A shallow decision tree partitions rows into a few rules. Gradient boosting adds shallow trees sequentially: each new tree moves predictions toward errors left by the earlier ensemble. Friedman describes this process as stage-wise function approximation using loss gradients.[1]
For intuition, switch from a binary outcome to delay hours:
| 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 repeated trees refine mistakes without letting one tree 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 correction tree predicts -1 hour for shorter workloads and 14 hours for the large-model workload. Shrinkage applies only one quarter of that proposal. One round lowers mean absolute error without pretending the first correction is perfect.
Pause and predict: If learning rate changed from
0.25to1.0, which job would move most? The large-model job would jump by the full14hours because its residual-tree leaf has the largest correction.
Classification needs one extra step. An SLA-risk classifier doesn't fit raw delay-hour residuals. With log loss, each new tree fits the negative gradient of that loss. At an initial probability of 0.50, the binary log-loss gradient points toward gold - probability.
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 process across many rows and trees.
XGBoost extends tree boosting with a regularized objective, sparse-aware split handling, column blocks, and parallel techniques for scalable training.[2] For an engineer, the important artifact is still the evaluation contract: a fitted booster and its threshold must be linked to feature version, split manifest, metrics, and serving schema.
The mechanics stay the same across libraries. This lab uses scikit-learn's GradientBoostingClassifier so the code stays small. Its classifier builds an additive model stage by stage and fits trees against loss gradients. A larger production job might swap in XGBoost for its scalable training system, but the evidence contract doesn't change.
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.
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: 1601validation_losses = [
2 log_loss(valid["y"], probabilities[:, 1], labels=[0, 1])
3 for probabilities in round_search.staged_predict_proba(valid["X"])
4]
5best_round = int(np.argmin(validation_losses) + 1)
6
7print("round 1 validation loss:", round(validation_losses[0], 4))
8print("best round:", best_round)
9print("best validation loss:", round(validation_losses[best_round - 1], 4))
10print("round 160 validation loss:", round(validation_losses[-1], 4))1round 1 validation loss: 0.6805
2best round: 50
3best validation loss: 0.5643
4round 160 validation loss: 0.5922April loss reaches its minimum at round 50, then gets worse by round 160. More trees improved the training objective but stopped helping future jobs. That's the overfitting symptom early stopping catches.
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 stopping 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 boosted model 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.
The classifier outputs an SLA-risk score. Operations 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 lower threshold as 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)
13print("selected threshold:", selected_threshold)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.15The selected threshold is 0.15, much lower than the common default of 0.50. Missing a priority SLA miss 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)
3
4print("test auc:", round(roc_auc_score(test["y"], test_scores), 3))
5print("test policy:", test_stats)1test auc: 0.882
2test policy: {'threshold': 0.15, 'cost': 728, 'priority_misses': 2, 'tp': 50, 'fp': 31, 'fn': 5, 'tn': 54}The holdout result is useful, but aggregate AUC and cost can still hide a serious workload failure.
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.
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 | versioned fitted scikit-learn 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 serialize the fitted estimator with skops.io, inspect any untrusted types before loading it, and pin Python, scikit-learn, NumPy, SciPy, and skops versions. Loading a persisted estimator across scikit-learn versions isn't supported.[3] It would also include the schema files named above.
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(json.dumps(receipt, indent=2, sort_keys=True))
27print("receipt sha256:", sha256(payload.encode()).hexdigest()[:12])1{
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": 50
9 },
10 "policy": {
11 "costs": {
12 "false_alarm": 8,
13 "priority_miss": 150,
14 "standard_miss": 60
15 },
16 "sla_risk_threshold": 0.15
17 },
18 "release_status": "candidate_for_shadow",
19 "required_slice_metrics": [
20 {
21 "confusion": {
22 "fn": 2,
23 "fp": 9,
24 "tn": 29,
25 "tp": 34
26 },
27 "minimum_positives": 10,
28 "minimum_recall": 0.9,
29 "name": "priority",
30 "passed": true,
31 "positives": 36,
32 "recall": 0.944,
33 "rows": 74
34 },
35 {
36 "confusion": {
37 "fn": 3,
38 "fp": 18,
39 "tn": 21,
40 "tp": 28
41 },
42 "minimum_positives": 10,
43 "minimum_recall": 0.85,
44 "name": "large_model",
45 "passed": true,
46 "positives": 31,
47 "recall": 0.903,
48 "rows": 70
49 }
50 ],
51 "required_slices_pass": true,
52 "split_manifest": {
53 "test": "May",
54 "train": "Jan-Mar",
55 "validation": "April"
56 },
57 "test_auc": 0.882,
58 "test_policy": {
59 "cost": 728,
60 "fn": 5,
61 "fp": 31,
62 "priority_misses": 2,
63 "threshold": 0.15,
64 "tn": 54,
65 "tp": 50
66 }
67}
68receipt 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.
drift=0.18 for May to drift=0.80. Rebuild the fixture from the first cell and rerun the lab. Compare test AUC, cost, and slice gates.30 instead of 8. Predict whether selected threshold should move up or down, then verify it.max_depth from 2 to 5 in both fitted models. Compare best round and April loss. Explain why more complex trees need fresh evidence.0.75. Decide whether candidate still passes.minimum_positives to 40. Confirm required slices fail closed when holdout evidence is too thin.| Evidence | What a strong answer shows |
|---|---|
| baseline discipline | compares boosted trees with a declared operational baseline on future holdout data |
| boosting mechanics | distinguishes regression residual corrections from classification loss gradients |
| early stopping | records monitored validation month, metric, stage cap, and selected round |
| decision policy | converts risk scores into thresholded actions with explicit costs |
| slice safety | evaluates support-aware workloads, workload classes, and job classes before release |
| Symptom | Cause | Fix |
|---|---|---|
| Great validation result, poor next month | random or stale split | evaluate on later jobs |
| Training keeps improving while April loss worsens | too many boosting rounds | stop on later validation loss and record selected round |
| Retraining changes interventions unexpectedly | threshold wasn't versioned with model | publish one scoring bundle |
| Average recall passes while premium jobs fail | no required-slice gate | gate priority and critical job classes |
| Slice recall looks perfect on one SLA-miss job | slice gate ignored support | require minimum positive cases or report insufficient evidence |
Answer every question, then check your score. Score above 75% to mark this lesson complete.
7 questions remaining.
Questions and insights from fellow learners.