12  Advanced forecasting methods

Load common libraries and settings
import warnings
warnings.filterwarnings("ignore")
import os
os.environ["NIXTLA_ID_AS_COL"] = "true"
import numpy as np
np.set_printoptions(suppress=True)
np.random.seed(1)
import random
random.seed(1)
import pandas as pd
pd.set_option("max_colwidth", 100)
pd.set_option("display.precision", 3)
from utilsforecast.plotting import plot_series as plot_series_utils
import seaborn as sns
sns.set_style("whitegrid")
import matplotlib.pyplot as plt
plt.style.use("ggplot")
plt.rcParams.update({
    "figure.figsize": (8, 5),
    "figure.dpi": 100,
    "figure.constrained_layout.use": True,
    "font.size": 11.5,
    "axes.titlesize": 14,
    "axes.labelsize": 11.5,
    "xtick.labelsize": 10.5,
    "ytick.labelsize": 10.5,
    "legend.fontsize": 9.5,
    "legend.title_fontsize": 11.5,
    "grid.alpha": 1.0,
})
import matplotlib as mpl
from cycler import cycler
mpl.rcParams['axes.prop_cycle'] = cycler(color=["#000000", "#000000"])
from fpppy.utils import plot_series

mpl.colormaps.register(
    mpl.colors.ListedColormap(
        ["#000000", "#2f2fff"], name="black_and_blue"),
    force=True,
)
mpl.colormaps.register(
    mpl.colors.ListedColormap(
        ["#000000", "#D55E00"], name="black_and_orange"),
    force=True,
)
mpl.colormaps.register(
    mpl.colors.ListedColormap(
        ["#000000", "#000000"], name="black"),
    force=True,
)
mpl.colormaps.register(
    mpl.colors.ListedColormap(
        ["#000000", "#0072B2", "#D55E00"],
        name='black_and_2color',
    ),
    force=True
)
mpl.colormaps.register(
    mpl.colors.ListedColormap(
        ["#000000", "#D55E00", "#0072B2", "#009E73"],
        name='black_and_3color',
    ),
    force=True
)
mpl.colormaps.register(
    mpl.colors.ListedColormap(
        ["#000000", "#D55E00", "#0072B2", "#009E73", "#CC79A7"],
        name='black_and_4color',
    ),
    force=True
)
mpl.colormaps.register(
    mpl.colors.ListedColormap(
        ["#D55E00", "#0072B2", "#009E73", "#CC79A7"],
        name='r_colors',
    ),
    force=True
)
Load additional libraries
from functools import partial, reduce

import statsmodels.api as sm
from matplotlib.ticker import MaxNLocator
from prophet import Prophet
from statsforecast import StatsForecast
from statsforecast.adapters.prophet import AutoARIMAProphet
from statsforecast.models import MSTL, AutoETS, AutoARIMA, ARIMA
from statsmodels.graphics.tsaplots import plot_acf
from statsmodels.tsa.api import VAR
from utilsforecast.evaluation import evaluate
from utilsforecast.feature_engineering import trend, fourier, pipeline
from utilsforecast.losses import rmse, mae, mape, mase
from utilsforecast.preprocessing import fill_gaps

In this chapter, we briefly discuss four more advanced forecasting methods that build on the models discussed in earlier chapters.

12.1 Complex seasonality

So far, we have mostly considered relatively simple seasonal patterns such as quarterly and monthly data. However, higher frequency time series often exhibit more complicated seasonal patterns. For example, daily data may have a weekly pattern as well as an annual pattern. Hourly data usually has three types of seasonality: a daily pattern, a weekly pattern, and an annual pattern. Even weekly data can be challenging to forecast as there are not a whole number of weeks in a year, so the annual pattern has a seasonal period of 365.25/7\approx 52.179 on average. Most of the methods we have considered so far are unable to deal with these seasonal complexities.

We don’t necessarily want to include all the possible seasonal periods in our models — just the ones that are likely to be present in the data. For example, if we have only 180 days of data, we may ignore the annual seasonality. If the data are measurements of a natural phenomenon (e.g., temperature), we can probably safely ignore any weekly seasonality.

Figure 12.1 shows the number of calls to a North American commercial bank per 5-minute interval between 7:00am and 9:05pm each weekday over a 33 week period. Figure 12.2 shows the first four weeks of the same time series. There is a strong daily seasonal pattern with period 169 (there are 169 5-minute intervals per day), and a weak weekly seasonal pattern with period 169 \times 5=845. (Call volumes on Mondays tend to be higher than the rest of the week.) If a longer series of data were available, we may also have observed an annual seasonal pattern.

Code
bank_calls = (
    pd.read_csv("data/bank_calls.csv", parse_dates=["ds"])
    .loc[lambda x: x["y"] > 0]
)
bank_calls_5min = fill_gaps(bank_calls, freq="5min")
fig, ax = plt.subplots(figsize=(8, 3))
plot_series(
    bank_calls_5min,
    xlabel="DateTime [5m]", ylabel="Calls",
    title="Five-minute call volume to bank",
    ax=ax,
)

Figure 12.1: Five-minute call volume handled on weekdays between 7:00am and 9:05pm in a large North American commercial bank. Data from 3 March – 24 October 2003.
Code
df = bank_calls_5min.loc[lambda x: x["ds"] <= "2003-03-29"]
fig, ax = plt.subplots(figsize=(8, 3))
plot_series(df, xlabel="DateTime [5m]", ylabel="Calls",
    title="Five-minute call volume to bank over 4 weeks",
    ax=ax,
)

