Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Friday's cluster ran 160 outbound batch jobs. Last Friday's same-weekday copy said 132. Is tomorrow a staffing problem, or just Friday?
Ranking and recommendation ordered documents for one query at one moment. Capacity planning looks forward in time. Forecasting predicts a later value. Anomaly detection asks whether an observation is unusual enough to inspect. Start with expected volume, then compare the actual count with that expectation. The leftover is the operational signal: unusual enough to inspect, not yet a root cause.

+/-3 band. Friday's observed 160 sits far above forecast 132, so the residual bar of +28 punches through the same band while Saturday and Sunday settle back.Start with an ordered batch-job series
A time series is a sequence of observations indexed by time. The series here is daily outbound batch-job counts for one model-serving cluster. Each row has a date, a batch-job count, and a flag for a planned model launch. The Friday 160 versus 132 gap is one row in that series.
The forecast horizon is how far ahead you predict. A one-day horizon answers tomorrow's staffing question. A seven-day horizon helps reserve capacity for the coming week. With a weekly seasonal naive baseline, those two horizons use the same numbers: from Sunday's origin, each of the next seven weekdays copies last week's matching day.
Build four weeks of daily counts. The weekday pattern repeats, but small changes keep the series realistic. The final Friday launch event creates a larger jump.
1from datetime import date, timedelta
2from hashlib import sha256
3from json import dumps
4from math import ceil, sqrt
5
6start = date(2026, 1, 5)
7weekly_pattern = [100, 112, 115, 118, 132, 82, 76]
8weekly_noise = [
9 [0, 0, 0, 0, 0, 0, 0],
10 [2, -2, 3, -2, 2, 2, 2],
11 [4, -2, 4, -2, 0, 1, 2],
12 [3, 1, 2, -1, 28, 2, 1],
13]
14
15rows = []
16for week, noise in enumerate(weekly_noise):
17 for weekday, (baseline, offset) in enumerate(zip(weekly_pattern, noise)):
18 rows.append(
19 {
20 "date": start + timedelta(days=7 * week + weekday),
21 "weekday": weekday,
22 "volume": baseline + offset,
23 "launch_event": week == 3 and weekday == 4,
24 }
25 )
26
27print("rows:", len(rows))
28print("first date:", rows[0]["date"])
29print("last date:", rows[-1]["date"])
30print("launch_event day:", next(row["date"] for row in rows if row["launch_event"]))1rows: 28
2first date: 2026-01-05
3last date: 2026-02-01
4launch_event day: 2026-01-30The fixture stays inspectable. Production evaluation should report separate series or slices by model-serving cluster, service tier, and forecast horizon.
Keep future rows out of training
Ordinary tabular prediction often starts with a random train/test split. That's unsafe for forecasting. A shuffled training set can contain January 31 while its test set contains January 6. A model evaluated that way has learned from the future relative to some test rows.
Compare an invalid shuffled split with a chronological split. The audit prints whether the latest training date reaches or passes the earliest test date.
1shuffled_train = [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26]
2shuffled_test = [index for index in range(len(rows)) if index not in shuffled_train]
3time_train = list(range(21))
4time_test = list(range(21, 28))
5
6def split_summary(name, train_indices, test_indices):
7 latest_train = max(rows[index]["date"] for index in train_indices)
8 earliest_test = min(rows[index]["date"] for index in test_indices)
9 future_leak = latest_train >= earliest_test
10 print(
11 f"{name}: latest_train={latest_train} "
12 f"earliest_test={earliest_test} future_leak={future_leak}"
13 )
14
15split_summary("shuffled", shuffled_train, shuffled_test)
16split_summary("time-aware", time_train, time_test)
17assert max(time_train) < min(time_test)1shuffled: latest_train=2026-01-31 earliest_test=2026-01-06 future_leak=True
2time-aware: latest_train=2026-01-25 earliest_test=2026-01-26 future_leak=FalseHyndman and Athanasopoulos call this time-series cross-validation, or evaluation on a rolling forecasting origin: each test observation occurs after the observations used to make that forecast.[1] You can expand the training history or keep a rolling window. Either way, the time direction stays intact.
Why isn't a random split acceptable just because every row belongs to the same cluster?
Answer
Cluster identity doesn't remove time order. A shuffled split can train on later demand, launch events, and seasonal behavior before evaluating earlier dates. Production only predicts later periods from earlier information.
Establish a same-weekday baseline
Batch-job volume has seasonality when a pattern repeats at a known calendar frequency. Saturday volume stays lower than weekday volume. Copying yesterday into tomorrow treats that weekend lull as the new baseline.
Monday, January 26 actually ran 103 jobs. Sunday was 78. Last Monday was 104.
| Method | What it copies | Monday forecast | Actual | Error |
|---|---|---|---|---|
| naive | yesterday (Sunday 78) | 78 | 103 | +25 |
| seasonal naive | last Monday (104) | 104 | 103 | -1 |
A seasonal naive forecast reuses the observation from the matching previous season. For daily batch-job data with a weekly pattern, forecast Monday from last Monday, Tuesday from last Tuesday, and so on.[1] Hyndman and Athanasopoulos treat this method as a benchmark: a fancier model has to beat it on later windows before the extra machinery is worth running.
| Target day | Same day last week | Forecast | Actual | Forecast error |
|---|---|---|---|---|
| Monday, Jan 26 | Monday, Jan 19 | 104 | 103 | -1 |
| Friday, Jan 30 | Friday, Jan 23 | 132 | 160 | +28 |
The Friday arithmetic is 160 - 132 = 28. Positive error means the observed count exceeded the forecast. Negative error means the forecast was too high.
Compute the complete holdout week. Each forecast reads the value exactly seven rows earlier.
1holdout = list(range(21, 28))
2
3def weekly_lag(index):
4 return rows[index - 7]["volume"]
5
6for index in holdout:
7 forecast = weekly_lag(index)
8 actual = rows[index]["volume"]
9 error = actual - forecast
10 print(
11 f"{rows[index]['date']} forecast={forecast} "
12 f"actual={actual} error={error:+d}"
13 )12026-01-26 forecast=104 actual=103 error=-1
22026-01-27 forecast=110 actual=113 error=+3
32026-01-28 forecast=119 actual=117 error=-2
42026-01-29 forecast=116 actual=117 error=+1
52026-01-30 forecast=132 actual=160 error=+28
62026-01-31 forecast=83 actual=84 error=+1
72026-02-01 forecast=78 actual=77 error=-1Saturday shows the same trap in the other direction. A naive one-step forecast would copy Friday's launch (160) into Saturday and miss by -76. The weekly lag copies last Saturday (83) and misses by +1.
Why is last Monday a better forecast for this Monday than yesterday's Sunday count?
Answer
Sunday is a weekend lull. Copying 78 into Monday treats that dip as the new baseline and misses by 25 jobs. Last Monday already lived in the weekday regime, so the error is 1 job.
Measure forecast error in batch jobs
Mean absolute error (MAE) averages the size of each mistake without letting positive and negative errors cancel:[1]
Here is the number of evaluated days, is observed volume on day , and is the forecast for that day. For the holdout week, the absolute weekly-lag errors are 1, 3, 2, 1, 28, 1, 1. Their sum is 37, so MAE is 37 / 7 = 5.3 batch jobs.
MAE stays in batch jobs, which operations can read. It also treats a 28-job miss and a 28-job surplus as equal. Underforecasting a capacity spike can cost more than overforecasting by the same amount, so record asymmetric business cost when the decision requires it.
Calculate overall MAE and normal-day MAE separately. The second metric excludes the known launch-event day so the weekday baseline isn't hidden by one planned spike. Then compare the same week against copying yesterday.
1def mean_absolute_error(errors):
2 return sum(abs(error) for error in errors) / len(errors)
3
4holdout_errors = [
5 rows[index]["volume"] - weekly_lag(index)
6 for index in holdout
7]
8normal_errors = [
9 rows[index]["volume"] - weekly_lag(index)
10 for index in holdout
11 if not rows[index]["launch_event"]
12]
13
14naive_errors = [
15 rows[index]["volume"] - rows[index - 1]["volume"]
16 for index in holdout
17]
18
19print("holdout errors:", holdout_errors)
20print("holdout MAE:", round(mean_absolute_error(holdout_errors), 1))
21print("normal-day MAE:", round(mean_absolute_error(normal_errors), 1))
22print("naive holdout MAE:", round(mean_absolute_error(naive_errors), 1))1holdout errors: [-1, 3, -2, 1, 28, 1, -1]
2holdout MAE: 5.3
3normal-day MAE: 1.5
4naive holdout MAE: 23.6The weekly lag's holdout MAE is 5.3 batch jobs. Yesterday's lag is 23.6 on the same week. Hyndman and Athanasopoulos recommend this comparison before you spend complexity: if a candidate can't beat last week's same weekday on later windows, don't ship it.[1]
Backtest more than one future window
A single future week is fragile evidence. Rolling-origin evaluation moves the forecast origin forward and measures several later windows. In this fixture, the first fold evaluates January 19 through January 25. The second evaluates January 26 through February 1.
Run both folds. The seasonal baseline can forecast each day because each fold starts after at least one full week of history.
1folds = []
2for origin in (14, 21):
3 indices = list(range(origin, origin + 7))
4 errors = [
5 rows[index]["volume"] - weekly_lag(index)
6 for index in indices
7 ]
8 fold = {
9 "start": rows[indices[0]]["date"],
10 "end": rows[indices[-1]]["date"],
11 "mae": round(mean_absolute_error(errors), 1),
12 }
13 folds.append(fold)
14 print(f"{fold['start']} to {fold['end']}: MAE={fold['mae']}")12026-01-19 to 2026-01-25: MAE=0.9
22026-01-26 to 2026-02-01: MAE=5.3Backtest by model-serving cluster, service tier, weekday, and launch-event status. A global average can hide one cluster that routinely underforecasts peak demand. The target must also match the decision: submitted job count helps GPU reservation, failed job count helps incident response, and queue-delay count helps operator notification.
Distinguish forecast errors from residuals
Two related quantities often get called residuals. Hyndman and Athanasopoulos separate them on two axes: which rows they use, and how many steps ahead they look.[1]
| Quantity | Which rows | How far ahead | Formula | What it tells you |
|---|---|---|---|---|
| residual | training data after fitting | usually one-step fitted values | observed value minus fitted value | whether the model captured known history |
| forecast error | later data, after the forecast was issued | one-step or multi-step | observed value minus forecast | whether the model predicted unseen future data |
Friday's +28 is a forecast error: Friday's observed volume wasn't available when the forecast was issued. This weekly-lag baseline has no fitted parameters, so a one-step training residual uses the same copy-last-week rule. What still changes is which rows you score: known history versus a later window. An operations team may casually say "residual alert," but the stored artifact should name the measured quantity.

