Global forecasting models: multiple time series forecasting with skforecast

If you like  Skforecast ,  please give us a star on   GitHub! ⭐️

Global Forecasting Models: Multi-series Forecasting with Python and Skforecast

Joaquín Amat Rodrigo, Javier Escobar Ortiz
October 2022 (last update July 2023)

Multi-series forecasting

In univariate time series forecasting, a single time series is modeled as a linear or nonlinear combination of its lags. That is, the past values of the series are used to forecast its future. In multi-series forecasting, two or more time series are modeled together using a single model. Two strategies can be distinguished:

Independent multi-series forecasting

In independent multi-series forecasting a single model is trained for all time series, but each time series remains independent of the others, meaning that past values of one series are not used as predictors of other series. However, modeling them together is useful because the series may follow the same intrinsic pattern regarding their past and future values. For instance, the sales of products A and B in the same store may not be related, but they follow the same dynamics, that of the store.

To predict the next n steps, the strategy of recursive multi-step forecasting is applied, with the only difference being that the series name for which to estimate the predictions needs to be indicated.


Dependent multi-series forecasting (Multivariate forecasting)

In dependent multi-series forecasting (multivariate time series), all series are modeled together in a single model, considering that each time series depends not only on its past values but also on the past values of the other series. The forecaster is expected not only to learn the information of each series separately but also to relate them. An example is the measurements made by all the sensors (flow, temperature, pressure...) installed on an industrial machine such as a compressor.

🖉 Note

ForecasterAutoregMultiSeries and ForecasterAutoregMultiSeriesCustom classes cover the use case of independent multi-series forecasting. API Reference ForecasterAutoregMultiVariate class covers the use case of dependent multi-series forecasting (Multivariate forecasting). API Reference

Advantages and limitations

Multi-series forecasts do not always outperform single-series forecasts. Which one works best depends largely on the characteristics of the use case to which they are applied. However, the following heuristic is worth keeping in mind:

Advantages of multi-series

  • It is easier to maintain and monitor a single model than several.

  • Since all time series are combined during training, the model has a higher learning capacity even if the series are short.

  • By combining multiple time series, the model can learn more generalizable patterns.

Disadvantages of multi-series-

  • If the series do not follow the same internal dynamics, the model may learn a pattern that does not represent any of them.

  • The series may mask each other, so the model may not predict all of them with the same performance.

  • It is more computationally demanding (time and resources) to train and backtest a big model than several small ones.

Case of study

The objective of this study is to compare the forecasting performance achieved by a multi-series model versus using a different model for each series.

Data has been obtained from Store Item Demand Forecasting Challenge. This dataset contains 913,000 sales transactions from 2013–01–01 to 2017–12–31 for 50 products (SKU) in 10 stores. The goal is to predict the next 7 days sales for 50 different items in one store using the available 5 years history.

Libraries

In [46]:
# Libraries
# ======================================================================================
import numpy as np
import pandas as pd
from tqdm import tqdm
import matplotlib.pyplot as plt
plt.style.use('seaborn-v0_8-darkgrid')
from statsmodels.graphics.tsaplots import plot_acf

from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import  HistGradientBoostingRegressor
from lightgbm import LGBMRegressor

from skforecast.ForecasterAutoregMultiSeries import ForecasterAutoregMultiSeries
from skforecast.ForecasterAutoreg import ForecasterAutoreg
from skforecast.model_selection import backtesting_forecaster
from skforecast.model_selection import grid_search_forecaster
from skforecast.model_selection_multiseries import backtesting_forecaster_multiseries
from skforecast.model_selection_multiseries import grid_search_forecaster_multiseries

Data

In [47]:
# Data download
# ======================================================================================
data = pd.read_csv('./train_stores_kaggle.csv')
display(data)
print(f"Shape: {data.shape}")
date store item sales
0 2013-01-01 1 1 13
1 2013-01-02 1 1 11
2 2013-01-03 1 1 14
3 2013-01-04 1 1 13
4 2013-01-05 1 1 10
... ... ... ... ...
912995 2017-12-27 10 50 63
912996 2017-12-28 10 50 59
912997 2017-12-29 10 50 74
912998 2017-12-30 10 50 62
912999 2017-12-31 10 50 82

913000 rows × 4 columns

Shape: (913000, 4)
In [48]:
# Data preprocessing
# ======================================================================================
selected_store = 2
selected_items = data.item.unique() # All items
#selected_items = [1, 2, 3, 4, 5] # Selection of items to reduce computation time

data = data[(data['store'] == selected_store) & (data['item'].isin(selected_items))].copy()
data['date'] = pd.to_datetime(data['date'], format='%Y-%m-%d')
data = pd.pivot_table(
           data    = data,
           values  = 'sales',
           index   = 'date',
           columns = 'item'
       )