Figure 12.2: First four weeks of data from the North American commercial bank.

Apart from the multiple seasonal periods, this series has the additional complexity of missing values between the working periods.

MSTL with multiple seasonal periods

The MSTL() function is designed to deal with multiple seasonalities, and it can be used to decompose a series into its trend, multiple seasonal, and remainder components.

Code
model = MSTL(season_length=[169, 169 * 5])
sf = StatsForecast(models=[model], freq="5min")
sf = sf.fit(bank_calls.assign(y = lambda df: np.sqrt(df["y"])))
dcmp = sf.fitted_[0, 0].model_
Code
fig, axes = plt.subplots(5, figsize=(8, 7), sharex=True)
for (ax, column) in zip(axes, dcmp.columns):
    sns.lineplot(data=dcmp, x=dcmp.index, y=column, ax=ax, color="k")
axes[0].set_title(
    "sqrt(Calls) = trend + seasonal169 + seasonal845 + remainder",
    size="medium", loc="left")
axes[-1].set(xlabel="")
fig.suptitle("STL decomposition")
plt.show()

Figure 12.3: STL decomposition with multiple seasonality for the call volume data.

There are two seasonal patterns shown in Figure 12.3, one for the time of day (the third panel), and one for the time of week (the fourth panel). Note that in this case, there is little trend seen in the data, and the weekly seasonality is weak.

The decomposition can also be used in forecasting, with each of the seasonal components forecast using a Seasonal Naïve method, and the seasonally adjusted data forecast using ETS.

The MSTL method can also be used for forecasting, as shown below and in Figure 12.4. Since StatsForecast always produces continuous dates, we need to account for the bank being closed before 7:00am, after 9:05pm, and on weekends. We will do this by generating the correct dates directly, using the knowledge that the dataset we used to generate the forecast ends on Friday the 24th at 9:05 PM.

Code
# Find forecast timestamps: weekdays 7am to 9pm
start = bank_calls["ds"].max() + pd.offsets.Minute(5)
all_5min = pd.date_range(start, start + pd.offsets.Day(7), freq="5min")
fc_ds = all_5min.to_series().loc[lambda x: (
    x.dt.weekday.between(0, 4)
    & x.dt.strftime("%H:%M").between("07:00", "21:00")
)]

# Forecast, back-transform numerical results, and set ds with gaps
fc = sf.predict(h=len(fc_ds), level=[80, 95])
res = fc.select_dtypes(include=np.number)
fc = (
    fc.assign(**res.transform(lambda x: x**2))
    .assign(ds=fc_ds.to_numpy())
    .pipe(fill_gaps, freq="5min")
)
Code
fig, ax = plt.subplots(figsize=(8, 4))
plot_series(bank_calls_5min, fc, level=[80, 95],
    max_insample_length=169 * (14 + 5),
    xlabel="DateTime [5m]", ylabel="Calls",
    title="Five-minute call volume to bank",
    palette="black_and_blue", rm_legend=False, ax=ax,
    legend_loc="outside lower center")
ax.xaxis.set_major_locator(MaxNLocator(4))
fig

Figure 12.4: Forecasts of the call volume data using an STL decomposition with the seasonal components forecast using a Seasonal Naïve method, and the seasonally adjusted data forecast using ETS.

Dynamic harmonic regression with multiple seasonal periods

With multiple seasonalities, we can use Fourier terms as we did in earlier chapters (see Sections 7.4 and 10.5). Because there are multiple seasonalities, we need to add Fourier terms for each seasonal period. In this case, the seasonal periods are 169 and 845, so the Fourier terms are of the form \sin\left(\frac{2\pi kt}{169}\right), \quad \cos\left(\frac{2\pi kt}{169}\right), \quad \sin\left(\frac{2\pi kt}{845}\right), \quad \text{and} \quad \cos\left(\frac{2\pi kt}{845}\right), for k=1,2,\dots. As usual, the fourier() function can generate these for you.

We will fit a dynamic harmonic regression model with an ARIMA error structure. The total number of Fourier terms for each seasonal period could be selected to minimise the AICc. However, for high seasonal periods, this tends to over-estimate the number of terms required, so we will use a more subjective choice with 10 terms for the daily seasonality and 5 for the weekly seasonality. We set D=d=0 in order to handle the non-stationarity through the regression terms, and P=Q=0 in order to handle the seasonality through the regression terms (this is achieved by using seasonal=False while instantiating AutoARIMA() as follows).

Code
features = [
    partial(fourier, season_length=169, k=10),
    partial(fourier, season_length=5 * 169, k=5),
]
bank_calls_fourier, bank_calls_futr_fourier = pipeline(
    bank_calls, features=features, freq="5min", h=len(fc_ds))

# Remove redundant Fourier features
dropcols = ["sin5_845", "cos5_845"]
bank_calls_fourier = bank_calls_fourier.drop(columns=dropcols)
bank_calls_futr_fourier = bank_calls_futr_fourier.drop(columns=dropcols)

model = AutoARIMA(d=0, seasonal=False, nmodels=5)
sf = StatsForecast(models=[model], freq="5min")
sf = sf.fit(bank_calls_fourier)

