Skip to content

Commit 87aeda0

Browse files
committed
forecast
1 parent c842de3 commit 87aeda0

File tree

2 files changed

+487
-73
lines changed

2 files changed

+487
-73
lines changed

examples/time_series/Time_Series_Generative_Graph.ipynb

Lines changed: 312 additions & 49 deletions
Large diffs are not rendered by default.

examples/time_series/Time_Series_Generative_Graph.myst.md

Lines changed: 175 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import numpy as np
5454
import pymc as pm
5555
import pytensor
5656
import pytensor.tensor as pt
57+
import statsmodels.api as sm
5758
5859
from pymc.pytensorf import collect_default_updates
5960
@@ -96,16 +97,17 @@ lags = 2 # Number of lags
9697
trials = 100 # Time series length
9798
9899
100+
# This is the transition function for the AR(2) model.
101+
# We take as inputs previous steps and then specify the autoregressive relationship.
102+
# Finally, we add Gaussian noise to the model.
103+
def ar_step(x_tm2, x_tm1, rho, sigma):
104+
mu = x_tm1 * rho[0] + x_tm2 * rho[1]
105+
x = mu + pm.Normal.dist(sigma=sigma)
106+
return x, collect_default_updates([x])
107+
108+
109+
# Here we actually "loop" over the time series.
99110
def ar_dist(ar_init, rho, sigma, size):
100-
# This is the transition function for the AR(2) model.
101-
# We take as inputs previous steps and then specify the autoregressive relationship.
102-
# Finally, we add Gaussian noise to the model.
103-
def ar_step(x_tm2, x_tm1, rho, sigma):
104-
mu = x_tm1 * rho[0] + x_tm2 * rho[1]
105-
x = mu + pm.Normal.dist(sigma=sigma)
106-
return x, collect_default_updates([x])
107-
108-
# Here we actually "loop" over the time series.
109111
ar_innov, _ = pytensor.scan(
110112
fn=ar_step,
111113
outputs_info=[{"initial": ar_init, "taps": range(-lags, 0)}],
@@ -260,14 +262,19 @@ for i, hdi_prob in enumerate((0.94, 0.64), 1):
260262
lower = hdi.sel(hdi="lower")
261263
upper = hdi.sel(hdi="higher")
262264
ax.fill_between(
263-
x=np.arange(trials),
265+
x=post_pred_ar["trials"],
264266
y1=lower,
265267
y2=upper,
266268
alpha=(i - 0.2) * 0.2,
267269
color="C0",
268270
label=f"{hdi_prob:.0%} HDI",
269271
)
270-
ax.plot(post_pred_ar.mean(("chain", "draw")), color="C0", label="Mean")
272+
ax.plot(
273+
post_pred_ar["trials"],
274+
post_pred_ar.mean(("chain", "draw")),
275+
color="C0",
276+
label="Mean",
277+
)
271278
ax.plot(ar_obs, color="black", label="Observed")
272279
ax.legend(loc="upper right")
273280
ax.set_xlabel("time")
@@ -310,12 +317,7 @@ $$
310317
Let's see how to do this in PyMC! The key observation is that we need to pass the observed data explicitly into out "for loop" in the generative graph. That is, we need to pass it into the {meth}`scan <pytensor.scan.basic.scan>` function.
311318

312319
```{code-cell} ipython3
313-
def conditional_ar_dist(y_data, ar_init, rho, sigma, size):
314-
def ar_step(x_tm2, x_tm1, rho, sigma):
315-
mu = x_tm1 * rho[0] + x_tm2 * rho[1]
316-
x = mu + pm.Normal.dist(sigma=sigma)
317-
return x, collect_default_updates([x])
318-
320+
def conditional_ar_dist(y_data, rho, sigma, size):
319321
# Here we condition on the observed data by passing it through the `sequences` argument.
320322
ar_innov, _ = pytensor.scan(
321323
fn=ar_step,
@@ -330,22 +332,26 @@ def conditional_ar_dist(y_data, ar_init, rho, sigma, size):
330332

331333
Then we can simply generate samples from the posterior predictive distribution. Observe we need to "rewrite" the generative graph to include te conditioned transition step. Nevertheless, we will use the posterior samples from the model above, this means we can put *any* "prior" distributions on the parameters we learned. For a detailed explanation on these type of cross model predictions, see the great blog post [Out of model predictions with PyMC](https://www.pymc-labs.com/blog-posts/out-of-model-predictions-with-pymc/).
332334

335+
+++
336+
337+
```{warning}
338+
We need to shift the coordinate `steps` forward by one! The reasons is that the data at (for example) `step=1` is used to create the prediction for `step=2`. If one does not do the shift, the `step=0` prediction will be mislabeled as `step=0`, and the model will look better than it is.
339+
```
340+
333341
```{code-cell} ipython3
334342
coords = {
335343
"lags": range(-lags, 0),
336-
"steps": range(trials - lags),
337-
"trials": range(trials),
344+
"steps": range(-1, trials - lags - 1), # <- Coordinate shift!
345+
"trials": range(1, trials + 1), # <- Coordinate shift!
338346
}
339347
with pm.Model(coords=coords, check_bounds=False) as conditional_model:
340348
y_data = pm.Data("y_data", ar_obs)
341349
rho = pm.Flat(name="rho", dims=("lags",))
342350
sigma = pm.Flat(name="sigma")
343-
ar_init = pm.Flat(name="ar_init", dims=("lags",))
344351
345352
ar_innov = pm.CustomDist(
346353
"ar_dist",
347354
y_data,
348-
ar_init,
349355
rho,
350356
sigma,
351357
dist=conditional_ar_dist,
@@ -359,26 +365,44 @@ with pm.Model(coords=coords, check_bounds=False) as conditional_model:
359365
post_pred_conditional = pm.sample_posterior_predictive(trace, var_names=["ar"], random_seed=rng)
360366
```
361367

362-
Let's visualize the conditional posterior predictive distribution:
368+
Let's visualize the conditional posterior predictive distribution and compare it with the `statsmodels` result:
363369

364370
```{code-cell} ipython3
371+
# PyMC conditional posterior predictive samples
365372
conditional_post_pred_ar = post_pred_conditional.posterior_predictive["ar"]
366373
374+
# Statsmodels AR(2) model
375+
mod = sm.tsa.ARIMA(ar_obs, order=(2, 0, 0))
376+
res = mod.fit()
377+
```
378+
379+
```{code-cell} ipython3
367380
_, ax = plt.subplots()
368381
for i, hdi_prob in enumerate((0.94, 0.64), 1):
369382
hdi = az.hdi(conditional_post_pred_ar, hdi_prob=hdi_prob)["ar"]
370383
lower = hdi.sel(hdi="lower")
371384
upper = hdi.sel(hdi="higher")
372385
ax.fill_between(
373-
x=np.arange(trials),
386+
x=conditional_post_pred_ar["trials"],
374387
y1=lower,
375388
y2=upper,
376389
alpha=(i - 0.2) * 0.2,
377390
color="C1",
378391
label=f"{hdi_prob:.0%} HDI",
379392
)
380-
ax.plot(conditional_post_pred_ar.mean(("chain", "draw")), color="C1", label="Mean")
393+
ax.plot(
394+
conditional_post_pred_ar["trials"],
395+
conditional_post_pred_ar.mean(("chain", "draw")),
396+
color="C1",
397+
label="Mean",
398+
)
381399
ax.plot(ar_obs, color="black", label="Observed")
400+
ax.plot(
401+
conditional_post_pred_ar["trials"],
402+
res.fittedvalues,
403+
color="C2",
404+
label="statsmodels",
405+
)
382406
ax.legend(loc="upper right")
383407
ax.set_xlabel("time")
384408
ax.set_title("AR(2) Conditional Posterior Predictive Samples", fontsize=18, fontweight="bold");
@@ -388,6 +412,133 @@ We indeed see that these credible intervals are tighter than the unconditional o
388412

389413
+++
390414

415+
Here are some additional remarks:
416+
417+
- There's no prediction for $y_0$, because we don't observe $y_{t - 1}$.
418+
- The predictions seem to "chase" the data, since that's exactly what we're doing. At each step, we reset to the observed data and make one prediction.
419+
420+
```{note}
421+
Relative to the `statsmodel` reference, we're just a little different in the initialization. This makes sense, since they do some fancy MLE initialization trickery and we estimate it as a parameter. The difference should wash out as we iterate over the sequence, and we see that indeed it does.
422+
```
423+
424+
+++
425+
426+
## Out of Sample Predictions
427+
428+
In this last section, wee describe how to generate out-of-sample predictions.
429+
430+
```{code-cell} ipython3
431+
# Specify the number of steps to forecast
432+
forecast_steps = 10
433+
```
434+
435+
The idea is to use the posterior samples and the latest available two data points (because we have an AR(2) model) to generate the forecast:
436+
437+
```{code-cell} ipython3
438+
coords = {
439+
"lags": range(-lags, 0),
440+
"trials": range(trials),
441+
"steps": range(trials, trials + forecast_steps),
442+
}
443+
with pm.Model(coords=coords, check_bounds=False) as forecasting_model:
444+
y_data = pm.Data("y_data", ar_obs[-lags:], dims=("lags",))
445+
rho = pm.Flat(name="rho", dims=("lags",))
446+
sigma = pm.Flat(name="sigma")
447+
ar_init = pm.Flat(name="ar_init", dims=("lags",))
448+
449+
def ar_dist_forecasting(y_data, rho, sigma, size):
450+
ar_innov, _ = pytensor.scan(
451+
fn=ar_step,
452+
outputs_info=[{"initial": y_data, "taps": range(-lags, 0)}],
453+
non_sequences=[rho, sigma],
454+
n_steps=forecast_steps,
455+
strict=True,
456+
)
457+
return ar_innov
458+
459+
ar_innov = pm.CustomDist(
460+
"ar_dist",
461+
y_data,
462+
rho,
463+
sigma,
464+
dist=ar_dist_forecasting,
465+
dims=("steps",),
466+
)
467+
468+
post_pred_forecast = pm.sample_posterior_predictive(
469+
trace, var_names=["ar_dist"], random_seed=rng
470+
)
471+
```
472+
473+
We can visualize the out-of-sample predictions and compare thee results wth the one from `statsmodels`.
474+
475+
```{code-cell} ipython3
476+
forecast_post_pred_ar = post_pred_forecast.posterior_predictive["ar_dist"]
477+
478+
_, ax = plt.subplots()
479+
for i, hdi_prob in enumerate((0.94, 0.64), 1):
480+
hdi = az.hdi(conditional_post_pred_ar, hdi_prob=hdi_prob)["ar"]
481+
lower = hdi.sel(hdi="lower")
482+
upper = hdi.sel(hdi="higher")
483+
ax.fill_between(
484+
x=conditional_post_pred_ar["trials"],
485+
y1=lower,
486+
y2=upper,
487+
alpha=(i - 0.2) * 0.2,
488+
color="C1",
489+
label=f"{hdi_prob:.0%} HDI",
490+
)
491+
492+
ax.plot(
493+
conditional_post_pred_ar["trials"],
494+
conditional_post_pred_ar.mean(("chain", "draw")),
495+
color="C1",
496+
label="Mean",
497+
)
498+
499+
for i, hdi_prob in enumerate((0.94, 0.64), 1):
500+
hdi = az.hdi(forecast_post_pred_ar, hdi_prob=hdi_prob)["ar_dist"]
501+
lower = hdi.sel(hdi="lower")
502+
upper = hdi.sel(hdi="higher")
503+
ax.fill_between(
504+
x=forecast_post_pred_ar["steps"],
505+
y1=lower,
506+
y2=upper,
507+
alpha=(i - 0.2) * 0.2,
508+
color="C3",
509+
label=f"{hdi_prob:.0%} HDI forecast",
510+
)
511+
512+
ax.plot(
513+
forecast_post_pred_ar["steps"],
514+
forecast_post_pred_ar.mean(("chain", "draw")),
515+
color="C3",
516+
label="Mean Forecast",
517+
)
518+
519+
520+
ax.plot(ar_obs, color="black", label="Observed")
521+
ax.plot(
522+
conditional_post_pred_ar["trials"],
523+
res.fittedvalues,
524+
color="C2",
525+
label="statsmodels",
526+
)
527+
ax.plot(
528+
forecast_post_pred_ar["steps"],
529+
res.forecast(forecast_steps),
530+
color="C2",
531+
label="statsmodels forecast",
532+
)
533+
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.1), ncol=4)
534+
ax.set_xlabel("time")
535+
ax.set_title(
536+
"AR(2) Conditional Posterior Predictive Samples & Forecast",
537+
fontsize=18,
538+
fontweight="bold",
539+
);
540+
```
541+
391542
## Authors
392543
- Authored by [Jesse Grabowski](https://github.com/jessegrabowski), [Juan Orduz](https://juanitorduz.github.io/) and [Ricardo Vieira](https://github.com/ricardoV94) in March 2024
393544

0 commit comments

Comments
 (0)