Calibrate an error band from earlier days
A point forecast such as 132 batch jobs doesn't express uncertainty. A prediction interval pairs a forecast with a lower and upper bound intended to cover a stated proportion of future outcomes. Hyndman and Athanasopoulos treat the interval as part of the forecast, not a chart decoration, and they evaluate it on later data.[1]
When one-step errors look roughly Gaussian, their textbook 95% interval is , where is the residual standard deviation. When that shape is doubtful, they fall back to resampling past errors. This lab uses a smaller cousin of that second idea: the 95th percentile of earlier absolute one-day errors, applied as a symmetric band.
Calibrate on January 12 through January 25, before the January 26 holdout starts. Then measure coverage again on the later holdout week. Coverage is the share of days whose absolute error stays inside the band. A band that always covers by growing infinitely wide isn't useful, so keep the width in batch jobs next to the coverage number.
1calibration = list(range(7, 21))
2calibration_errors = [
3 rows[index]["volume"] - weekly_lag(index)
4 for index in calibration
5]
6target_coverage = 0.95
7interval_policy = "empirical-absolute-error-p95-v1"
8
9def nearest_rank_quantile(values, probability):
10 ordered = sorted(values)
11 rank = max(0, ceil(probability * len(ordered)) - 1)
12 return ordered[rank]
13
14error_band = nearest_rank_quantile(
15 [abs(error) for error in calibration_errors],
16 probability=target_coverage,
17)
18calibration_coverage = sum(
19 abs(error) <= error_band
20 for error in calibration_errors
21) / len(calibration_errors)
22holdout_coverage = sum(
23 abs(error) <= error_band
24 for error in holdout_errors
25) / len(holdout_errors)
26
27residual_sd = sqrt(sum(error * error for error in calibration_errors) / len(calibration_errors))
28normal_half_width = round(1.96 * residual_sd, 1)
29
30assert max(calibration) < min(holdout)
31print("calibration errors:", calibration_errors)
32print("95% empirical absolute-error band:", error_band)
33print("residual sd:", round(residual_sd, 2))
34print("normal 95% half-width:", normal_half_width)
35print("in-sample calibration coverage:", round(calibration_coverage, 3))
36print("later holdout coverage:", round(holdout_coverage, 3))1calibration errors: [2, -2, 3, -2, 2, 2, 2, 2, 0, 1, 0, -2, -1, 0]
295% empirical absolute-error band: 3
3residual sd: 1.75
4normal 95% half-width: 3.4
5in-sample calibration coverage: 1.0
6later holdout coverage: 0.857The empirical band is +/-3 batch jobs. The normal-theory cousin is about +/-3.4. Both widths agree that Friday's +28 isn't ordinary noise. Neither width is a production 95% guarantee from fourteen calibration errors. In-sample coverage is 1.000 because the band was chosen on those same errors. Later holdout coverage is 6 / 7 = 0.857 because Friday falls outside. A production system should measure out-of-sample coverage across rolling windows, forecast horizons, model-serving clusters, and important demand slices, and it should keep the interval narrow enough to act on.
Why doesn't the tiny +/-3 band prove that future batch-job counts will land inside it 95% of the time?
Answer
It comes from only fourteen earlier errors. In-sample calibration coverage tells you how the band was chosen; later holdout coverage checks whether it transferred to unseen days. One holdout week still isn't enough. A production interval needs enough history plus out-of-sample coverage checks across rolling windows, forecast horizons, and important slices.
Turn an unusual error into a review artifact
An anomaly is an observation unusual enough to inspect. It isn't proof that a cluster, pipeline, or model failed. Friday's jump could come from a planned model launch, duplicated event ingestion, a large backfill, or genuine demand growth.
Apply the empirical band to the held-out week. The alert stores enough context to reproduce the comparison.
1forecast_version = "weekly-lag-v1"
2training_cutoff = str(rows[20]["date"])
3owner = "capacity-ops"
4
5alerts = []
6for index in holdout:
7 forecast = weekly_lag(index)
8 actual = rows[index]["volume"]
9 forecast_error = actual - forecast
10 if abs(forecast_error) > error_band:
11 alerts.append(
12 {
13 "series": "fc-a.batch_jobs",
14 "forecast_version": forecast_version,
15 "training_cutoff": training_cutoff,
16 "interval_policy": interval_policy,
17 "date": str(rows[index]["date"]),
18 "forecast": forecast,
19 "actual": actual,
20 "forecast_error": forecast_error,
21 "lower": forecast - error_band,
22 "upper": forecast + error_band,
23 "launch_event": rows[index]["launch_event"],
24 "action": "review",
25 "owner": owner,
26 "resolution": "pending",
27 }
28 )
29
30for alert in alerts:
31 print(alert)1{'series': 'fc-a.batch_jobs', 'forecast_version': 'weekly-lag-v1', 'training_cutoff': '2026-01-25', 'interval_policy': 'empirical-absolute-error-p95-v1', 'date': '2026-01-30', 'forecast': 132, 'actual': 160, 'forecast_error': 28, 'lower': 129, 'upper': 135, 'launch_event': True, 'action': 'review', 'owner': 'capacity-ops', 'resolution': 'pending'}The baseline caught Friday because 160 falls outside [129, 135]. The launch-event flag doesn't erase the event. It changes the first investigation step.
Route alerts with context. A planned model launch still deserves capacity review, but it shouldn't automatically create an incident page.
1def route_alert(alert):
2 if alert["launch_event"]:
3 return "review planned model launch capacity"
4 return "page unexpected batch-job spike"
5
6for alert in alerts:
7 print(alert["date"], "->", route_alert(alert))12026-01-30 -> review planned model launch capacityLog the forecast version, training cutoff, interval policy version, observed value, error, known-event flags, owner, and eventual resolution. That receipt helps operations act now and helps future model reviews learn from the alert.
Evaluate alert usefulness separately
Low MAE doesn't guarantee useful alerts. An alert threshold can still page too often or miss disruptions. Once reviewers label whether each historical alert was actionable, measure alert precision and recall:
| Metric | Question |
|---|---|
| alert precision | Of the alerts sent to review, how many were actionable? |
| alert recall | Of the actionable disruptions, how many did the policy surface? |
Compute both metrics on a five-event resolution fixture. The policy surfaced two useful events, sent one noisy alert, and missed one disruption.
1resolved_events = [
2 {"alert": True, "actionable": True},
3 {"alert": True, "actionable": False},
4 {"alert": False, "actionable": True},
5 {"alert": False, "actionable": False},
6 {"alert": True, "actionable": True},
7]
8
9true_positives = sum(event["alert"] and event["actionable"] for event in resolved_events)
10false_positives = sum(event["alert"] and not event["actionable"] for event in resolved_events)
11false_negatives = sum(not event["alert"] and event["actionable"] for event in resolved_events)
12
13def rate_or_none(numerator, denominator):
14 return round(numerator / denominator, 3) if denominator else None
15
16alert_precision = rate_or_none(true_positives, true_positives + false_positives)
17alert_recall = rate_or_none(true_positives, true_positives + false_negatives)
18print("alert precision:", alert_precision)
19print("alert recall:", alert_recall)1alert precision: 0.667
2alert recall: 0.667Tune the policy against operational cost. A missed cluster disruption and a noisy review ticket don't have equal consequences. If a denominator is zero, report None: no evidence isn't the same as measured failure. Page only when urgency warrants interruption; otherwise queue a review with the same evidence.
Reproduce a data-quality anomaly
Not every spike represents demand. A duplicated telemetry event can inflate a count before forecasting code sees it. Reproduce that failure with three raw telemetry rows, two of which describe the same batch job event.
1raw_job_events = [
2 {"event_id": "job-001", "job_id": "batch-100", "type": "started"},
3 {"event_id": "job-001", "job_id": "batch-100", "type": "started"},
4 {"event_id": "job-002", "job_id": "batch-101", "type": "started"},
5]
6unique_events = {
7 event["event_id"]: event
8 for event in raw_job_events
9}
10deduplication_policy = "telemetry-event-id-v1"
11duplicate_rows = len(raw_job_events) - len(unique_events)
12
13print("naive started-job count:", len(raw_job_events))
14print("deduplicated started-job count:", len(unique_events))
15print("duplicate rows:", duplicate_rows)1naive started-job count: 3
2deduplicated started-job count: 2
3duplicate rows: 1An anomaly review should inspect input quality before retraining a model. Retraining on duplicated counts would teach the model to copy a pipeline bug. This tiny dictionary deduper keeps one row because the repeated payloads match. If two rows reuse an event ID but disagree, quarantine the conflict instead of silently choosing one.
Why shouldn't the alert policy declare an incident as soon as a count crosses the error band?
Answer
The forecast error proves that the observation is unusual relative to the baseline. It doesn't identify the cause. Known launch events, duplicated ingestion, and genuine demand shifts require different responses.
Gate and publish a forecast candidate
A minimal release gate should prove time order, calibration order, normal-day accuracy, interval bookkeeping, contextualized alerts, and assigned ownership. These checks don't prove the baseline is production-ready, but they prevent avoidable mistakes from reaching a capacity workflow.
Run the gate.
1gates = {
2 "time_order": max(time_train) < min(time_test),
3 "calibration_before_holdout": max(calibration) < min(holdout),
4 "normal_day_mae": mean_absolute_error(normal_errors) <= 3.0,
5 "calibration_coverage_recorded": 0.0 <= calibration_coverage <= 1.0,
6 "holdout_coverage_recorded": 0.0 <= holdout_coverage <= 1.0,
7 "interval_policy_versioned": bool(interval_policy),
8 "deduplication_policy_versioned": bool(deduplication_policy),
9 "alerts_have_context": all(
10 {
11 "series", "forecast_version", "training_cutoff", "interval_policy",
12 "date", "forecast", "actual", "forecast_error", "lower", "upper",
13 "launch_event", "action", "owner", "resolution",
14 } <= alert.keys()
15 for alert in alerts
16 ),
17 "owner_assigned": bool(owner),
18}
19
20for name, passed in gates.items():
21 print(f"{name}: {passed}")
22print("release gate:", all(gates.values()))1time_order: True
2calibration_before_holdout: True
3normal_day_mae: True
4calibration_coverage_recorded: True
5holdout_coverage_recorded: True
6interval_policy_versioned: True
7deduplication_policy_versioned: True
8alerts_have_context: True
9owner_assigned: True
10release gate: TruePublish a receipt. The hash lets a later monitoring job identify the exact baseline, interval, and input policies it evaluates.
1receipt = {
2 "series": "fc-a.batch_jobs",
3 "forecast": forecast_version,
4 "training_cutoff": training_cutoff,
5 "interval_policy": {
6 "version": interval_policy,
7 "target_coverage": target_coverage,
8 "absolute_error_band": error_band,
9 "calibration_window": [str(rows[7]["date"]), str(rows[20]["date"])],
10 "calibration_coverage": round(calibration_coverage, 3),
11 "holdout_window": [str(rows[21]["date"]), str(rows[27]["date"])],
12 "holdout_coverage": round(holdout_coverage, 3),
13 },
14 "input_policy": {
15 "deduplication": deduplication_policy,
16 },
17 "evaluation": {
18 "normal_day_mae": round(mean_absolute_error(normal_errors), 1),
19 "alert_count": len(alerts),
20 },
21 "alerts": alerts,
22 "release_checks": gates,
23 "owner": owner,
24 "status": "candidate_for_shadow" if all(gates.values()) else "blocked",
25}
26payload = dumps(receipt, sort_keys=True, separators=(",", ":"))
27
28print("status:", receipt["status"])
29print("evaluation:", receipt["evaluation"])
30print("holdout coverage:", receipt["interval_policy"]["holdout_coverage"])
31print("receipt sha256:", sha256(payload.encode()).hexdigest()[:12])1status: candidate_for_shadow
2evaluation: {'normal_day_mae': 1.5, 'alert_count': 1}
3holdout coverage: 0.857
4receipt sha256: a40b84aed22bThe receipt keeps calibration evidence separate from later holdout evidence and binds the policies that produced both. Passing these checks earns shadow evaluation, not a production rollout. The hash is what a later monitoring job should look up when it asks whether live inputs, delayed labels, or a newer candidate should replace this baseline.
When the policy breaks
| Symptom | Cause | Fix |
|---|---|---|
| Test results collapse at launch | random split leaked future behavior | use chronological rolling-origin validation |
| Alerts fire every Monday | yesterday's Sunday lull became Monday's forecast | start with a same-weekday baseline |
| Interval looks precise but misses peaks | band was calibrated in sample or on too little history | measure out-of-sample coverage by horizon and slice |
| Promotion creates an incident page | alert threshold has no business context | route known events to capacity review |
| Model learns a fake demand jump | duplicated events entered training rows | deduplicate and audit ingestion before retraining |
| Operations ignores alerts | receipt lacks ownership or resolution | log policy version, context, owner, and outcome |