level = [80, 95]
fc_fourier = (
    sf.predict(h=len(fc_ds), X_df=bank_calls_futr_fourier, level=level)
    .assign(ds=fc_ds.to_numpy())
    .pipe(fill_gaps, freq="5min")
)
Code
fig, ax = plt.subplots(figsize=(8, 4))
plot_series(
    bank_calls_5min, fc_fourier, level=level,
    max_insample_length=169 * (14 + 5),
    xlabel="DateTime [5m]", ylabel="Calls",
    title="Five-minute call volume to bank",
    palette="black_and_blue", rm_legend=False, ax=ax,
    legend_loc="outside lower center")
ax.xaxis.set_major_locator(MaxNLocator(4))
fig

Figure 12.5: Forecasts from a dynamic harmonic regression applied to the call volume data.

This is a large model, containing 33 parameters: 4 ARMA coefficients, 20 Fourier coefficients for period 169, 10 Fourier coefficients for period 845, and an intercept. Two of the Fourier terms for period 845 must be dropped because they overlap with the terms of period 169 (since 845=5\times169).

Example: Electricity demand

One common application of such models is electricity demand modelling. Figure 12.6 shows half-hourly electricity demand (MWh) in Victoria, Australia, during 2012–2014, along with temperatures (degrees Celsius) for the same period for Melbourne (the largest city in Victoria).

Code
vic_elec = pd.read_csv("data/vic_elec.csv", parse_dates=["ds"])
fig, axes = plt.subplots(2, 1, sharex=True)
plot_series(vic_elec, ylabel="", ax=axes)

Figure 12.6: Half-hourly electricity demand and corresponding temperatures in 2012–2014, Victoria, Australia.

Plotting electricity demand against temperature (Figure 12.7) shows that there is a nonlinear relationship between the two, with demand increasing for low temperatures (due to heating) and increasing for high temperatures (due to cooling).

vic_elec_df = (
    vic_elec.loc[lambda x: x["ds"] >= "2014"]
    .pivot(index=["ds", "Holiday"], columns="unique_id", values="y")
    .reset_index().rename_axis(None, axis="columns")
    .assign(
        Demand=lambda x: x["Demand"] / 1e3,
        weekend=lambda x: x["ds"].dt.dayofweek.isin([5, 6]),
        Working_Day=lambda x: ~x["Holiday"] & ~x["weekend"],
        Cooling=lambda x: x["Temperature"].clip(lower=18),
        unique_id="VIC",
    )
    .drop(columns=["weekend", "Holiday"])
)
Code
fig, ax = plt.subplots()
sns.scatterplot(data=vic_elec_df, x="Temperature", y="Demand",
    hue="Working_Day", palette="tab10", alpha=0.6, ax=ax)
ax.set(
    xlabel="Maximum daily temperature",
    ylabel="Electricity demand (GW)",
)
plt.show()

Figure 12.7: Half-hourly electricity demand for Victoria, plotted against temperatures for the same times in Melbourne, the largest city in Victoria.

We will fit a regression model with a piecewise linear function of temperature (containing a knot at 18 degrees), and harmonic regression terms to allow for the daily seasonal pattern. Again, we set the orders of the Fourier terms subjectively, while using the AICc to select the order of the ARIMA errors.

Code
features = [
    partial(fourier, season_length=2 * 24, k=10),
    partial(fourier, season_length=2 * 24 * 7, k=5),
    partial(fourier, season_length=2 * 24 * 7 * 365, k=3),
]
vic_elec_fourier, vic_elec_futr_fourier = pipeline(
    vic_elec_df, features=features, freq="30min", h=2 * 48)
model = AutoARIMA(max_d=0, seasonal=False, nmodels=20)
sf = StatsForecast(models=[model], freq="30min")
sf = sf.fit(vic_elec_fourier, target_col="Demand")

Forecasting with such models is difficult because we require future values of the predictor variables. Future values of the Fourier terms are easy to compute, but future temperatures are, of course, unknown. If we are only interested in forecasting up to a week ahead, we could use temperature forecasts obtained from a meteorological model. Alternatively, we could use scenario forecasting (Section 6.5) and plug in possible temperature patterns. In the following example, we have used a repeat of the last two days of temperatures to generate future possible demand values.

futr_df = (
    vic_elec_df.assign(ds=lambda x: x["ds"] + pd.to_timedelta("2d"))
    .drop(columns="Demand")
    .merge(vic_elec_futr_fourier)
)
level = [80, 95]
fc = sf.predict(h=len(futr_df), X_df=futr_df, level=level)
Code
fig, ax = plt.subplots()
plot_series(
    vic_elec_fourier, fc, level=level, max_insample_length=10 * 48,
    target_col="Demand", xlabel="Time [30m]", ylabel="Demand (MWh)",
    title="Half hourly electricity demand: Victoria",
    palette="black_and_blue", rm_legend=False, ax=ax,
    legend_loc="outside lower center")
ax.xaxis.set_major_locator(MaxNLocator(4))
fig

Figure 12.8: Forecasts from a dynamic harmonic regression model applied to half-hourly electricity demand data.

Although the short-term forecasts look reasonable, this is a crude model for a complicated process. The residuals, plotted in Figure 12.9, demonstrate that there is a lot of information that has not been captured with this model.

Code
residuals = sf.fitted_[0, 0].model_["residuals"]
fig, axes = plt.subplot_mosaic([["resid", "resid"], ["acf", "hist"]])
axes["resid"].plot(vic_elec_fourier["ds"], residuals, marker=".")
axes["resid"].set(xlabel="Time", ylabel="Innovation Residuals")
plot_acf(residuals, axes["acf"], zero=False, auto_ylims=True,
    bartlett_confint=False)
