learn-timeseries-forecasting-with-phoebe / Builder session 9 of 10
Learn Timeseries Forecasting with Phoebe · Builder track · Session 9 of 10

Productionizing: from a fitted model to a forecast people use

A backtested model in a notebook is worth nothing until someone acts on its numbers. This session ships the forecast: the retrain-then-serve pattern that makes forecasting a scheduled batch job, a data pipeline that lands each new day cleanly, a store that turns serving into a lookup, and the tiny API the dashboard reads. A great model behind a stale or confusing dashboard delivers zero value - so we treat the handoff as part of the model.

🟠 Builder track Practitioners: analysts · data scientists · engineers Python 3.10+ · a notebook ~45 min
0-3 · Recap 3-18 · Batch serving + retrain cadence 18-42 · Build-along: refit-and-store + API 42-45 · Q&A
Part 0

You have a model. Now ship it.

Sessions b1-b8 took Lumen's daily sessions from a raw series to a backtested model with honest prediction intervals. But the model still lives in your notebook. Production forecasting is almost never a live model behind every page request - it is a batch job: refit on a schedule, write the forecast and its interval to a store, and let the business read that store. This session builds exactly that pipeline and the contract that hands it to engineering.

Live - presented in session Self-study - read after class ★ Run it now (Python) Sources covered (cited)
★ What you walk out with today The retrain-then-serve mental model, a scheduled refit-and-store routine that ingests the latest day and writes forecast + interval to a store, a minimal FastAPI endpoint that serves that store as JSON, and a clear DS-to-engineering contract (horizon, granularity, interval) so nobody guesses what the numbers mean.
Part 1 · covers industry practice

Forecasting is a batch job, not a live model 7 min live

A classifier scores each request as it arrives. A forecast does not work that way: the answer for the next 14 days barely changes minute to minute, and recomputing it on every dashboard load is pure waste. The production pattern is retrain-then-serve - a scheduled job refits the model as new data lands, writes the forecast and its prediction interval to a store, and serving becomes a cheap lookup. Offline you refit; online you serve.

Offline (scheduled) writes the forecast · Online (fast) only reads it New daily data one row lands / day Scheduled refit ETS / SARIMA, cron Store forecast + interval table / parquet / JSON Forecast API /forecast Dashboard the business OFFLINE - runs on a schedule, refits, writes once ONLINE - a cheap lookup on every dashboard load Serving is a lookup, not a model call - the heavy refit runs once per schedule, away from the user. Store the forecast AND its prediction interval - never ship a bare point number to the business. The store (a warehouse table here stood in by JSON / parquet) is the single source of truth both API and dashboard read.
🔍 Click to zoom - new data → scheduled refit → store → API → dashboard
LiveThe data pipeline that lands each new day cleanly3 min▶

The refit is only as good as the day you feed it. The b1 resample discipline is not a notebook nicety - in production it is the ingest contract:

  • Land, then resample. Raw events arrive irregular. The pipeline resamples to the same fixed daily grid the model was trained on - the same frequency, every run.
  • No silent gaps. A missing day is not a zero. Fill, interpolate, or flag it explicitly, exactly as in b1, before it reaches the model.
  • Idempotent. Re-running yesterday's job must produce the same series, not a double count. Key on the date.
Real world

