Turn training-job events into stable prediction inputs while preventing leakage and training-serving mismatch.
Versioned datasets with clean splits still aren't model-ready by themselves. A prediction model can't consume a raw event log directly. To predict whether a training job will miss its SLA, it needs a fixed row of measurements available at the moment the promise is made.
Those measurements are features. A feature such as hours_since_last_heartbeat compresses many scheduler events into one input value. Inventing columns is easy compared with making sure each value means the same thing during training and while serving live requests.
09:00, the contract emits the exact six-field row [a100-pool, 42.0, 0.5, 18.0, 0, 0]. The noon heartbeat, finished timestamp, and post-SLA escalation remain beyond the cutoff and can't enter the model input.Suppose the product asks at 2026-05-01 09:00: will job J-204 miss its SLA? Job heartbeats after that timestamp aren't available to the prediction service and can't appear in its training row.
| Candidate field | Known at prediction time? | Use as feature? |
|---|---|---|
| runner pool | yes | yes, categorical |
| queued minutes | yes | yes, numeric |
| minutes since most recent heartbeat | yes | yes, numeric |
| cluster queue backlog | yes | yes, numeric |
| finished timestamp | no | no, it defines the eventual label |
| post-SLA escalation | no | no, it leaks the outcome |
The label may be computed later as missed_sla = 1. A feature must be computed from history ending at the prediction timestamp. If a training row contains the post-SLA escalation, offline accuracy will reward a model for reading the answer.
Sculley et al. describe production ML systems as networks of data and configuration dependencies where hidden feedback and undeclared consumers create technical debt.[1] Feature definitions are one of those dependencies: when their time boundary is unclear, the model's impressive score doesn't survive deployment.
Build a small contract for job-SLA prediction:
| Feature | Type | Missing rule | Why it can help |
|---|---|---|---|
queued_minutes | numeric | reject if absent | longer queue waits expose more scheduling risk |
hours_since_last_heartbeat | numeric | cap at 180 | stale heartbeats signal job risk |
queue_backlog | numeric | use measured queue only | queue pressure affects scheduling and preemption |
runner_pool | categorical | reject if absent; map unseen to other | runner pools have different networks |
priority_job | boolean | default false only when source guarantees it | priority changes SLA and retry policy |
A missing value is a product decision. Filling missing queue_backlog with zero says "unknown congestion means no congestion," which is rarely defensible. Store an additional queue_backlog_missing indicator or stop scoring until the feed recovers.
Categorical values need a policy too. If a new runner pool appears after training, the online encoder can't invent a new model column. An other bucket provides stable behavior while a new model candidate is evaluated. Missing runner pool data is different: it may signal a broken source feed, so don't silently fold it into other.
A feature job should make the prediction timestamp explicit, then discard later events before aggregation. This first lab keeps only heartbeats whose event timestamps aren't later than the prediction. It deliberately assumes heartbeats arrive immediately. The next lesson adds a separate ingestion timestamp so a replay can't use an earlier heartbeat that arrived late.
1from datetime import datetime
2
3prediction_time = datetime.fromisoformat("2026-05-01T09:00:00")
4heartbeats = [
5 datetime.fromisoformat("2026-05-01T01:00:00"),
6 datetime.fromisoformat("2026-05-01T08:30:00"),
7 datetime.fromisoformat("2026-05-01T12:00:00"),
8]
9
10visible_heartbeats = [heartbeat for heartbeat in heartbeats if heartbeat <= prediction_time]
11print("visible heartbeats:", len(visible_heartbeats))
12print("latest visible:", max(visible_heartbeats).isoformat())1visible heartbeats: 2
2latest visible: 2026-05-01T08:30:00Filtering source history is necessary but not sufficient. A transformation can still copy a future-only field into its output by mistake. Check the actual feature keys before training or serving.
1allowed_feature_keys = {
2 "runner_pool",
3 "queued_minutes",
4 "hours_since_last_heartbeat",
5 "queue_backlog",
6 "queue_backlog_missing",
7 "priority_job",
8}
9candidate_features = {
10 "runner_pool": "a100-pool",
11 "queued_minutes": 42.0,
12 "finished_at": "2026-05-03T14:00:00",
13}
14
15blocked_fields = sorted(candidate_features.keys() - allowed_feature_keys)
16print("blocked fields:", blocked_fields)
17print("release allowed:", not blocked_fields)1blocked fields: ['finished_at']
2release allowed: FalseAn allowlist gate is stronger than a comment or a static list of future fields: it fails when an unexpected key enters the feature row. It can't prove that an allowed field contains only information known at prediction time. Source-history filtering and offline/online parity checks still have to enforce that semantic contract.
For this model, keep a missing queue backlog distinct from a measured queue backlog of zero. The imputed value keeps the vector numeric; the indicator preserves the information that measurement failed. Another product might abstain instead.
1def encode_queue_backlog(value):
2 return {
3 "queue_backlog": 0.0 if value is None else float(value),
4 "queue_backlog_missing": int(value is None),
5 }
6
7print("measured zero:", encode_queue_backlog(0))
8print("missing:", encode_queue_backlog(None))1measured zero: {'queue_backlog': 0.0, 'queue_backlog_missing': 0}
2missing: {'queue_backlog': 0.0, 'queue_backlog_missing': 1}Reserve a fitted other category too. Well-formed new runner pools then have a stable representation until retraining evaluates them explicitly. Reject an absent runner_pool instead of hiding a feed failure inside the fallback bucket.
1known_runner_pools = {"a100-pool", "h100-pool"}
2
3def encode_runner_pool(value):
4 if not isinstance(value, str) or not value.strip():
5 raise ValueError("runner_pool is missing")
6 normalized = value.strip().casefold()
7 return normalized if normalized in known_runner_pools else "other"
8
9for runner_pool in ["a100-pool", "l4-pool", None]:
10 try:
11 print(f"{runner_pool!r} -> {encode_runner_pool(runner_pool)}")
12 except ValueError as error:
13 print(f"{runner_pool!r} -> blocked: {error}")1'a100-pool' -> a100-pool
2'l4-pool' -> other
3None -> blocked: runner_pool is missingCaps are contract choices, not universal constants. This example limits the influence of very old heartbeats. The fitted cap must travel with the model.
1max_heartbeat_age_hours = 180.0
2
3def cap_heartbeat_age(hours):
4 return min(float(hours), max_heartbeat_age_hours)
5
6for hours in [8, 180, 360]:
7 print(f"{hours} -> {cap_heartbeat_age(hours)}")18 -> 8.0
2180 -> 180.0
3360 -> 180.0A negative heartbeat age indicates broken event timing or a missing cutoff filter. Reject it instead of passing a surprising number into the model.
1from datetime import datetime
2
3def hours_since_last_heartbeat(last_heartbeat_at, prediction_time):
4 heartbeat = datetime.fromisoformat(last_heartbeat_at)
5 if heartbeat > prediction_time:
6 raise ValueError("last_heartbeat_at is after prediction_time")
7 return (prediction_time - heartbeat).total_seconds() / 3600
8
9prediction_time = datetime.fromisoformat("2026-05-01T09:00:00")
10try:
11 hours_since_last_heartbeat("2026-05-01T12:00:00", prediction_time)
12except ValueError as error:
13 print("blocked:", error)1blocked: last_heartbeat_at is after prediction_timeNow assemble those policies. The source record may contain post-job-SLA fields because labels need them later. The returned model row may contain only allowlisted feature keys.
1from datetime import datetime
2from math import isfinite
3
4allowed_feature_keys = {
5 "runner_pool",
6 "queued_minutes",
7 "hours_since_last_heartbeat",
8 "queue_backlog",
9 "queue_backlog_missing",
10 "priority_job",
11}
12known_runner_pools = {"a100-pool", "h100-pool"}
13future_only_keys = {"finished_at", "post_sla_escalation"}
14
15def make_features(row, prediction_time):
16 required_keys = {"queued_minutes", "last_heartbeat_at", "priority_job"}
17 missing_keys = sorted(required_keys - row.keys())
18 if missing_keys:
19 raise ValueError(f"missing required fields: {missing_keys}")
20
21 queued_minutes = float(row["queued_minutes"])
22 if not isfinite(queued_minutes) or queued_minutes <= 0:
23 raise ValueError("queued_minutes must be finite and positive")
24
25 runner_pool = row.get("runner_pool")
26 if not isinstance(runner_pool, str) or not runner_pool.strip():
27 raise ValueError("runner_pool is missing")
28 runner_pool = runner_pool.strip().casefold()
29
30 if not isinstance(row["priority_job"], bool):
31 raise ValueError("priority_job must be boolean")
32
33 last_heartbeat = datetime.fromisoformat(row["last_heartbeat_at"])
34 if last_heartbeat > prediction_time:
35 raise ValueError("last_heartbeat_at is after prediction_time")
36 heartbeat_age = min((prediction_time - last_heartbeat).total_seconds() / 3600, 180.0)
37
38 queue_backlog = row.get("queue_backlog")
39 if queue_backlog is not None:
40 queue_backlog = float(queue_backlog)
41 if not isfinite(queue_backlog) or queue_backlog < 0:
42 raise ValueError("queue_backlog must be finite and nonnegative")
43 features = {
44 "runner_pool": runner_pool if runner_pool in known_runner_pools else "other",
45 "queued_minutes": queued_minutes,
46 "hours_since_last_heartbeat": heartbeat_age,
47 "queue_backlog": 0.0 if queue_backlog is None else queue_backlog,
48 "queue_backlog_missing": int(queue_backlog is None),
49 "priority_job": int(row["priority_job"]),
50 }
51
52 blocked_fields = sorted(features.keys() - allowed_feature_keys)
53 if blocked_fields:
54 raise ValueError(f"unexpected feature fields: {blocked_fields}")
55 return features
56
57prediction_time = datetime.fromisoformat("2026-05-01T09:00:00")
58job = {
59 "job_id": "J-204",
60 "runner_pool": "a100-pool",
61 "queued_minutes": 42,
62 "queue_backlog": 18,
63 "priority_job": False,
64 "last_heartbeat_at": "2026-05-01T08:30:00",
65 "finished_at": "2026-05-03T14:00:00",
66 "post_sla_escalation": True,
67}
68
69features = make_features(job, prediction_time)
70print(features)
71print("future fields leaked:", sorted(features.keys() & future_only_keys))1{'runner_pool': 'a100-pool', 'queued_minutes': 42.0, 'hours_since_last_heartbeat': 0.5, 'queue_backlog': 18.0, 'queue_backlog_missing': 0, 'priority_job': 0}
2future fields leaked: []The contract now exercises its promises: required fields, finite positive queued minutes, missing-runner-pool rejection, boolean priority flag, future-heartbeat rejection, heartbeat-age cap, finite nonnegative queue backlog, missing-queue backlog indicator, unseen-runner-pool bucket, and output allowlist.
A model still needs a fixed vector order and fitted categorical mapping. Version both beside the model artifact.
1runner_pool_code = {"a100-pool": 0, "h100-pool": 1, "other": 2}
2feature_order = (
3 "runner_pool_code",
4 "queued_minutes",
5 "hours_since_last_heartbeat",
6 "queue_backlog",
7 "queue_backlog_missing",
8 "priority_job",
9)
10features = {
11 "runner_pool": "other",
12 "queued_minutes": 42.0,
13 "hours_since_last_heartbeat": 8.0,
14 "queue_backlog": 0.0,
15 "queue_backlog_missing": 1,
16 "priority_job": 0,
17}
18
19vector = [
20 runner_pool_code[features["runner_pool"]],
21 features["queued_minutes"],
22 features["hours_since_last_heartbeat"],
23 features["queue_backlog"],
24 features["queue_backlog_missing"],
25 features["priority_job"],
26]
27print("order:", feature_order)
28print("vector:", vector)1order: ('runner_pool_code', 'queued_minutes', 'hours_since_last_heartbeat', 'queue_backlog', 'queue_backlog_missing', 'priority_job')
2vector: [2, 42.0, 8.0, 0.0, 1, 0]An offline notebook might compute queue backlog by scanning a completed daily table. The service might read an hourly cache. Even when both columns are named queue_backlog, differences in freshness or aggregation can change predictions. This failure is training-serving skew.
Feast documents point-in-time joins that reproduce feature state at each historical entity timestamp, scanning backward only within the configured TTL.[2] It also documents online stores for low-latency serving, where only the latest feature values for each entity key are stored.[3] The tool isn't the lesson: the contract is. A model release must identify the feature definition and snapshot that produced its score.
Sample offline and online paths on the same entities before promoting a model. Matching dictionaries provide a small, readable parity receipt.
1def mismatches(offline, online):
2 keys = offline.keys() | online.keys()
3 return {key: (offline.get(key), online.get(key)) for key in keys if offline.get(key) != online.get(key)}
4
5offline = {"runner_pool": "a100-pool", "queue_backlog": 18.0, "priority_job": 0}
6online = {"runner_pool": "a100-pool", "queue_backlog": 18.0, "priority_job": 0}
7differences = mismatches(offline, online)
8print("mismatches:", differences)
9print("release allowed:", not differences)1mismatches: {}
2release allowed: TrueThe same check catches a stale cache or divergent transformation before users see different scores.
1def mismatches(offline, online):
2 keys = offline.keys() | online.keys()
3 return {key: (offline.get(key), online.get(key)) for key in keys if offline.get(key) != online.get(key)}
4
5offline = {"runner_pool": "a100-pool", "queue_backlog": 18.0, "priority_job": 0}
6online = {"runner_pool": "a100-pool", "queue_backlog": 0.0, "priority_job": 0}
7differences = mismatches(offline, online)
8print("mismatches:", differences)
9print("release allowed:", not differences)1mismatches: {'queue_backlog': (18.0, 0.0)}
2release allowed: FalseMonitor the contract before monitoring accuracy:
| Production check | Failure it catches | Action |
|---|---|---|
| null rate by feature | upstream feed disappeared | fail closed or fallback |
| unseen-category rate | runner-pool catalog changed | collect labels and retrain |
| freshness lag | online values are stale | pause promotions |
| offline/online parity sample | transformations disagree | repair feature path |
Freshness failures need an explicit product policy. This policy tries normal scoring, fallback, then abstention. Thresholds depend on product tolerance and must be versioned with the serving path.
1def scoring_policy(freshness_lag_minutes):
2 if freshness_lag_minutes <= 15:
3 return "normal scoring"
4 if freshness_lag_minutes <= 45:
5 return "fallback model"
6 return "abstain and alert"
7
8for lag in [8, 30, 90]:
9 print(f"{lag} minutes -> {scoring_policy(lag)}")18 minutes -> normal scoring
230 minutes -> fallback model
390 minutes -> abstain and alertRun build-feature-row.py, then make one change at a time:
"finished_at": row["finished_at"] to features. Confirm the output allowlist blocks release.last_heartbeat_at to 2026-05-01T12:00:00. Confirm future-event rejection.runner_pool to l4-pool. Confirm the returned category is other. Then remove runner_pool and confirm the contract blocks the row instead of hiding missing data inside that bucket.queue_backlog. Confirm value becomes 0.0 while queue_backlog_missing becomes 1.| Evidence | What a strong answer shows |
|---|---|
| prediction contract | identifies prediction time, allowed fields, labels, and missing-value meaning |
| leakage control | proves future-only events and post-cutoff heartbeats can't enter feature construction |
| parity plan | versions transformations and monitors online/offline disagreement |
| Symptom | Cause | Fix |
|---|---|---|
| Offline score is excellent, live accuracy collapses | future event entered features | enforce prediction timestamps and leakage gates |
| New runner_pool causes errors or silent zeros | categorical mapping wasn't versioned | reserve other and monitor its rate |
| Predictions shift after a data job rewrite | feature meaning changed without model release | version transformation and test parity |
| Feed outage looks like healthy operations | missing queue backlog was encoded as measured zero | preserve missing indicator or abstain |
Answer every question, then check your score. Score above 75% to mark this lesson complete.
8 questions remaining.
Questions and insights from fellow learners.