axes["acf"].xaxis.set_major_locator(MaxNLocator(integer=True))
axes["acf"].set(xlabel="lag [30m]", ylabel="acf", title="")
axes["hist"].hist(residuals, bins=20)
axes["hist"].set(xlabel="residuals", ylabel="count")
plt.show()

Figure 12.9: Residual diagnostics for the dynamic harmonic regression model.

More sophisticated versions of this model which provide much better forecasts are described in Hyndman & Fan (2010) and Fan & Hyndman (2012).

12.2 Prophet model

A recent proposal is the Prophet model, available via PyPI. This model was introduced by Facebook (Taylor & Letham, 2018), originally for forecasting daily data with weekly and yearly seasonality, plus holiday effects. It was later extended to cover more types of seasonal data. It works best with time series that have strong seasonality and several seasons of historical data.

Prophet can be considered a nonlinear regression model (Chapter 7), of the form y_t = g(t) + s(t) + h(t) + \varepsilon_t, where g(t) describes a piecewise-linear trend (or “growth term”), s(t) describes the various seasonal patterns, h(t) captures the holiday effects, and \varepsilon_t is a white noise error term.

  • The knots (or changepoints) for the piecewise-linear trend are automatically selected if not explicitly specified. Optionally, a logistic function can be used to set an upper bound on the trend.
  • The seasonal component consists of Fourier terms of the relevant periods. By default, order 10 is used for annual seasonality and order 3 is used for weekly seasonality.
  • Holiday effects are added as simple dummy variables.
  • The model is estimated using a Bayesian approach to allow for automatic selection of the changepoints and other model characteristics.

We illustrate the approach using two data sets: a simple quarterly example, and then the electricity demand data described in the previous section.

Example: Quarterly cement production

For this simple quarterly example, we will repeat the analysis from Section 9.10 in which we compared an ARIMA and ETS model, but we will add in a Prophet model for comparison. By default, Prophet fits weekly and yearly seasonalities. Since we have quarterly data, we need to specify this directly using add_seasonality.

Code
aus_production = pd.read_csv("data/aus_production.csv",
    parse_dates=["ds"])
cement = (
    aus_production.loc[lambda x: x["ds"] >= "1988"]
    [["ds", "Cement"]]
    .rename(columns={"Cement": "y"})
    .assign(unique_id="Cement")
)
train = cement.loc[lambda x: x["ds"] < "2008"]
test = cement.loc[lambda x: x["ds"] >= "2008"]

m = Prophet(weekly_seasonality=False)
m.add_seasonality(name="quarterly", period=4, fourier_order=2)
m.fit(train)

future = m.make_future_dataframe(periods=10, freq="QS")
fc_prophet = (
    m.predict(future)[["ds", "yhat", "yhat_lower", "yhat_upper"]]
    .rename(columns={
        "yhat": "Prophet",
        "yhat_lower": "Prophet-lo-80",
        "yhat_upper": "Prophet-hi-80",
    })
)

models = [AutoARIMA(season_length=4), AutoETS(season_length=4)]
sf = StatsForecast(models=models, freq="QS")
level = [80]
fc_stats = sf.forecast(df=train, h=10, level=level)
fc = fc_stats.merge(fc_prophet, on="ds")
Code
plot_series(cement, fc, level=level,
    xlabel="Quarter", ylabel="Cement",
    title="Cement production: Australia",
    palette="black_and_3color", rm_legend=False,
    legend_loc="outside lower center",
)

Figure 12.10: Forecasts of the Australian cement production data using Prophet and StatsForecast.

In this example, the Prophet forecasts are worse than either the ETS or ARIMA forecasts.

evaluation = (
    evaluate(
        fc.merge(test, on=["unique_id", "ds"]),
        metrics=[rmse, mae, mape, partial(mase, seasonality=4)],
        train_df=cement,
    )
    .drop(columns="unique_id")
    .sort_values("metric")
    .assign(metric=lambda x: x["metric"].str.upper())
    .set_index("metric").rename_axis("")
    .transpose()
    .rename_axis("Method").reset_index()
    .assign(MAPE=lambda x: 100 * x["MAPE"])
)
evaluation
Method MAE MAPE MASE RMSE
0 AutoARIMA 169 7.9 1.12 195
1 AutoETS 191 8.9 1.27 222
2 Prophet 253 11.7 1.68 296

Example: Half-hourly electricity demand

We will now fit a prophet model to half-hourly electricity demand data, and compare its results with the MSTL model from the previous section.

Code
vic_demand = (
    vic_elec.loc[lambda x: x["unique_id"] == "Demand"]
    .drop(columns="Holiday")
    .rename(columns={"Demand": "y"})
    .assign(unique_id="VIC")
    [["unique_id", "ds", "y"]]
)
train = vic_demand.iloc[:-48]
test = vic_demand.iloc[-48:]

m = Prophet()
m.add_seasonality(name="hourly", period=48, fourier_order=5)
m.add_seasonality(name="subhourly", period=48 * 7, fourier_order=5)
m.fit(train)
future = m.make_future_dataframe(periods=48 * 2, freq="30min")
fc_prophet = (
    m.predict(future)
    .rename(columns={
        "yhat": "Prophet",
        "yhat_lower": "Prophet-lo-80",
        "yhat_upper": "Prophet-hi-80",
    })
    .assign(resid=lambda x: train["y"] - x["Prophet"])
)
renames = {
    "multiplicative_terms": "multiplicative_term",
    "additive_terms": "additive_term",
}
df = fc_prophet.merge(train[["ds", "y"]]).rename(columns=renames)
columns = ["y", "trend", "multiplicative_term", "additive_term", "resid"]
Code
fig, axes = plt.subplots(5, sharex=True, figsize=(8, 7))
for (ax, column) in zip(axes, columns):
    sns.lineplot(data=df, x="ds", y=column, ax=ax, color="k")
    ax.set_ylabel(column.replace("_", "\n"))
