# Time Series Forecasting

In this tutorial, we will demonstrate how to build a model for time series forecasting in NumPyro. Specifically, we will replicate the **Seasonal, Global Trend (SGT)** model from the [Rlgt: Bayesian Exponential Smoothing Models with Trend Modifications](https://cran.r-project.org/web/packages/Rlgt/index.html) package. The time series data that we will use for this tutorial is the **lynx** dataset, which contains annual numbers of lynx trappings from 1821 to 1934 in Canada.


```python
import os

import matplotlib.pyplot as plt
import pandas as pd
from IPython.display import set_matplotlib_formats

import jax.numpy as jnp
from jax import random

import numpyro
import numpyro.distributions as dist
from numpyro.contrib.control_flow import scan
from numpyro.diagnostics import autocorrelation, hpdi
from numpyro.infer import MCMC, NUTS, Predictive

if "NUMPYRO_SPHINXBUILD" in os.environ:
    set_matplotlib_formats("svg")

numpyro.set_host_device_count(4)
assert numpyro.__version__.startswith("0.2.4")
```

## Data

First, lets import and take a look at the dataset.


```python
URL = "https://raw.githubusercontent.com/vincentarelbundock/Rdatasets/master/csv/datasets/lynx.csv"
lynx = pd.read_csv(URL, index_col=0)
data = lynx["value"].values
print("Length of time series:", data.shape[0])
plt.figure(figsize=(8, 4))
plt.plot(lynx["time"], data)
plt.show()
```

The time series has a length of 114 (a data point for each year), and by looking at the plot, we can observe [seasonality](https://en.wikipedia.org/wiki/Seasonality) in this dataset, which is the recurrence of similar patterns at specific time periods. e.g. in this dataset, we observe a cyclical pattern every 10 years, but there is also a less obvious but clear spike in the number of trappings every 40 years. Let us see if we can model this effect in NumPyro.

In this tutorial, we will use the first 80 values for training and the last 34 values for testing.


```python
y_train, y_test = jnp.array(data[:80], dtype=jnp.float32), data[80:]
```

## Model

The model we are going to use is called **Seasonal, Global Trend**, which when tested on 3003 time series of the [M-3 competition](https://forecasters.org/resources/time-series-data/m3-competition/), has been known to outperform other models originally participating in the competition:

$$
\begin{align}
\text{exp_val}_{t} &= \text{level}_{t-1} + \text{coef_trend} \times \text{level}_{t-1}^{\text{pow_trend}} + \text{s}_t \times \text{level}_{t-1}^{\text{pow_season}}, \\
\sigma_{t} &= \sigma \times \text{exp_val}_{t}^{\text{powx}} + \text{offset}, \\
y_{t} &\sim \text{StudentT}(\nu, \text{exp_val}_{t}, \sigma_{t})
\end{align}
$$

, where `level` and `s` follows the following recursion rules:

$$
\begin{align}
\text{level_p} &=
\begin{cases}
y_t - \text{s}_t \times \text{level}_{t-1}^{\text{pow_season}} & \text{if } t \le \text{seasonality}, \\ 
\text{Average} \left[y(t - \text{seasonality} + 1), \ldots, y(t)\right] & \text{otherwise},
\end{cases} \\
\text{level}_{t} &= \text{level_sm} \times \text{level_p} + (1 - \text{level_sm}) \times \text{level}_{t-1}, \\
\text{s}_{t + \text{seasonality}} &= \text{s_sm} \times \frac{y_{t} - \text{level}_{t}}{\text{level}_{t-1}^{\text{pow_trend}}}
+ (1 - \text{s_sm}) \times \text{s}_{t}.
\end{align}
$$

A more detailed explanation for SGT model can be found in [this vignette](https://cran.r-project.org/web/packages/Rlgt/vignettes/GT_models.html) from the authors of the Rlgt package. Here we summarize the core ideas of this model:

+ [Student's t-distribution](https://en.wikipedia.org/wiki/Student%27s_t-distribution), which has heavier tails than normal distribution, is used for the likelihood.
+ The expected value `exp_val` consists of a trending component and a seasonal component:
  - The trend is governed by the map $x \mapsto x + ax^b$, where $x$ is `level`, $a$ is `coef_trend`, and $b$ is `pow_trend`. Note that when $b \sim 0$, the trend is linear with $a$ is the slope, and when $b \sim 1$, the trend is exponential with $a$ is the rate. So that function can cover a large family of trend.
  - When time changes, `level` and `s` are updated to new values. Coefficients `level_sm` and `s_sm` are used to make the transition smoothly.
+ When `powx` is near $0$, the error $\sigma_t$ will be nearly constant while when `powx` is near $1$, the error will be propotional to the expected value.
+ There are several varieties of SGT. In this tutorial, we use generalized seasonality and seasonal average method.

We are ready to specify the model using *NumPyro* primitives. In NumPyro, we use the primitive `sample(name, prior)` to declare a latent random variable with a corresponding `prior`. These primitives can have custom interpretations depending on the effect handlers that are used by NumPyro inference algorithms in the backend. e.g. we can condition on specific values using the `condition` handler, or record values at these sample sites in the execution trace using the `trace` handler. Note that these details are not important for specifying the model, or running inference, but curious readers are encouraged to read the [tutorial on effect handlers](http://pyro.ai/examples/effect_handlers.html) in Pyro.


```python
def sgt(y, seasonality, future=0):
    # heuristically, standard derivation of Cauchy prior depends on
    # the max value of data
    cauchy_sd = jnp.max(y) / 150

    # NB: priors' parameters are taken from
    # https://github.com/cbergmeir/Rlgt/blob/master/Rlgt/R/rlgtcontrol.R
    nu = numpyro.sample("nu", dist.Uniform(2, 20))
    powx = numpyro.sample("powx", dist.Uniform(0, 1))
    sigma = numpyro.sample("sigma", dist.HalfCauchy(cauchy_sd))
    offset_sigma = numpyro.sample(
        "offset_sigma", dist.TruncatedCauchy(low=1e-10, loc=1e-10, scale=cauchy_sd)
    )

    coef_trend = numpyro.sample("coef_trend", dist.Cauchy(0, cauchy_sd))
    pow_trend_beta = numpyro.sample("pow_trend_beta", dist.Beta(1, 1))
    # pow_trend takes values from -0.5 to 1
    pow_trend = 1.5 * pow_trend_beta - 0.5
    pow_season = numpyro.sample("pow_season", dist.Beta(1, 1))

    level_sm = numpyro.sample("level_sm", dist.Beta(1, 2))
    s_sm = numpyro.sample("s_sm", dist.Uniform(0, 1))
    init_s = numpyro.sample("init_s", dist.Cauchy(0, y[:seasonality] * 0.3))

    def transition_fn(carry, t):
        level, s, moving_sum = carry
        season = s[0] * level ** pow_season
        exp_val = level + coef_trend * level ** pow_trend + season
        exp_val = jnp.clip(exp_val, a_min=0)
        # use expected vale when forecasting
        y_t = jnp.where(t >= N, exp_val, y[t])

        moving_sum = (
            moving_sum + y[t] - jnp.where(t >= seasonality, y[t - seasonality], 0.0)
        )
        level_p = jnp.where(t >= seasonality, moving_sum / seasonality, y_t - season)
        level = level_sm * level_p + (1 - level_sm) * level
        level = jnp.clip(level, a_min=0)

        new_s = (s_sm * (y_t - level) / season + (1 - s_sm)) * s[0]
        # repeat s when forecasting
        new_s = jnp.where(t >= N, s[0], new_s)
        s = jnp.concatenate([s[1:], new_s[None]], axis=0)

        omega = sigma * exp_val ** powx + offset_sigma
        y_ = numpyro.sample("y", dist.StudentT(nu, exp_val, omega))

        return (level, s, moving_sum), y_

    N = y.shape[0]
    level_init = y[0]
    s_init = jnp.concatenate([init_s[1:], init_s[:1]], axis=0)
    moving_sum = level_init
    with numpyro.handlers.condition(data={"y": y[1:]}):
        _, ys = scan(
            transition_fn, (level_init, s_init, moving_sum), jnp.arange(1, N + future)
        )
    if future > 0:
        numpyro.deterministic("y_forecast", ys[-future:])
```

Note that `level` and `s` are updated recursively while we collect the expected value at each time step. NumPyro uses [JAX](https://github.com/google/jax) in the backend to JIT compile many critical parts of the NUTS algorithm, including the verlet integrator and the tree building process. However, doing so using Python's `for` loop in the model will result in a long compilation time for the model, so we use `scan` - which is a wrapper of [lax.scan](https://jax.readthedocs.io/en/latest/_autosummary/jax.lax.scan.html#jax.lax.scan) with supports for NumPyro primitives and handlers. A detailed explanation for using this utility can be found in [NumPyro documentation](http://num.pyro.ai/en/latest/primitives.html#scan). Here we use it to collect `y` values while the triple `(level, s, moving_sum)` plays the role of carrying state.

Another note is that instead of declaring the observation site `y` in `transition_fn`

```python
numpyro.sample("y", dist.StudentT(nu, exp_val, omega), obs=y[t])
```

, we have used [condition](http://num.pyro.ai/en/stable/handlers.html#numpyro.handlers.condition) handler here. The reason is we also want to use this model for forecasting. In forecasting, future values of `y` are non-observable, so `obs=y[t]` does not make sense when `t >= len(y)` (caution: index out-of-bound errors do not get raised in JAX, e.g. `jnp.arange(3)[10] == 2`). Using `condition`, when the length of `scan` is larger than the length of the conditioned/observed site, unobserved values will be sampled from the distribution of that site.

## Inference

First, we want to choose a good value for `seasonality`. Following [the demo in Rlgt](https://github.com/cbergmeir/Rlgt/blob/master/Rlgt/demo/lynx.R), we will set `seasonality=38`. Indeed, this value can be guessed by looking at the plot of the training data, where the second order seasonality effect has a periodicity around $40$ years. Note that $38$ is also one of the highest-autocorrelation lags.


```python
print("Lag values sorted according to their autocorrelation values:\n")
print(jnp.argsort(autocorrelation(y_train))[::-1])
```

    Lag values sorted according to their autocorrelation values:
    
    [ 0 67 57 38 68  1 29 58 37 56 28 10 19 39 66 78 47 77  9 79 48 76 30 18
     20 11 46 59 69 27 55 36  2  8 40 49 17 21 75 12 65 45 31 26  7 54 35 41
     50  3 22 60 70 16 44 13  6 25 74 53 42 32 23 43 51  4 15 14 34 24  5 52
     73 64 33 71 72 61 63 62]


Now, let us run $4$ MCMC chains (using the No-U-Turn Sampler algorithm) with $5000$ warmup steps and $5000$ sampling steps per each chain. The returned value will be a collection of $20000$ samples.


```python
%%time
kernel = NUTS(sgt)
mcmc = MCMC(kernel, num_warmup=5000, num_samples=5000, num_chains=4)
mcmc.run(random.PRNGKey(0), y_train, seasonality=38)
mcmc.print_summary()
samples = mcmc.get_samples()
```

    
                          mean       std    median      5.0%     95.0%     n_eff     r_hat
          coef_trend     36.52    126.97     13.59    -95.92    174.81   1454.73      1.00
           init_s[0]     90.70    116.71     67.22    -55.87    246.22   2005.71      1.00
           init_s[1]    -21.18     69.17    -25.53   -129.89     84.52   5925.76      1.00
           init_s[2]     29.71     91.29     17.58   -105.86    179.52   4110.82      1.00
           init_s[3]    123.24    125.48    105.96    -71.54    297.02   5028.94      1.00
           init_s[4]    448.13    251.71    401.07     57.63    807.72   2954.29      1.00
           init_s[5]   1164.09    473.62   1086.07    465.71   1850.38   3196.76      1.00
           init_s[6]   1971.52    656.65   1881.46    959.63   2941.65   2459.35      1.00
           init_s[7]   3636.33   1100.96   3493.76   1896.79   5235.14   1926.91      1.00
           init_s[8]   2576.20    817.21   2458.89   1346.02   3852.86   2450.33      1.00
           init_s[9]    943.14    453.36    870.80    300.55   1574.16   3480.28      1.00
          init_s[10]     46.17    106.81     29.82   -104.58    197.73   3985.31      1.00
          init_s[11]     -0.29     54.60     -2.62    -79.27     74.88   2501.26      1.00
          init_s[12]     -9.94     65.21    -11.51   -114.00     85.02   4473.50      1.00
          init_s[13]     72.56    120.13     48.91    -82.80    217.49   2442.98      1.00
          init_s[14]    328.93    247.82    277.09    -18.02    681.53   4978.88      1.00
          init_s[15]    962.85    389.58    897.64    394.79   1543.83   2990.50      1.00
          init_s[16]   1248.09    474.78   1175.10    541.77   1934.84   2588.09      1.00
          init_s[17]   1368.26    551.93   1271.90    571.07   2221.27   3144.82      1.00
          init_s[18]    618.26    321.94    558.53    148.87   1064.72   3783.10      1.00
          init_s[19]     18.95     88.28      8.49   -103.99    160.77   4297.71      1.00
          init_s[20]    -32.31     63.41    -28.11   -139.01     60.62   3335.40      1.00
          init_s[21]    -15.61     45.53     -5.52    -94.22     47.21   2651.41      1.00
          init_s[22]     -1.74     41.69     -1.30    -66.32     58.76   3891.95      1.00
          init_s[23]     41.54     86.61     27.38    -76.68    174.81   4798.57      1.00
          init_s[24]    524.15    334.20    464.87     38.51    985.44   4665.72      1.00
          init_s[25]    928.17    450.39    849.11    260.38   1579.18   3538.37      1.00
          init_s[26]   1778.62    686.20   1677.18    725.45   2758.93   2942.77      1.00
          init_s[27]   1255.39    470.39   1189.05    531.97   1910.48   2666.95      1.00
          init_s[28]    211.94    167.37    182.82    -36.34    451.14   4699.98      1.00
          init_s[29]     -6.22     85.73    -16.66   -138.63    115.79   4744.34      1.00
          init_s[30]     -4.07     86.14    -13.16   -138.52    126.19   4665.95      1.00
          init_s[31]    -36.82     71.74    -37.29   -155.82     68.96   3575.86      1.00
          init_s[32]     -8.21     83.02    -15.64   -138.47    113.69   5558.33      1.00
          init_s[33]    115.15    143.78     91.99    -87.71    306.54   4090.04      1.00
          init_s[34]    507.36    284.02    456.86    102.04    936.52   4597.00      1.00
          init_s[35]   1060.70    446.30    978.34    415.05   1739.56   3163.21      1.00
          init_s[36]   1830.52    633.74   1736.39    824.55   2743.67   2705.82      1.00
          init_s[37]   1433.42    539.85   1345.03    611.66   2243.29   3002.23      1.00
            level_sm      0.00      0.00      0.00      0.00      0.00  10679.24      1.00
                  nu     12.24      4.79     12.56      5.46     19.99   6307.39      1.00
        offset_sigma     32.43     31.01     23.46      0.00     72.40   7616.26      1.00
          pow_season      0.09      0.04      0.09      0.02      0.15   1399.95      1.00
      pow_trend_beta      0.25      0.17      0.22      0.00      0.49    990.21      1.00
                powx      0.62      0.13      0.61      0.40      0.84   3497.43      1.00
                s_sm      0.08      0.09      0.05      0.00      0.18   6625.91      1.00
               sigma      9.86     10.05      6.91      0.38     21.22   4697.63      1.00
    
    Number of divergences: 4106
    CPU times: user 1min 54s, sys: 705 ms, total: 1min 55s
    Wall time: 58.1 s


## Forecasting

Given `samples` from `mcmc`, we want to do forecasting for the testing dataset `y_test`. NumPyro provides a convenient utility [Predictive](http://num.pyro.ai/en/stable/utilities.html#numpyro.infer.util.Predictive) to get predictive distribution. Let's see how to use it to get forecasting values.

Notice that in the `sgt` model defined above, there is a keyword `future` which controls the execution of the model - depending on whether `future > 0` or `future == 0`. The following code predicts the last 34 values from the original time-series.


```python
predictive = Predictive(sgt, samples, return_sites=["y_forecast"])
forecast_marginal = predictive(random.PRNGKey(1), y_train, seasonality=38, future=34)[
    "y_forecast"
]
```

Let's get sMAPE, root mean square error of the prediction, and visualize the result with the mean prediction and the 90% highest posterior density interval (HPDI).


```python
y_pred = jnp.mean(forecast_marginal, axis=0)
sMAPE = jnp.mean(jnp.abs(y_pred - y_test) / (y_pred + y_test)) * 200
msqrt = jnp.sqrt(jnp.mean((y_pred - y_test) ** 2))
print("sMAPE: {:.2f}, rmse: {:.2f}".format(sMAPE, msqrt))
```

    sMAPE: 64.19, rmse: 1252.99


Finally, let's plot the result to verify that we get the expected one.


```python
plt.figure(figsize=(8, 4))
plt.plot(lynx["time"], data)
t_future = lynx["time"][80:]
hpd_low, hpd_high = hpdi(forecast_marginal)
plt.plot(t_future, y_pred, lw=2)
plt.fill_between(t_future, hpd_low, hpd_high, alpha=0.3)
plt.title("Forecasting lynx dataset with SGT model (90% HPDI)")
plt.show()
```

As we can observe, the model has been able to learn both the first and second order seasonality effects, i.e. a cyclical pattern with a periodicity of around 10, as well as spikes that can be seen once every 40 or so years. Moreover, we not only have point estimates for the forecast but can also use the uncertainty estimates from the model to bound our forecasts. 

## Acknowledgements

We would like to thank Slawek Smyl for many helpful resources and suggestions. Fast inference would not have been possible without the support of JAX and the XLA teams, so we would like to thank them for providing such a great open-source platform for us to build on, and for their responsiveness in dealing with our feature requests and bug reports.

## References

[1] `Rlgt: Bayesian Exponential Smoothing Models with Trend Modifications`,<br>&nbsp;&nbsp;&nbsp;&nbsp;
Slawek Smyl, Christoph Bergmeir, Erwin Wibowo, To Wang Ng, Trustees of Columbia University

3d761be5f9852bdf839f3db4a1428443f749cf4d
ipynb
Jupyter Notebook
notebooks/source/time_series_forecasting.ipynb
daydreamt/numpyro
33b77a9f619f745df95cc38cfd9aaa0d39f1a109
Apache-2.0
notebooks/source/time_series_forecasting.ipynb
daydreamt/numpyro
33b77a9f619f745df95cc38cfd9aaa0d39f1a109
Apache-2.0
notebooks/source/time_series_forecasting.ipynb
daydreamt/numpyro
33b77a9f619f745df95cc38cfd9aaa0d39f1a109
Apache-2.0
Qwen/Qwen-72B
1. YES
2. YES