data.columns.name = None
data.columns = [f"item_{col}" for col in data.columns]
data = data.asfreq('1D')
data = data.sort_index()
data.head(4)
Out[48]:
item_1 item_2 item_3 item_4 item_5 item_6 item_7 item_8 item_9 item_10 ... item_41 item_42 item_43 item_44 item_45 item_46 item_47 item_48 item_49 item_50
date
2013-01-01 12 41 19 21 4 34 39 49 28 51 ... 11 25 36 12 45 43 12 45 29 43
2013-01-02 16 33 32 14 6 40 47 42 21 56 ... 19 21 35 25 50 52 13 37 25 57
2013-01-03 16 46 26 12 12 41 43 46 29 46 ... 23 20 52 18 56 30 5 45 30 45
2013-01-04 20 50 34 17 16 41 44 55 32 56 ... 15 28 50 24 57 46 19 32 20 45

4 rows × 50 columns

⚠ Warning

Modeling 50 elements may require significant computational time. Feel free to select only a subset of items to speed up the execution.

The dataset is divided into 3 partitions: one for training, one for validation, and one for testing.

In [49]:
# Split data into train-validation-test
# ======================================================================================
end_train = '2016-05-31 23:59:00'
end_val = '2017-05-31 23:59:00'

data_train = data.loc[:end_train, :].copy()
data_val   = data.loc[end_train:end_val, :].copy()
data_test  = data.loc[end_val:, :].copy()

print(f"Train dates      : {data_train.index.min()} --- {data_train.index.max()}  (n={len(data_train)})")
print(f"Validation dates : {data_val.index.min()} --- {data_val.index.max()}  (n={len(data_val)})")
print(f"Test dates       : {data_test.index.min()} --- {data_test.index.max()}  (n={len(data_test)})")
Train dates      : 2013-01-01 00:00:00 --- 2016-05-31 00:00:00  (n=1247)
Validation dates : 2016-06-01 00:00:00 --- 2017-05-31 00:00:00  (n=365)
Test dates       : 2017-06-01 00:00:00 --- 2017-12-31 00:00:00  (n=214)

Four of the series are plotted to understand their trends and patterns. The reader is strongly encouraged to plot several more to gain an in-depth understanding of the series.