axes[0].set_title(
  "Demand = trend * (1 + multiplicative_term) + additive_term + resid",
  loc="left")
axes[-1].set(xlabel="", xticks=["2012", "2013", "2014", "2015"])
fig.suptitle("Prophet decomposition")
plt.show()

Figure 12.11: Components of a Prophet model fitted to the Victorian electricity demand

Figure 12.11 shows the trend and seasonal components of the fitted model.

The model specification is very similar to the DHR model in the previous section, although the result is different in several important ways. The Prophet model adds a piecewise linear time trend which is not really appropriate here as we don’t expect the long term forecasts to continue to follow the downward linear trend at the end of the series.

There is also substantial remaining autocorrelation in the residuals.

Code
resid = fc_prophet[["ds", "resid"]].dropna()
fig, axes = plt.subplot_mosaic([["resid", "resid"], ["acf", "hist"]])
axes["resid"].plot(resid["ds"], resid["resid"], marker=".")
axes["resid"].set(xlabel="Time", ylabel="Innovation Residuals",
    xticks=["2012", "2013", "2014", "2015"])
plot_acf(resid["resid"], axes["acf"], zero=False, auto_ylims=True,
    bartlett_confint=False)
axes["acf"].xaxis.set_major_locator(MaxNLocator(integer=True))
axes["acf"].set(xlabel="lag [30m]", ylabel="acf", title="")
axes["hist"].hist(resid["resid"], bins=20)
axes["hist"].set(xlabel="residuals", ylabel="count")
plt.show()

Figure 12.12: Residuals from the Prophet model for Victorian electricity demand.

The prediction intervals would be narrower if the autocorrelations were taken into account. Now let’s compare these forecasts against MSTL.

sf = StatsForecast(models=[MSTL(season_length=[48, 48 * 7])], freq="30min")
level = [80]
fc_stats = sf.forecast(df=train, h=48, level=level)

prophet_columns = ["ds", "Prophet", "Prophet-lo-80", "Prophet-hi-80"]
fc = fc_stats.merge(fc_prophet[prophet_columns], on="ds")

Code
plot_series(vic_demand, fc, level=level,
    max_insample_length=48 * 7,
    xlabel="Date", ylabel="Demand (MWh)", title = "Demand (MWh)",
    palette="black_and_2color", rm_legend=False,
    legend_loc="outside lower center",
)

Figure 12.13: Two day forecasts from the Prophet and the MSTL model for Victorian electricity demand.
Code
evaluation = (
    evaluate(
        fc.merge(test, on=["unique_id", "ds"]),
        metrics=[rmse, mae, mape, partial(mase, seasonality=4)],
        train_df=train,
    )
    .drop(columns="unique_id")
    .sort_values("metric")
    .assign(metric=lambda x: x["metric"].str.upper())
    .set_index("metric").rename_axis("")
    .transpose()
    .rename_axis("Method").reset_index()
    .assign(MAPE=lambda x: 100 * x["MAPE"])
)
evaluation
Method MAE MAPE MASE RMSE
0 MSTL 139 3.5 0.36 165
1 Prophet 461 11.6 1.21 512

Prophet rarely gives better forecast accuracy than the alternative approaches, as these two examples have illustrated. However, it is a popular model for forecasting, with thousands of stars on GitHub and used as a dependency in hundreds of repositories. Its popularity can partly be explained by its open-source nature and ease of use. However, several studies, such as Garza et al. (2022) and Menculini et al. (2021), have shown that it does not perform well in terms of time and accuracy.

StatsForecast offers an adapter to Prophet with improved performance, as shown by Garza et al. (2022). It returns the best ARIMA model using Prophet’s feature preprocessing. To use it, simply replace Prophet with AutoARIMAProphet, as shown below using the previous example.

Code
m = AutoARIMAProphet()
m.add_seasonality(name="hourly", period=48, fourier_order=5)
m.add_seasonality(name="subhourly", period=48 * 7, fourier_order=5)
m.fit(train)

future = m.make_future_dataframe(periods=48 * 2, freq="30T")
fc_ap = m.predict(future)

There is a more advanced model called NeuralProphet that combines neural networks with traditional time series algorithms and was inspired by Prophet. This model is available through PyPI and can be set up in a way similar to Prophet.

12.3 Vector autoregressions

One limitation of the models that we have considered so far is that they impose a unidirectional relationship — the forecast variable is influenced by the predictor variables, but not vice versa. However, there are many cases where the reverse should also be allowed for — where all variables affect each other. In previous chapters, the changes in personal consumption expenditure (C_t) were forecast based on the changes in personal disposable income (I_t). However, in this case a bi-directional relationship may be more suitable: an increase in I_t will lead to an increase in C_t and vice versa.

An example of such a situation occurred in Australia during the Global Financial Crisis of 2008–2009. The Australian government issued stimulus packages that included cash payments in December 2008, just in time for Christmas spending. As a result, retailers reported strong sales and the economy was stimulated. Consequently, incomes increased.