The stale-data failure is silent. A model can keep producing plausible forecasts long after the upstream feed broke - it just extrapolates the last good day. The pipeline must check freshness (did today's row actually arrive?) and refuse to serve a forecast built on stale input rather than quietly mislead the business.

Self-studyPersisting the model vs persisting the forecast2 min read▶

Two things can be saved, and they answer different questions:

  • The forecast (values + interval, per date) - this is what the business reads. Persist it every run; it is small and cheap to serve.
  • The fitted model (its parameters) - persist it if refitting is slow or if you need to reproduce a past forecast for audit. For light models like ETS, refit-from-scratch each run is often simpler and safer than un-pickling a stale object across library versions.

Rule of thumb: always persist the forecast; persist the model only when you have a concrete reason (cost, audit, reproducibility).

Part 2 · covers industry practice

Retrain cadence: how often to refit 5 min live

"Retrain" here means refit on the newest window of data so the forecast reflects the latest days. Cadence is a trade-off. Refit too rarely and the forecast goes stale - it misses a new trend break or a shifted season. Refit too often and you pay in compute cost and churn - the numbers jitter run to run, and the business loses trust in a forecast that changes every hour. The right cadence tracks how fast the series actually moves and how often the decision is made.

Pick the cadence that matches how fast the series moves and how often the decision runs Too rare (e.g. quarterly) Forecast goes STALE. Misses new trend breaks and shifted seasons. Cheap, but wrong. Matched (e.g. daily / weekly) Cadence = decision cadence. Fresh enough to track the series, stable enough to trust. The default to reach for. Too often (e.g. hourly) Cost + CHURN. Numbers jitter run to run; the business loses trust in a forecast that never sits still. Start by refitting on the decision's cadence; let monitoring (b10) fire an off-schedule refit on drift. Two clocks: a scheduled refit (this session) and an event-driven refit fired by drift (next session). Most teams run both. A daily decision rarely needs an hourly model. Match the clock to the choice being made.
🔍 Click to zoom - too rare = stale, too often = cost and churn, matched = trust
LiveThe DS-to-engineering handoff is a contract3 min▶

The most expensive production bugs are not model bugs - they are handoff bugs. The forecast crosses from data science to engineering, and if the meaning of the numbers is ambiguous, the dashboard will quietly show the wrong thing. Write the contract down:

Contract termWhat it pins down
HorizonHow many periods ahead (H = 14 days). The dashboard must not plot past it.
GranularityDaily. Not "days" some places and "weeks" others.
IntervalEvery point ships with a lower and upper bound and its coverage level (e.g. 90%).
As-of / freshnessThe last actual date the forecast was built from, so the dashboard can show "as of".
Units + timezoneSessions, in the store's timezone. Ambiguity here silently doubles or shifts a day.
Ship the interval, always The single honesty rail of this whole course survives into production: never hand the business a bare point. Every stored row carries yhat, lower, upper. A dashboard that plots the point without the band is showing false confidence.
Demo 1 of 2

A scheduled refit-and-store routine ★ 14 min · everyone builds

Build the offline half of the pattern: a function that ingests the latest daily data, refits Holt-Winters, and writes the forecast plus its prediction interval to a store. The store here is a dict / JSON standing in for a warehouse table - swap it for your real sink later.

Rebuild the Lumen daily series from b1 (trend + weekly seasonality + noise), on a clean daily datetime index.

Write one function: ingest the newest data, refit ETS/Holt-Winters, forecast H days with an interval, and write rows to the store.

Run it. The store now holds one row per future date with yhat, lower, upper, and an as_of stamp.

Persist the store to JSON - this is what the API will read. Serving is now a lookup.

★ Run it now (Python) - refit and store the forecast + intervalimport pandas as pd, numpy as np, json from statsmodels.tsa.holtwinters import ExponentialSmoothing def build_series() -> pd.Series: # Lumen daily sessions: trend + weekly seasonality + noise (from b1) idx = pd.date_range("2026-01-01", periods=140, freq="D") i = np.arange(140) season = np.array([40, 55, 60, 50, 30, -120, -140]) # weekday shape y = 1000 + 2.5*i + season[i % 7] + 14*np.sin(i*1.7) return pd.Series(np.round(y), index=idx, name="sessions").asfreq("D") def refit_and_store(store: dict, m: int = 7, H: int = 14, z: float = 1.645) -> dict: s = build_series() # ingest latest clean daily data assert s.isna().sum() == 0, "gap in series - fix ingest before refit" model = ExponentialSmoothing( s, trend="add", seasonal="add", seasonal_periods=m ).fit() yhat = model.forecast(H) # point forecast, H days ahead resid = s - model.fittedvalues # in-sample residual spread sigma = float(np.sqrt((resid**2).mean())) lower, upper = yhat - z*sigma, yhat + z*sigma # ~90% band (z=1.645) as_of = str(s.index.max().date()) store["sessions"] = { "as_of": as_of, "horizon": H, "granularity": "D", "coverage": 0.90, "rows": [ {"date": str(d.date()), "yhat": round(float(p), 1), "lower": round(float(lo), 1), "upper": round(float(hi), 1)} for d, p, lo, hi in zip(yhat.index, yhat.values, lower.values, upper.values) ], } return store STORE: dict = {} STORE = refit_and_store(STORE) json.dump(STORE, open("forecast_store.json", "w"), indent=2) # stands in for a warehouse table print("as_of:", STORE["sessions"]["as_of"], "| rows:", len(STORE["sessions"]["rows"])) print(STORE["sessions"]["rows"][0])
Schedule it, do not babysit it In production this function is the body of a scheduled job - cron, Airflow, a Cloud Scheduler task, whatever your stack uses. It runs unattended on the cadence from Part 2, overwrites the store, and stamps as_of. The interval here is a simple residual-based band; b8's backtested coverage is the honest way to size it.
Demo 2 of 2

A minimal forecast API the dashboard reads ★ 8 min · build your own

Build the online half: a tiny FastAPI service that reads the store and returns the forecast + interval as JSON. This is the contract the dashboard consumes - no model runs here, it is a lookup. The dashboard / UI part is called out separately: that layer just draws the JSON.

Load the JSON store the refit job wrote. The API never fits a model - it only reads.

Expose GET /forecast/{metric} returning as_of, horizon, granularity, coverage, and the rows (date, yhat, lower, upper).

Return a clean 404 when the metric is unknown, so the dashboard fails loud, not silent.

The dashboard (a separate UI layer) calls this endpoint and plots the band around the line.

★ Run it now (Python) - forecast API sketch (FastAPI)# pip install fastapi uvicorn ; run: uvicorn serve:app --reload import json from fastapi import FastAPI, HTTPException app = FastAPI(title="Lumen forecast API") def load_store() -> dict: # reads the store the scheduled refit job wrote (Demo 1) return json.load(open("forecast_store.json")) @app.get("/forecast/{metric}") def get_forecast(metric: str): store = load_store() if metric not in store: # unknown metric -> loud 404 raise HTTPException(status_code=404, detail=f"no forecast for '{metric}'") entry = store[metric] return { # the DS->eng contract, as JSON "metric": metric, "as_of": entry["as_of"], # last actual date used "horizon": entry["horizon"], # periods ahead "granularity": entry["granularity"], # "D" "coverage": entry["coverage"], # 0.90 interval "forecast": entry["rows"], # [{date, yhat, lower, upper}, ...] } # ---- Dashboard / UI layer (separate service, sketch only) ---- # A dashboard calls GET /forecast/sessions and plots: # - the yhat line (green, the forecast) # - the lower..upper band (shaded, the interval) <- never omit this # - an "as of {as_of}" freshness label so a stale feed is visible # Example client call the UI makes: # import requests # data = requests.get("http://localhost:8000/forecast/sessions").json()
Real world

The dashboard is where value is won or lost. A perfectly backtested model behind a dashboard that hides the interval, omits the "as of" date, or plots past the horizon will mislead the business - and they will (rightly) stop trusting the forecast. Treat the API contract and the dashboard as part of the model's accuracy, not an afterthought. Buying a vendor forecast product still leaves this last mile to you.

Homework

Try it yourself - this week ◐ 30-45 min total

Source material

Sources covered (cited)

Productionizing a forecast is industry practice rather than a single paper; it builds on the modelling canon this track has taught. This session covers:

✓Industry practice - batch serving, retrain cadence, forecast API + dashboard, DS/eng handoffParts 1-2 + both demos · the retrain-then-serve pattern and the contract
◐Forecasting: Principles and Practice, 3rd ed (Hyndman & Athanasopoulos, FPP3)honesty rails carried forward · always serve a prediction interval, not a bare point
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Why is production forecasting usually a batch job rather than a live model call?

Retrain-then-serve: a scheduled job refits and writes the forecast + interval to a store; serving is a lookup. Recomputing per request is pure waste.

2 · What is the risk of retraining too often?

Too rare = stale; too often = cost and churn. Match cadence to how fast the series moves and how often the decision is made.

3 · What must the DS-to-engineering contract pin down?

Ambiguous handoffs are the most expensive production bugs. The dashboard must know the horizon, granularity, interval + coverage, and freshness - and never plot a bare point.

Builder session 9 cheat sheet · pin this

Retrain-then-serverefit on a schedule, write forecast + interval to a store, serve a cheap lookup. Offline refits; online reads.
Batch, not livethe next-14-days answer barely moves minute to minute. Don't recompute per dashboard load.
Pipeline disciplineland, resample to the trained grid, no silent gaps, idempotent. The b1 clean-series rule, in production.
Retrain cadencetoo rare = stale, too often = cost + churn. Start on the decision's cadence.
Persist the forecastalways store values + interval. Persist the model only for cost, audit, or reproducibility.
Freshness guardrefuse to serve on stale input. A broken feed extrapolates silently - check the newest day arrived.
The contracthorizon · granularity · interval + coverage · as-of · units · timezone. Written down, not inferred.
Next sessionb10 - monitoring: track error as actuals arrive, detect drift, and close the loop with a retrain trigger.