In [50]:
# Plot time series
# ======================================================================================
fig, ax = plt.subplots(figsize=(8, 5))
data.iloc[:, :4].plot(
    legend   = True,
    subplots = True, 
    sharex   = True,
    title    = 'Sales of store 2',
    ax       = ax, 
)
fig.tight_layout();
/tmp/ipykernel_17322/27394305.py:4: UserWarning: To output multiple subplots, the figure containing the passed axes is being cleared.
  data.iloc[:, :4].plot(
In [51]:
# Autocorrelation plot
# ======================================================================================
fig, axes = plt.subplots(nrows=4, ncols=1, figsize=(8, 5), sharex=True)
axes = axes.flat
for i, col in enumerate(data.columns[:4]):
    plot_acf(data[col], ax=axes[i], lags=7*5)
    axes[i].set_ylim(-1, 1.1)
    axes[i].set_title(f'{col}')
fig.tight_layout()
plt.show()

The autocorrelation plots show a clear association between sales on one day and sales on the same day a week earlier. This type of correlation is an indication that autoregressive models can work well.

There is also a common weekly seasonality between the series. The more similar the dynamics between the series, the more likely the model will learn useful patterns.

Individual forecaster for each product

A different model is trained for each item of the store and its mean absolute error is estimated using backtesting.

In [52]:
# Train and backtest a model for each item: ForecasterAutoreg
# ======================================================================================
items = []
mae_values = []
predictions = {}

for i, item in enumerate(tqdm(data.columns)):

    # Define forecaster
    forecaster = ForecasterAutoreg(
                     regressor     = HistGradientBoostingRegressor(random_state=123),
                     lags          = 14,
                     transformer_y = StandardScaler()
                 )

    # Backtesting forecaster
    metric, preds = backtesting_forecaster(
                        forecaster         = forecaster,
                        y                  = data[item],
                        initial_train_size = len(data_train) + len(data_val),
                        steps              = 7,
                        metric             = 'mean_absolute_error',
                        refit              = False,
                        fixed_train_size   = False,
                        verbose            = False,
                        show_progress      = False
                    )

    items.append(item)
    mae_values.append(metric)
    predictions[item] = preds

# Results
uni_series_mae = pd.Series(
                     data  = mae_values,
                     index = items,
                     name  = 'uni_series_mae'
                 )
100%|██████████| 50/50 [00:32<00:00,  1.53it/s]

Multiseries forecaster

A single multi-series model is trained to predict the sales of each product over the next 7 days. As in the predict method, the levels at which backtesting is performed must be indicated. The argument can also be set to None to perform backtesting at all levels.

In [53]:
# Train and backtest a model for all items: ForecasterAutoregMultiSeries
# ======================================================================================
items = list(data.columns)

# Define forecaster
forecaster_ms = ForecasterAutoregMultiSeries(
                    regressor          = HistGradientBoostingRegressor(random_state=123),
                    lags               = 14,
                    transformer_series = StandardScaler(),
                )

# Backtesting forecaster for all items
multi_series_mae, predictions_ms = backtesting_forecaster_multiseries(
                                       forecaster         = forecaster_ms,
                                       series             = data,
                                       levels             = items,
                                       steps              = 7,
                                       metric             = 'mean_absolute_error',
                                       initial_train_size = len(data_train) + len(data_val),
                                       refit              = False,
                                       fixed_train_size   = False,
                                       verbose            = False,
                                       show_progress      = True                                   )

# Results
display(multi_series_mae.head(3))
print('')
display(predictions_ms.head(3))
levels mean_absolute_error
0 item_1 5.579400
1 item_2 9.269760
2 item_3 7.410133

item_1 item_2 item_3 item_4 item_5 item_6 item_7 item_8 item_9 item_10 ... item_41 item_42 item_43 item_44 item_45 item_46 item_47 item_48 item_49 item_50
2017-06-01 34.970517 90.425181 61.410945 35.095362 30.546009 92.451404 91.585215 127.189217 87.655798 120.136830 ... 37.011384 58.206884 78.447951 43.877367 125.384112 97.990755 37.572624 78.323208 49.073243 110.726256
2017-06-02 38.519069 99.603758 62.314616 37.184234 31.385269 100.206850 94.790210 135.543159 91.844634 124.148161 ... 41.811890 59.124785 85.434846 50.532889 137.563561 101.337474 41.599957 84.519961 52.524124 114.854633
2017-06-03 37.490938 101.565408 65.009803 40.614632 33.810371 108.394930 103.483348 143.192527 95.888009 133.022574 ... 42.815077 66.180392 87.478442 52.633649 140.414283 108.508473 41.503211 89.098035 47.387566 117.525369

3 rows × 50 columns

Comparison

In [54]:
# Difference of backtesting metric for each item
# ======================================================================================
multi_series_mae = multi_series_mae.set_index('levels')
multi_series_mae.columns = ['multi_series_mae']
results = pd.concat((uni_series_mae, multi_series_mae), axis = 1)
results['improvement'] = results.eval('uni_series_mae - multi_series_mae')
results['improvement_(%)'] = 100 * results.eval('(uni_series_mae - multi_series_mae) / uni_series_mae')
results = results.round(2)
results.style.bar(subset=['improvement_(%)'], align='mid', color=['#d65f5f', '#5fba7d'])
Out[54]:
  uni_series_mae multi_series_mae improvement improvement_(%)
item_1 6.190000 5.580000 0.610000 9.880000
item_2 9.850000 9.270000 0.580000 5.850000
item_3 8.660000 7.410000 1.250000 14.470000
item_4 5.430000 4.980000 0.440000 8.170000
item_5 5.000000 4.660000 0.340000 6.820000
item_6 10.360000 10.040000 0.310000 3.020000
item_7 10.040000 9.800000 0.240000 2.390000
item_8 11.300000 10.420000 0.880000 7.800000
item_9 9.450000 8.770000 0.680000 7.240000
item_10 11.570000 10.570000 1.010000 8.700000
item_11 11.220000 10.320000 0.910000 8.080000
item_12 12.070000 10.980000 1.090000 9.010000
item_13 11.950000 11.370000 0.580000 4.870000
item_14 10.170000 9.600000 0.570000 5.650000
item_15 12.870000 11.380000 1.490000 11.600000
item_16 6.090000 6.030000 0.070000 1.110000
item_17 7.690000 7.290000 0.400000 5.170000
item_18 12.550000 12.010000 0.540000 4.300000
item_19 7.880000 7.330000 0.550000 6.930000
item_20 8.370000 7.870000 0.500000 5.970000
item_21 8.520000 8.100000 0.420000 4.960000
item_22 11.850000 10.790000 1.060000 8.940000
item_23 7.410000 6.720000 0.680000 9.230000
item_24 10.710000 9.950000 0.760000 7.080000
item_25 12.520000 11.740000 0.780000 6.240000
item_26 9.110000 8.590000 0.520000 5.760000
item_27 5.520000 5.150000 0.360000 6.610000
item_28 13.170000 11.870000 1.300000 9.860000
item_29 10.710000 10.380000 0.340000 3.140000
item_30 8.410000 7.820000 0.600000 7.110000
item_31 10.720000 10.050000 0.670000 6.210000
item_32 9.550000 9.230000 0.320000 3.300000
item_33 9.420000 9.300000 0.120000 1.280000
item_34 6.540000 5.860000 0.680000 10.430000
item_35 10.770000 10.230000 0.540000 4.980000
item_36 11.550000 10.680000 0.870000 7.520000
item_37 6.530000 6.090000 0.430000 6.660000
item_38 12.060000 11.270000 0.790000 6.510000
item_39 8.050000 7.270000 0.780000 9.730000
item_40 7.220000 6.530000 0.690000 9.560000
item_41 5.690000 5.250000 0.440000 7.800000
item_42 7.290000 6.980000 0.310000 4.290000
item_43 8.650000 8.480000 0.170000 1.970000
item_44 6.530000 6.390000 0.140000 2.110000
item_45 12.760000 11.780000 0.980000 7.710000
item_46 10.050000 9.700000 0.350000 3.480000
item_47 5.220000 4.990000 0.240000 4.510000
item_48 9.100000 8.200000 0.900000 9.900000
item_49 6.170000 5.930000 0.240000 3.810000
item_50 12.000000 10.650000 1.340000 11.180000
In [55]:
# Average improvement for all items
# ======================================================================================
results[['improvement', 'improvement_(%)']].agg(['mean', 'min', 'max'])
Out[55]:
improvement improvement_(%)
mean 0.6172 6.578
min 0.0700 1.110
max 1.4900 14.470
In [56]:
# Number of series with positive and negative improvement
# ======================================================================================
pd.Series(np.where(results['improvement_(%)'] < 0, 'negative', 'positive')).value_counts()
Out[56]:
positive    50
Name: count, dtype: int64
In [57]:
# Plot the item with maximum improvement
# ======================================================================================
fig, ax=plt.subplots(figsize=(8, 4))
data_test['item_3'].tail(60).plot(ax=ax)
predictions['item_3'].tail(60).plot(ax=ax)
predictions_ms['item_3'].tail(60).plot(ax=ax)
ax.legend(['real', 'predictions single forecaster', 'predictions multi series forecaster'])
ax.set_xlabel('')
ax.set_title('Forecasting for Item 3');
In [58]:
# Plot the item with minimum improvement
# ======================================================================================
fig, ax=plt.subplots(figsize=(8, 4))
data_test['item_16'].tail(60).plot(ax=ax)
predictions['item_16'].tail(60).plot(ax=ax)
predictions_ms['item_16'].tail(60).plot(ax=ax)
ax.legend(['real', 'predictions single forecaster', 'predictions multi series forecaster'])
ax.set_xlabel('')
ax.set_title('Forecasting for Item 16');

Impact of the series length

If a time series has few observations, the amount of information available for the model to learn is limited. This is a well know problem in real cases where there is not much historical data available. Non multivariate multi-series forecasters combine all the series during training; therefore they can access more data.

In this section, the same comparison between single-series forecasting and multi-series forecasting is performed but, this time, the length of the series is much shorter.

In [59]:
# Split data into train-validation-test
# ======================================================================================
start_train = '2017-01-01 00:00:00'
end_train = '2017-05-01 00:00:00'
end_val = '2017-07-31 23:59:00'
end_test = '2017-09-30 23:59:00'

data = data.loc[start_train:, :].copy()
data_train = data.loc[:end_train, :].copy()
data_val   = data.loc[end_train:end_val, :].copy()
data_test  = data.loc[end_val:end_test, :].copy()

print(f"Train dates      : {data_train.index.min()} --- {data_train.index.max()}  (n={len(data_train)})")
print(f"Validation dates : {data_val.index.min()} --- {data_val.index.max()}  (n={len(data_val)})")
print(f"Test dates       : {data_test.index.min()} --- {data_test.index.max()}  (n={len(data_test)})")
Train dates      : 2017-01-01 00:00:00 --- 2017-05-01 00:00:00  (n=121)
Validation dates : 2017-05-01 00:00:00 --- 2017-07-31 00:00:00  (n=92)
Test dates       : 2017-08-01 00:00:00 --- 2017-09-30 00:00:00  (n=61)
In [60]:
# Train and backtest a model for each item
# ======================================================================================
items = []
mae_values = []
predictions = {}

for i, item in enumerate(tqdm(data.columns)):

    forecaster = ForecasterAutoreg(
                     regressor     = HistGradientBoostingRegressor(random_state=123),
                     lags          = 14,
                     transformer_y = StandardScaler()
                 )

    metric, preds = backtesting_forecaster(
                        forecaster         = forecaster,
                        y                  = data[item],
                        initial_train_size = len(data_train) + len(data_val),
                        steps              = 7,
                        metric             = 'mean_absolute_error',
                        refit              = False,
                        fixed_train_size   = False,
                        verbose            = False,
                        show_progress      = False
                    )

    items.append(item)
    mae_values.append(metric)
    predictions[item] = preds

uni_series_mae = pd.Series(
                     data  = mae_values,
                     index = items,
                     name  = 'uni_series_mae'
                 )
100%|██████████| 50/50 [00:18<00:00,  2.77it/s]
In [61]:
# Train and backtest a model for all items
# ======================================================================================

# Define forecaster
forecaster_ms = ForecasterAutoregMultiSeries(
                    regressor          = HistGradientBoostingRegressor(random_state=123),
                    lags               = 14,
                    transformer_series = StandardScaler(),
                )

# Backtesting forecaster for all items
multi_series_mae, predictions_ms = backtesting_forecaster_multiseries(
                                       forecaster         = forecaster_ms,
                                       series             = data,
                                       levels             = None, # If None all levels are selected
                                       steps              = 7,
                                       metric             = 'mean_absolute_error',
                                       initial_train_size = len(data_train) + len(data_val),
                                       refit              = False,
                                       fixed_train_size   = False,
                                       verbose            = False
                                   )

# Results
display(multi_series_mae.head(3))
print('')
display(predictions_ms.head(3))
levels mean_absolute_error
0 item_1 5.916400
1 item_2 10.241195
2 item_3 7.937301

item_1 item_2 item_3 item_4 item_5 item_6 item_7 item_8 item_9 item_10 ... item_41 item_42 item_43 item_44 item_45 item_46 item_47 item_48 item_49 item_50
2017-08-02 38.799183 96.508038 59.663414 38.651256 32.065031 92.758344 97.089159 128.457251 88.622465 119.606434 ... 34.575309 59.960024 85.529356 52.144640 134.024475 96.528340 35.984520 88.372264 50.788241 121.528017
2017-08-03 38.712972 110.883112 65.769765 36.440864 36.865280 97.905376 108.625140 137.329917 93.654646 126.784431 ... 39.612689 63.967339 87.927004 58.093810 143.851475 109.246742 39.443198 96.031760 54.062914 123.135648
2017-08-04 40.099963 116.237826 69.827511 39.391681 38.751137 107.745055 115.040061 146.882289 103.897172 138.875529 ... 40.061436 65.427221 96.417850 62.312794 163.098872 120.418589 42.338213 98.744629 60.984447 130.095691

3 rows × 50 columns

In [62]:
# Difference in backtesting metric for each item
# ======================================================================================
multi_series_mae = multi_series_mae.set_index('levels')
multi_series_mae.columns = ['multi_series_mae']
results = pd.concat((uni_series_mae, multi_series_mae), axis = 1)
results['improvement'] = results.eval('uni_series_mae - multi_series_mae')
results['improvement_(%)'] = 100 * results.eval('(uni_series_mae - multi_series_mae) / uni_series_mae')
results = results.round(2)
results

# Average improvement for all items
# ======================================================================================
results[['improvement', 'improvement_(%)']].agg(['mean', 'min', 'max'])
Out[62]:
improvement improvement_(%)
mean 0.88 8.169
min -0.19 -3.390
max 2.25 18.440
In [63]:
# Number of series with positive and negative improvement
# ======================================================================================
pd.Series(np.where(results['improvement_(%)'] < 0, 'negative', 'positive')).value_counts()
Out[63]:
positive    49
negative     1
Name: count, dtype: int64

The average improvement has increased from 6.6 to 8.2%. The advantage of using a multi-series forecaster seems to grow as the length of the available series is reduced.

Hyperparameter tuning and lags selection

In the previous sections, the comparison between forecasters was done without optimizing the hyperparameters of the regressors. To make a fair comparison, a grid search strategy is used in order to select the best configuration for each forecaster. See more information in hyperparameter tuning and lags selection.

⚠ Warning

The following section may require significant computational time (about 45 minutes). Feel free to select only a subset of items to speed up the execution.
In [64]:
# Hide progress bar tqdm
# ======================================================================================
from tqdm import tqdm
from functools import partialmethod
tqdm.__init__ = partialmethod(tqdm.__init__, disable=True)
In [ ]:
# Hyperparameter search and backtesting of each item's model
# ======================================================================================
items = []
mae_values  = []

lags_grid = [7, 14, 21]
param_grid = {
    'max_iter': [100, 500],
    'max_depth': [3, 5, 10, None],
    'learning_rate': [0.01, 0.1]
}

for i, item in enumerate(data.columns):

    forecaster = ForecasterAutoreg(
                     regressor     = HistGradientBoostingRegressor(random_state=123),
                     lags          = 14,
                     transformer_y = StandardScaler()
                 )

    results_grid = grid_search_forecaster(
                       forecaster         = forecaster,
                       y                  = data.loc[:end_val, item],
                       lags_grid          = lags_grid,
                       param_grid         = param_grid,
                       steps              = 7,
                       metric             = 'mean_absolute_error',
                       initial_train_size = len(data_train),
                       refit              = False,
                       fixed_train_size   = False,
                       return_best        = True,
                       verbose            = False,
                       show_progress      = False 
                  )

    metric, preds = backtesting_forecaster(
                        forecaster         = forecaster,
                        y                  = data[item],
                        initial_train_size = len(data_train) + len(data_val),
                        steps              = 7,
                        metric             = 'mean_absolute_error',
                        refit              = False,
                        fixed_train_size   = False,
                        verbose            = False,
                        show_progress      = False
                    )

    items.append(item)
    mae_values.append(metric)

uni_series_mae = pd.Series(
                     data  = mae_values,
                     index = items,
                     name  = 'uni_series_mae'
                 )
In [66]:
# Hyperparameter search for the multi-series model and backtesting for each item
# ======================================================================================
lags_grid = [7, 14, 21]
param_grid = {
    'max_iter': [100, 500],
    'max_depth': [3, 5, 10, None],
    'learning_rate': [0.01, 0.1]
}

forecaster_ms = ForecasterAutoregMultiSeries(
                    regressor          = HistGradientBoostingRegressor(random_state=123),
                    lags               = 14,
                    transformer_series = StandardScaler(),
                )

results_grid_ms = grid_search_forecaster_multiseries(
                      forecaster         = forecaster_ms,
                      series             = data.loc[:end_val, :],
                      levels             = None, # If None all levels are selected
                      lags_grid          = lags_grid,
                      param_grid         = param_grid,
                      steps              = 7,
                      metric             = 'mean_absolute_error',
                      initial_train_size = len(data_train),
                      refit              = False,
                      fixed_train_size   = False,
                      return_best        = True,
                      verbose            = False
                  )      

multi_series_mae, predictions_ms = backtesting_forecaster_multiseries(
                                       forecaster         = forecaster_ms,
                                       series             = data,
                                       levels             = None, # If None all levels are selected
                                       steps              = 7,
                                       metric             = 'mean_absolute_error',
                                       initial_train_size = len(data_train) + len(data_val),
                                       refit              = False,
                                       fixed_train_size   = False,
                                       verbose            = False
                                   )
48 models compared for 50 level(s). Number of iterations: 48.
`Forecaster` refitted using the best-found lags and parameters, and the whole data set: 
  Lags: [ 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21]
  Parameters: {'learning_rate': 0.01, 'max_depth': 3, 'max_iter': 500}
  Backtesting metric: 10.79552245163781
  Levels: ['item_1', 'item_2', 'item_3', 'item_4', 'item_5', 'item_6', 'item_7', 'item_8', 'item_9', 'item_10', 'item_11', 'item_12', 'item_13', 'item_14', 'item_15', 'item_16', 'item_17', 'item_18', 'item_19', 'item_20', 'item_21', 'item_22', 'item_23', 'item_24', 'item_25', 'item_26', 'item_27', 'item_28', 'item_29', 'item_30', 'item_31', 'item_32', 'item_33', 'item_34', 'item_35', 'item_36', 'item_37', 'item_38', 'item_39', 'item_40', 'item_41', 'item_42', 'item_43', 'item_44', 'item_45', 'item_46', 'item_47', 'item_48', 'item_49', 'item_50']

In [67]:
# Difference in backtesting metric for each item
# ======================================================================================
multi_series_mae = multi_series_mae.set_index('levels')
multi_series_mae.columns = ['multi_series_mae']
results = pd.concat((uni_series_mae, multi_series_mae), axis = 1)
results['improvement'] = results.eval('uni_series_mae - multi_series_mae')
results['improvement_(%)'] = 100 * results.eval('(uni_series_mae - multi_series_mae) / uni_series_mae')
results = results.round(2)
results.style.bar(subset=['improvement_(%)'], align='mid', color=['#d65f5f', '#5fba7d'])
Out[67]:
  uni_series_mae multi_series_mae improvement improvement_(%)
item_1 6.740000 6.070000 0.670000 9.940000
item_2 12.590000 11.290000 1.290000 10.280000
item_3 8.330000 8.030000 0.300000 3.610000
item_4 5.870000 5.810000 0.060000 0.980000
item_5 5.590000 5.230000 0.370000 6.560000
item_6 11.410000 11.260000 0.150000 1.320000
item_7 11.340000 11.270000 0.070000 0.660000
item_8 14.340000 13.910000 0.420000 2.940000
item_9 11.940000 10.740000 1.190000 9.980000
item_10 14.790000 13.630000 1.160000 7.850000
item_11 12.730000 13.090000 -0.360000 -2.850000
item_12 14.430000 14.110000 0.310000 2.180000
item_13 15.600000 13.920000 1.670000 10.740000
item_14 11.770000 10.840000 0.930000 7.890000
item_15 15.750000 15.210000 0.540000 3.410000
item_16 6.920000 6.830000 0.090000 1.320000
item_17 8.310000 7.890000 0.420000 5.030000
item_18 16.290000 15.190000 1.090000 6.720000
item_19 8.620000 8.290000 0.320000 3.750000
item_20 9.630000 9.320000 0.310000 3.210000
item_21 9.100000 8.570000 0.530000 5.870000
item_22 14.300000 13.830000 0.470000 3.300000
item_23 7.650000 7.510000 0.140000 1.830000
item_24 14.080000 12.450000 1.630000 11.580000
item_25 14.260000 15.180000 -0.920000 -6.460000
item_26 11.090000 10.680000 0.410000 3.720000
item_27 5.830000 5.390000 0.440000 7.530000
item_28 14.990000 14.720000 0.270000 1.830000
item_29 12.550000 12.630000 -0.080000 -0.650000
item_30 10.300000 8.770000 1.530000 14.860000
item_31 11.900000 11.610000 0.290000 2.420000
item_32 10.190000 10.620000 -0.430000 -4.250000
item_33 12.710000 12.230000 0.480000 3.780000
item_34 6.420000 6.130000 0.300000 4.660000
item_35 12.580000 12.540000 0.040000 0.290000
item_36 14.640000 13.670000 0.970000 6.660000
item_37 7.190000 6.780000 0.410000 5.670000
item_38 15.660000 14.650000 1.010000 6.480000
item_39 8.620000 8.730000 -0.110000 -1.220000
item_40 8.260000 7.220000 1.030000 12.500000
item_41 6.450000 5.770000 0.680000 10.510000
item_42 8.860000 7.840000 1.020000 11.540000
item_43 10.910000 10.340000 0.560000 5.180000
item_44 7.960000 6.980000 0.990000 12.430000
item_45 14.170000 15.230000 -1.060000 -7.450000
item_46 12.270000 12.040000 0.230000 1.890000
item_47 5.930000 5.800000 0.130000 2.210000
item_48 9.440000 9.680000 -0.240000 -2.550000
item_49 7.270000 7.260000 0.010000 0.160000
item_50 12.800000 12.600000 0.200000 1.550000
In [68]:
# Average improvement for all items
# ======================================================================================
results[['improvement', 'improvement_(%)']].agg(['mean', 'min', 'max'])
Out[68]:
improvement improvement_(%)
mean 0.4386 4.2278
min -1.0600 -7.4500
max 1.6700 14.8600
In [69]:
# Number of series with positive and negative improvement
# ======================================================================================
pd.Series(np.where(results['improvement_(%)'] < 0, 'negative', 'positive')).value_counts()
Out[69]:
positive    43
negative     7
Name: count, dtype: int64

After identifying the combination of lags and hyperparameters that achieve the best predictive performance for each forecaster, more single-series models have achieved higher predictive ability by better generalizing their own data (one item). Even so, the multi-series model provides better results for most of the items.

Weights in multi-series

The weights are used to control the influence that each observation has on the training of the model. ForecasterAutoregMultiseries accepts two types of weights:

  • series_weights controls the relative importance of each series. If a series has twice as much weight as the others, the observations of that series influence the training twice as much. The higher the weight of a series relative to the others, the more the model will focus on trying to learn that series.

  • weight_func controls the relative importance of each observation according to its index value. For example, a function that assigns a lower weight to certain dates.

If the two types of weights are indicated, they are multiplied to create the final weights as shown in the figure. The resulting sample_weight cannot have negative values.

Learn more about weights in multi-series forecasting and weighted time series forecasting with skforecast.

In this example, item_1 has higher relative importance among series (it weighs 3 times more than the rest of the series), and observations between '2013-12-01' and '2014-01-31' are considered non-representative and a weight of 0 is applied to them.

In [70]:
# Weights in Multi-Series
# ======================================================================================
# `series_weights`
series_weights = {'item_1': 3.} # Series not presented in the dict will have weight 1

# A dictionary can be passed to `weight_func` to apply different functions for each series
# If a series is not presented in the dict, it will have weight 1
def custom_weights(index):
    """
    Return 0 if index is between '2013-12-01' and '2014-01-31', 1 otherwise.
    """
    weights = np.where(
                  (index >= '2013-12-01') & (index <= '2014-01-31'),
                   0,
                   1
              )
    
    return weights

forecaster = ForecasterAutoregMultiSeries(
                 regressor          = HistGradientBoostingRegressor(random_state=123),
                 lags               = 14,
                 transformer_series = StandardScaler(),
                 transformer_exog   = None,
                 weight_func        = custom_weights, 
                 series_weights     = series_weights
             )

forecaster.fit(series=data)
forecaster.predict(steps=7).head(3)
/home/ubuntu/anaconda3/envs/skforecast_09_py11/lib/python3.11/site-packages/skforecast/ForecasterAutoregMultiSeries/ForecasterAutoregMultiSeries.py:577: IgnoredArgumentWarning: {'item_50', 'item_41', 'item_39', 'item_32', 'item_19', 'item_44', 'item_46', 'item_30', 'item_11', 'item_23', 'item_33', 'item_36', 'item_45', 'item_6', 'item_43', 'item_8', 'item_47', 'item_5', 'item_25', 'item_27', 'item_31', 'item_7', 'item_10', 'item_13', 'item_24', 'item_37', 'item_20', 'item_35', 'item_22', 'item_38', 'item_48', 'item_2', 'item_29', 'item_18', 'item_42', 'item_14', 'item_9', 'item_28', 'item_12', 'item_40', 'item_3', 'item_15', 'item_21', 'item_49', 'item_17', 'item_26', 'item_4', 'item_16', 'item_34'} not present in `series_weights`. A weight of 1 is given to all their samples. 
 You can suppress this warning using: warnings.simplefilter('ignore', category=IgnoredArgumentWarning)
  warnings.warn(
Out[70]:
item_1 item_2 item_3 item_4 item_5 item_6 item_7 item_8 item_9 item_10 ... item_41 item_42 item_43 item_44 item_45 item_46 item_47 item_48 item_49 item_50
2018-01-01 20.109874 53.478138 36.348853 23.820464 23.794303 51.894103 55.784459 68.754883 40.716822 60.196889 ... 26.809501 34.880140 44.365278 27.547850 81.444684 53.123010 22.365198 47.706126 27.452497 64.263828
2018-01-02 22.510628 63.946284 40.750709 25.113519 19.989210 58.160237 61.580644 82.784148 48.883716 73.419269 ... 23.802414 39.521469 53.034414 30.260770 94.674182 59.403028 26.274588 50.975857 32.787511 61.725721
2018-01-03 20.182876 66.101162 39.411403 26.318990 19.076542 63.713520 63.230728 84.571132 52.234398 70.178156 ... 19.533864 40.694235 52.909487 32.867555 97.204512 62.760504 26.893860 52.239564 35.155388 71.421888

3 rows × 50 columns

Conclusions

This use case shows that a multi-series model may have advantages over multiple individual models when forecasting time series that follow similar dynamics. Beyond the potential improvements in forecasting, it is also important to take into consideration the benefit of having only one model to maintain.

Session information

In [71]:
import session_info
session_info.show(html=False)
-----
lightgbm            3.3.5
matplotlib          3.7.2
numpy               1.24.4
pandas              2.0.3
session_info        1.0.0
skforecast          0.9.0
sklearn             1.3.0
statsmodels         0.14.0
tqdm                4.65.0
-----
IPython             8.14.0
jupyter_client      8.3.0
jupyter_core        5.3.1
notebook            6.5.4
-----
Python 3.11.4 (main, Jul  5 2023, 13:45:01) [GCC 11.2.0]
Linux-5.15.0-1039-aws-x86_64-with-glibc2.31
-----
Session information updated at 2023-07-13 13:41

How to cite this document?

Global Forecasting Models: Multi-series forecasting with python and skforecast by Joaquín Amat Rodrigo and Javier Escobar Ortiz, available under a CC BY-NC-SA 4.0 at https://www.cienciadedatos.net/documentos/py44-multi-series-forecasting-skforecast.html DOI


Did you like the article? Your support is important

Website maintenance has high cost, your contribution will help me to continue generating free educational content. Many thanks! 😊


Creative Commons Licence
This work by Joaquín Amat Rodrigo and Javier Escobar Ortiz is licensed under a Attribution-NonCommercial-ShareAlike 4.0 International.