Such feedback relationships are allowed for in the vector autoregressive (VAR) framework. In this framework, all variables are treated symmetrically. They are all modelled as if they all influence each other equally. In more formal terminology, all variables are now treated as “endogenous”. To signify this, we now change the notation and write all variables as ys: y_{1,t} denotes the tth observation of variable y_1, y_{2,t} denotes the tth observation of variable y_2, and so on.

A VAR model is a generalisation of the univariate autoregressive model for forecasting a vector of time series.1 It comprises one equation per variable in the system. The right hand side of each equation includes a constant and lags of all the variables in the system. To keep it simple, we will consider a two variable VAR with one lag. We write a 2-dimensional VAR(1) model as \begin{aligned} y_{1,t} &= c_1+\phi _{11,1}y_{1,t-1}+\phi _{12,1}y_{2,t-1}+\varepsilon_{1,t} \\ y_{2,t} &= c_2+\phi _{21,1}y_{1,t-1}+\phi _{22,1}y_{2,t-1}+\varepsilon_{2,t}, \end{aligned} \tag{12.1} where \varepsilon_{1,t} and \varepsilon_{2,t} are white noise processes that may be contemporaneously correlated. The coefficient \phi_{ii,\ell} captures the influence of the \ellth lag of variable y_i on itself, while the coefficient \phi_{ij,\ell} captures the influence of the \ellth lag of variable y_j on y_i.

If the series are stationary, we forecast them by fitting a VAR to the data directly (known as a “VAR in levels”). If the series are non-stationary, we take differences of the data in order to make them stationary, then fit a VAR model (known as a “VAR in differences”). In both cases, the models are estimated equation by equation using the principle of least squares. For each equation, the parameters are estimated by minimising the sum of squared \varepsilon_{i,t} values.

The other possibility, which is beyond the scope of this book and therefore we do not explore here, is that the series may be non-stationary but cointegrated, which means that there exists a linear combination of them that is stationary. In this case, a VAR specification that includes an error correction mechanism (usually referred to as a vector error correction model) should be included, and alternative estimation methods to least squares estimation should be used.2

Forecasts are generated from a VAR in a recursive manner. The VAR generates forecasts for each variable included in the system. To illustrate the process, assume that we have fitted the 2-dimensional VAR(1) model described in Equations 12.1, for all observations up to time T. Then the one-step-ahead forecasts are generated by \begin{align*} \hat y_{1,T+1|T} &=\hat{c}_1+\hat\phi_{11,1}y_{1,T}+\hat\phi_{12,1}y_{2,T} \\ \hat y_{2,T+1|T} &=\hat{c}_2+\hat\phi _{21,1}y_{1,T}+\hat\phi_{22,1}y_{2,T}. \end{align*} This is the same form as Equations 12.1, except that the errors have been set to zero and parameters have been replaced with their estimates. For h=2, the forecasts are given by \begin{align*} \hat y_{1,T+2|T} &=\hat{c}_1+\hat\phi_{11,1}\hat y_{1,T+1|T}+\hat\phi_{12,1}\hat y_{2,T+1|T}\\ \hat y_{2,T+2|T}&=\hat{c}_2+\hat\phi_{21,1}\hat y_{1,T+1|T}+\hat\phi_{22,1}\hat y_{2,T+1|T}. \end{align*} Again, this is the same form as Equations 12.1, except that the errors have been set to zero, the parameters have been replaced with their estimates, and the unknown values of y_1 and y_2 have been replaced with their forecasts. The process can be iterated in this manner for all future time periods.

There are two decisions one has to make when using a VAR to forecast, namely how many variables (denoted by K) and how many lags (denoted by p) should be included in the system. The number of coefficients to be estimated in a VAR is equal to K+pK^2 (or 1+pK per equation). For example, for a VAR with K=5 variables and p=3 lags, there are 16 coefficients per equation, giving a total of 80 coefficients to be estimated. The more coefficients that need to be estimated, the larger the estimation error entering the forecast.

In practice, it is usual to keep K small and include only variables that are correlated with each other, and therefore useful in forecasting each other. Information criteria are commonly used to select the number of lags to be included. Care should be taken when using the AICc as it tends to choose large numbers of lags; instead, for VAR models, we often use the BIC instead. A more sophisticated version of the model is a “sparse VAR” (where many coefficients are set to zero); another approach is to use “shrinkage estimation” (where coefficients are smaller).

A criticism that VARs face is that they are atheoretical; that is, they are not built on some economic theory that imposes a theoretical structure on the equations. Every variable is assumed to influence every other variable in the system, which makes a direct interpretation of the estimated coefficients difficult. Despite this, VARs are useful in several contexts:

  1. forecasting a collection of related variables where no explicit interpretation is required;
  2. testing whether one variable is useful in forecasting another (the basis of Granger causality tests);
  3. impulse response analysis, where the response of one variable to a sudden but temporary change in another variable is analysed;
  4. forecast error variance decomposition, where the proportion of the forecast variance of each variable is attributed to the effects of the other variables.

Example: A VAR model for forecasting US consumption

You can use a VAR model in Python via the statsmodels.tsa.api module. Note that you need to set the dates as the index of the DataFrame before fitting the model. We will fit two models: one using the AIC and another using the BIC.

Code
us_change = (
    pd.read_csv("data/US_change.csv", parse_dates=["ds"])
    .rename(columns={"y": "Consumption"})
    .drop(columns=["unique_id"])
    .set_index("ds").asfreq("QS")
)
model = VAR(us_change)
fit_aic = model.fit(ic="aic")
fit_bic = model.fit(ic="bic")
print("Lags selected by AIC:", fit_aic.k_ar)
print("Lags selected by BIC:", fit_bic.k_ar)
Lags selected by AIC: 5
Lags selected by BIC: 1

