Epidemiological Time Series Forecasting & Plotting

Integrating epidatpy (Delphi Epidata V5 API) with omnicast

This tutorial demonstrates how to fit diverse statistical forecasters with omnicast, evaluate models via rolling-origin backtests, and visualize results across multiple forecasting scenarios.

Setup & Environment

Install the required libraries:

pip install -e . git+https://github.com/cmu-delphi/epidatpy.git
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns
from epidatpy import EpiDataContext, EpiRange

from omnicast import (
    AutoForecaster,
    backtest,
    plot_metric_by_horizon,
)
from omnicast.models import (
    AutoARIMAForecaster,
    ETSForecaster,
    NaiveForecaster,
    ThetaForecaster,
)

# Set plot styling defaults
sns.set_theme(style="whitegrid")
plt.rcParams["figure.figsize"] = (10, 5)

Ingesting Data via epidatpy

We query the Delphi V5 API snapshot endpoint for inpatient claims admissions (claims_inpatient_adm_pct_claims_flu) in California (ca).

epidata = EpiDataContext()

# Query Delphi V5 snapshot endpoint directly
df = epidata.epidata_snapshot(
    source="claims_inpatient",
    signals="claims_inpatient_adm_pct_claims_flu",
    geo_type="state",
    geo_values="ca",

    reference_time=EpiRange(20240101, 20260215),
).df()

# Convert reference_time to weekly time series
df["reference_time"] = pd.to_datetime(df["reference_time"])
y = (
    df.set_index("reference_time")["value"]
    .sort_index()
    .asfreq("D")
    .ffill()
    .resample("W-SUN")
    .mean()
    .astype(float)
    .rename("Weekly Inpatient Claims (% flu)")
)