A VAR(5) model is selected using the AICc (the default), while a VAR(1) model is selected using the BIC. This is not unusual — the BIC will always select a model that has fewer parameters than the AICc model as it imposes a stronger penalty for the number of parameters.

Code
fig, axes = plt.subplots(4, figsize=(8, 7), sharex=True)

def plot_one_acf(series, name, ax):
    plot_acf(
        series, ax=ax,
        title=f"ACF of {name} Model Residuals - {series.name}",
        zero=False, auto_ylims=True, bartlett_confint=False)

plot_one_acf(fit_aic.resid["Consumption"], "VAR(5)", ax=axes[0])
plot_one_acf(fit_aic.resid["Income"], "VAR(5)", ax=axes[1])
plot_one_acf(fit_bic.resid["Consumption"], "VAR(1)", ax=axes[2])
plot_one_acf(fit_bic.resid["Income"], "VAR(1)", ax=axes[3])
fig.supylabel("acf")
fig.supxlabel("lag [1Q]")
plt.show()

Figure 12.14: ACF of the residuals from the two VAR models. A VAR(5) model is selected by the AICc, while a VAR(1) model is selected using the BIC.

From Figure 12.14, we see that the residuals from the VAR(1) model (BIC) have significant autocorrelation for Consumption, while the VAR(5) model has effectively captured all the information in the data.

The forecasts generated by the VAR(5) model are plotted in Figure 12.15. Note that forecast_interval generates both the mean forecast and the (1-\alpha)\% prediction interval.

Code
lag_order = fit_aic.k_ar
y = us_change.tail(lag_order).to_numpy()
fc80 = fit_aic.forecast_interval(y, steps=8, alpha=0.2)
fc95 = fit_aic.forecast_interval(y, steps=8, alpha=0.05)
fc_var5 = pd.concat([
    pd.DataFrame({
        "unique_id": column,
        "ds": pd.date_range(start="2019-07-01", periods=8, freq="QS"),
        "VAR_5": fc80[0][:, i],
        "VAR_5-lo-80": fc80[1][:, i],
        "VAR_5-hi-80": fc80[2][:, i],
        "VAR_5-lo-95": fc95[1][:, i],
        "VAR_5-hi-95": fc95[2][:, i],
    })
    for (i, column) in enumerate(["Consumption", "Income"])
])

Code
columns = ["Consumption", "Income"]
plot_us_change = (
    us_change[columns]
    .reset_index()
    .melt(id_vars="ds", var_name="unique_id", value_name="y")
)
fig, axes = plt.subplots(1, 2, figsize=(8, 4))
plot_series(plot_us_change, fc_var5, level=[80, 95],
    max_insample_length=4 * 20, xlabel="", ylabel="",
    palette="black_and_blue", ax=axes, rm_legend=False,
    legend_loc="outside lower center")
fig.supxlabel("Quarter")
fig

Figure 12.15: Forecasts for US consumption and income generated from a VAR(5) model.

12.4 Bootstrapping and bagging

Bootstrapping time series

In Section 5.5, we bootstrap the residuals of a time series in order to simulate future values of a series using a model. More generally, we can generate new time series that are similar to our observed series, using another type of bootstrap.

First, the time series is transformed if necessary, and then decomposed into trend, seasonal and remainder components using STL. Then we obtain shuffled versions of the remainder component to get bootstrapped remainder series. Because there may be autocorrelation present in an STL remainder series, we cannot simply use the re-draw procedure that was described in Section 5.5. Instead, we use a “blocked bootstrap”, where contiguous sections of the time series are selected at random and joined together. These bootstrapped remainder series are added to the trend and seasonal components, and the transformation is reversed to give variations on the original time series.

Consider the quarterly cement production in Australia from 1988 Q1 to 2010 Q2. First we check (see Figure 12.16) that the decomposition has adequately captured the trend and seasonality, and that there is no obvious remaining signal in the remainder series. (In StatsForecast, an STL decomposition is defined by specifying a single seasonality in MSTL()).

Code
sf = StatsForecast(models=[MSTL(season_length=4)], freq="QS")
sf = sf.fit(cement)
dcmp = sf.fitted_[0, 0].model_.assign(ds=cement["ds"].to_numpy())

fig, axes = plt.subplots(4, sharex=True)
columns = dcmp.columns[:-1]
for (ax, column) in zip(axes, columns):
    sns.lineplot(data=dcmp, x="ds", y=column, ax=ax, color="black")
axes[0].set_title("Cement = trend + seasonal + remainder", loc="left")
axes[-1].set(xlabel="Quarter")
fig.suptitle("STL decomposition")
plt.show()

Figure 12.16: STL decomposition of quarterly Australian cement production.

Now we can generate several bootstrapped versions of the data. We will use a block size of 8 to cover two years of data and the blocked_bootstrap() function to generate the simulated data from the STL decomposition we have just obtained.

Code
def blocked_bootstrap(dcmp, block_size, num_sims):
    sim = {}
    num_blocks = np.ceil(len(dcmp) / block_size).astype(int)
    for i in range(num_sims):
        blocks = []
        for _ in range(num_blocks):
            start = np.random.randint(0, len(dcmp) - block_size + 1)
            end = start + block_size
            blocks.append(dcmp["remainder"].iloc[start:end])
        sim[str(i)] = (
            dcmp["trend"]
            + dcmp["seasonal"]
            + pd.concat(blocks, ignore_index=True)[0 : len(dcmp)]
        )
    sim_df = pd.DataFrame(sim)
    return sim_df

sim_df = blocked_bootstrap(dcmp, block_size=8, num_sims=10).assign(
    unique_id=cement.unique_id.iloc[0],
    ds=cement["ds"].to_numpy(),
)
fig, ax = plt.subplots()
plot_series(cement, sim_df,
    xlabel="Quarter [1Q]", ylabel="Tonnes ('000)",
    palette="tab10", ax=ax)
# Re-draw original `cement` time series on top and in black
ax.plot("ds", "y", "k-", data=cement)
fig

Figure 12.17: Ten bootstrapped versions of quarterly Australian cement production (coloured), along with the original data (black).

Bagged forecasts

One use for these bootstrapped time series is to improve forecast accuracy. If we produce forecasts from each of the additional time series, and average the resulting forecasts, we get better forecasts than if we simply forecast the original time series directly. This is called “bagging” which stands for “bootstrap aggregating”.

We demonstrate the idea using the cement data. First, we simulate many time series that are similar to the original data using bootstrapping.

sim_df = blocked_bootstrap(dcmp, 8, 100).assign(
    unique_id="Cement",
    ds=cement["ds"].to_numpy()
)

For each of these series, we fit an ETS model. A different ETS model is selected for each case, although all models will be similar because the series themselves are similar. However, since the estimated parameters will be slightly different, the forecasts will also be different. This can be a time-consuming process due to the large number of series involved.

Code
sf = StatsForecast(models=[AutoETS(season_length=4)], freq="QS")
forecasts = []
bootstrap_columns = sim_df.drop(columns=["ds", "unique_id"]).columns
for column in bootstrap_columns:
    train = (
        sim_df[["unique_id", "ds", column]]
        .rename(columns={column: "y"})
    )
    fc = (
        sf.forecast(df=train, h=10)
        .rename(columns={"AutoETS": f"AutoETS_{column}"})
    )
    forecasts.append(fc)

forecasts_df = reduce(lambda left, right: left.merge(right), forecasts)
fig, ax = plt.subplots()
plot_series(cement, forecasts_df,
    xlabel="Quarter [1Q]", ylabel="Tonnes ('000)",
    title="Cement production: bootstrapped forecasts",
    palette="tab20", ax=ax)
ax.plot("ds", "y", "k-", data=cement)
fig

Figure 12.18: Forecasts of 100 bootstrapped series obtained using AutoETS models.

Finally, we average these forecasts for each time period to obtain the “bagged forecasts” for the original data. We also generate an ETS model applied directly to the data for comparison.

Code
fc_bootstrap_avg = (
    forecasts_df
    .set_index(["unique_id", "ds"])
    .mean(axis="columns").rename("AvgAutoETS")
    .reset_index()
)
fc_ets = sf.forecast(df=cement, h=10)

fig, ax = plt.subplots()
plot_series(cement, fc_bootstrap_avg.merge(fc_ets),
    xlabel="Quarter [1Q]", ylabel="Tonnes ('000)",
    title="Cement production: bootstrapped forecasts",
    palette="black_and_2color", rm_legend=False, ax=ax,
    legend_loc="outside lower center")
fig

Figure 12.19: Comparing bagged AutoETS forecasts (the average of 100 bootstrapped forecasts) and AutoETS applied directly to the data.

Bergmeir et al. (2016) show that, on average, bagging gives better forecasts than just applying AutoETS() directly. Of course, it is slower because a lot more computation is required.

12.5 Exercises

  1. Compare STL and Dynamic Harmonic Regression forecasts for one of the series in the pedestrian data set.

    1. Try modifying the order of the Fourier terms to minimise the AICc value.
    2. Check the residuals for each model. Do they capture the available information in the data?
    3. Which of the two sets of forecasts are best? Explain.
  2. Consider the weekly data on US finished motor gasoline products supplied (millions of barrels per day) (series us_gasoline).

    1. Use the Prophet model to forecast the next 13 weeks of data.
    2. Repeat the previous exercise using the AutoARIMA() and the AutoETS() classes from StatsForecast.
    3. Compare the accuracy of the models you generated.

12.6 Further reading

  • The Prophet model is described in Taylor & Letham (2018).
  • Pfaff (2008) provides a book-length overview of VAR modelling and other multivariate time series models.
  • A current survey of the use of recurrent neural networks for forecasting is provided by Hewamalage et al. (2021).
  • Bootstrapping for time series is discussed in Lahiri (2003).
  • Bagging for time series forecasting is relatively new. Bergmeir et al. (2016) is one of the few papers which addresses this topic.

12.7 Used modules and classes

StatsForecast

  • StatsForecast class- Core forecasting engine
  • MSTL() model - For multiple seasonal decomposition
  • AutoARIMA() model - For automatic ARIMA modelling
  • AutoETS() model - For automatic exponential smoothing
  • AutoARIMAProphet() adapter - Prophet adapter with improved performance

UtilsForecast

  • plot_series() utility - For creating time series visualisations
  • evaluate() utility - For model evaluation metrics

  1. A more flexible generalisation would be a Vector ARMA process. However, the relative simplicity of VARs has led to their dominance in forecasting. Interested readers may refer to Athanasopoulos et al. (2012).↩︎

  2. Interested readers should refer to Hamilton (1994) and Lütkepohl (2007).↩︎