display(y.head())
/Users/javiermtz/Library/Mobile Documents/com~apple~CloudDocs/code-projects/omnicast/.venv/lib/python3.12/site-packages/epidatpy/_auth.py:9: UserWarning: DELPHI_EPIDATA_KEY environment variable not set. Please set it to your Epidata API key to avoid rate limits. You can get a free key at: https://api.delphi.cmu.edu/epidata/admin/registration_form
  warnings.warn(
reference_time
2024-01-07    0.453684
2024-01-14    0.236010
2024-01-21    0.160317
2024-01-28    0.195574
2024-02-04    0.152667
Freq: W-SUN, Name: Weekly Inpatient Claims (% flu), dtype: float64

A First Forecast with Uncertainty

Hold out the last 8 weeks, fit on the rest, and forecast that window with .predict(horizon, level=[80, 95]). Plotting the ForecastResult with .plot(observed=y) draws the point forecast and its nested 80% / 95% bands against the full series, so the forecast can be eyeballed against the held-out truth. The forecast line joins the last training point.

# One model's forecast with nested 80% and 95% uncertainty bands
y_train, y_test = y.iloc[:-8], y.iloc[-8:]
ets_fc = ETSForecaster().fit(y_train).predict(horizon=8, level=[80, 95])
ax = ets_fc.plot(
    observed = y,
    title="ETSForecaster: 8-Week Forecast with 80% and 95% Uncertainty Bands",
)
ax.set_xlabel("Date")
ax.set_ylabel("% COVID-19 Inpatient Claims")
ax.grid(True)
plt.show()
../_images/ac638d4c078137351aece55cf88673fa0f0d03bc0951b1e890e4da77e6a08c44.png

Rolling-Origin Backtesting

Expanding-window splits score a model on data it never saw at fit time, avoiding look-ahead bias.

backtest() returns a BacktestResult with per-fold scores (.scores), every fold’s out-of-sample predictions (.predictions), a .summary(), and a .plot() that overlays all rolling folds.

horizon = 3
initial = len(y) - 16

bt_result = backtest(
    ThetaForecaster(),
    y,
    horizon=horizon,
    initial=initial,
    step=2,
    metric="rmse",
)

print("Backtest Scores Table:")
display(bt_result.scores.head())

print("\nFold Predictions:")
display(bt_result.predictions.head())

# Visualize out-of-sample forecast trajectories across all cutoffs
ax = bt_result.plot(observed=y.iloc[-25:], title="ThetaForecaster: Rolling-Origin Backtest Predictions (3-Week Folds)")
ax.set_xlabel("Date")
ax.set_ylabel("% Inpatient Claims")
ax.grid(True)
plt.show()
Backtest Scores Table:
cutoff score n_train
0 2025-10-26 0.010361 95
1 2025-11-09 0.027186 97
2 2025-11-23 0.024530 99
3 2025-12-07 0.106275 101
4 2025-12-21 0.088923 103
Fold Predictions:
cutoff target_date step predicted lower_80 upper_80 actual
0 2025-10-26 2025-11-02 1 0.023787 -0.065485 0.113059 0.009184
1 2025-10-26 2025-11-09 2 0.023928 -0.102322 0.150178 0.034341
2 2025-10-26 2025-11-16 3 0.024070 -0.130554 0.178694 0.023479
3 2025-11-09 2025-11-16 1 0.034407 -0.054014 0.122828 0.023479
4 2025-11-09 2025-11-23 2 0.034474 -0.090573 0.159520 0.064262
../_images/5f3ef464e283659b89b7e9d73496f52af088b83a91c573e291e859837724f442.png

Forecast Accuracy by Horizon

Error usually grows with the forecast horizon. We backtest each candidate at horizons $h \in {1, 2, 3, 4, 6}$ and compare with plot_metric_by_horizon().

horizons = [1, 2, 3, 4, 6]
candidate_models = {
    "NaiveForecaster": NaiveForecaster(),
    "ThetaForecaster": ThetaForecaster(),
    "ETSForecaster": ETSForecaster(),
    "AutoARIMAForecaster": AutoARIMAForecaster(),
}

rows = []
for name, model in candidate_models.items():
    for h in horizons:
        res = backtest(model, y, horizon=h, initial=initial, metric="rmse")
        rows.append({
            "model": name,
            "horizon": h,
            "rmse": res.scores["score"].mean(),
        })

metrics_df = pd.DataFrame(rows)

ax = plot_metric_by_horizon(
    metrics_df=metrics_df,
    metric_col="rmse",
    title="Forecast Error (RMSE) across Increasing Horizons",
    ylabel="RMSE (% Inpatient Claims)",
    colors={
        "NaiveForecaster": "#E24A33",
        "ThetaForecaster": "#348ABD",
        "ETSForecaster": "#988ED5",
        "AutoARIMAForecaster": "#467821",
    },
    horizons=horizons,
)
plt.show()
/Users/javiermtz/Library/Mobile Documents/com~apple~CloudDocs/code-projects/omnicast/.venv/lib/python3.12/site-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals
  warnings.warn("Maximum Likelihood optimization failed to "
/Users/javiermtz/Library/Mobile Documents/com~apple~CloudDocs/code-projects/omnicast/.venv/lib/python3.12/site-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals
  warnings.warn("Maximum Likelihood optimization failed to "
/Users/javiermtz/Library/Mobile Documents/com~apple~CloudDocs/code-projects/omnicast/.venv/lib/python3.12/site-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals
  warnings.warn("Maximum Likelihood optimization failed to "
/Users/javiermtz/Library/Mobile Documents/com~apple~CloudDocs/code-projects/omnicast/.venv/lib/python3.12/site-packages/statsmodels/base/model.py:607: ConvergenceWarning: Maximum Likelihood optimization failed to converge. Check mle_retvals
  warnings.warn("Maximum Likelihood optimization failed to "
../_images/864cf54731efe76d9c71134b104e986b0f343707b978b4b417f260cdaf8b87fd.png

Automated Selection with AutoForecaster

AutoForecaster runs the rolling-origin backtest above for every candidate, ranks them by the chosen metric, and refits the winner on the full series. The leaderboard and the winning forecast:

auto = AutoForecaster(
    models=[NaiveForecaster(), ThetaForecaster(), ETSForecaster(), AutoARIMAForecaster()],
    metric="rmse",
    validation_horizon=4,
)
auto.fit(y_train)

print("AutoForecaster Leaderboard:")
display(auto.leaderboard_)

auto_fc = auto.predict(horizon=8, level=[80, 95])
ax = auto_fc.plot(
    observed=y,
    title=f"AutoForecaster Winning Model ({auto.best_model_.__class__.__name__})",
)
ax.set_xlabel("Date")
ax.set_ylabel("% COVID-19 Inpatient Claims")
ax.grid(True)
plt.show()
AutoForecaster Leaderboard:
model score status
0 ThetaForecaster 0.033873 ok
1 NaiveForecaster 0.033934 ok
2 AutoARIMAForecaster 0.038197 ok
3 ETSForecaster 0.038622 ok
../_images/4d42bfac05354d056629da1a13b8618e5f8493dd42b1f62b3ec7493f33c35496.png

Comparing Every Candidate

plot_all() refits each candidate the selector kept and overlays their trajectories – the visual complement to the leaderboard.

ax = auto.plot_all(
    horizon=8,
    observed=y.iloc[-35:],
    intervals=False,  # shade each candidate's 80% / 95% bands
    title="Multi-Model Forecast Trajectory Comparison (8 Weeks Ahead)",
)
ax.set_ylabel("% COVID-19 Inpatient Claims")
plt.show()
../_images/ab32aad679328703365fef9eb802a79cf42b44639eddf93cbe597c834d7b00ac.png