Reconciliation

This example provides a minimal example of how to reconcile a set of existing forecasts.

This example is based on chapter 11 of:

Hyndman, R.J., & Athanasopoulos, G. (2021) Forecasting: principles and practice, 3rd edition, OTexts: Melbourne, Australia. OTexts.com/fpp3. Accessed on 05/07/2022.

The data comes from the online book of Hyndman, see link below.

The code for this example can be found at:

Import packages & read data

[1]:
#%% Read packages
import pandas as pd
import numpy as np
from hierts.reconciliation import calc_summing_matrix, apply_reconciliation_methods,\
                                  calc_level_method_rmse
[2]:
#%% Read data
df = pd.read_csv("https://OTexts.com/fpp3/extrafiles/prison_population.csv")
[3]:
# Let's look at the data
df.head(10)
[3]:
Date State Gender Legal Indigenous Count
0 2005-03-01 ACT Female Remanded ATSI 0
1 2005-03-01 ACT Female Remanded Non-ATSI 2
2 2005-03-01 ACT Female Sentenced ATSI 0
3 2005-03-01 ACT Female Sentenced Non-ATSI 5
4 2005-03-01 ACT Male Remanded ATSI 7
5 2005-03-01 ACT Male Remanded Non-ATSI 58
6 2005-03-01 ACT Male Sentenced ATSI 5
7 2005-03-01 ACT Male Sentenced Non-ATSI 101
8 2005-03-01 NSW Female Remanded ATSI 51
9 2005-03-01 NSW Female Remanded Non-ATSI 131

As you can see, we have a column indicating the time index (‘Date’), four columns that provide information on the hierarchy/aggregation of the data (‘State’, ‘Gender’, ‘Legal’, ‘Indigenous’), and a target column (‘Count’).

Set aggregations and calculate summing matrix

For this dataset, the aggregations are in the columns: [‘State’, ‘Gender’, ‘Legal’, ‘Indigenous’]. These columns will be in our aggregations.

[4]:
aggregation_cols = ['State', 'Gender', 'Legal', 'Indigenous']

Next, we define the aggregations that we are interested in. In this case, we are interested in the following aggregations:

[5]:
aggregations = [['State'],
                ['State', 'Gender'],
                ['State', 'Legal'],
                ['State', 'Indigenous'],
                ['Gender', 'Legal']]

Don’t include the top (total) level and bottom-level: these will be added automatically later on. Now, we can create a summing matrix, which shows how each of the bottom-level series maps to our chosen aggregations:

[6]:
df_S = calc_summing_matrix(df, aggregation_cols, aggregations)

Let’s have a look at df_S:

[7]:
df_S
[7]:
bottom_timeseries ACT-Female-Remanded-ATSI ACT-Female-Remanded-Non-ATSI ACT-Female-Sentenced-ATSI ACT-Female-Sentenced-Non-ATSI ACT-Male-Remanded-ATSI ACT-Male-Remanded-Non-ATSI ACT-Male-Sentenced-ATSI ACT-Male-Sentenced-Non-ATSI NSW-Female-Remanded-ATSI NSW-Female-Remanded-Non-ATSI ... VIC-Male-Sentenced-ATSI VIC-Male-Sentenced-Non-ATSI WA-Female-Remanded-ATSI WA-Female-Remanded-Non-ATSI WA-Female-Sentenced-ATSI WA-Female-Sentenced-Non-ATSI WA-Male-Remanded-ATSI WA-Male-Remanded-Non-ATSI WA-Male-Sentenced-ATSI WA-Male-Sentenced-Non-ATSI
Aggregation Value
Total Total 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 ... 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0
State ACT 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
NSW 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 1.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
NT 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
QLD 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
bottom_timeseries WA-Female-Sentenced-Non-ATSI 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0
WA-Male-Remanded-ATSI 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0
WA-Male-Remanded-Non-ATSI 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0
WA-Male-Sentenced-ATSI 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0
WA-Male-Sentenced-Non-ATSI 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0

125 rows × 64 columns

As you can see, df_S is a mapping between the bottom-level series (columns of df_S) and the aggregations specified. Some observations:

  • ‘Total’ contains the total across all bottom-level series. Hence, this is a row consisting of ones - the total is the sum of all the bottom-level series.

  • ‘Bottom level’ contains the bottom level series. This is an identity matrix, as each bottom-level series in the rows only maps to a single bottom level series in the columns

  • Everything in between ‘Bottom level’ and ‘Total’ denotes how we can construct the aggregations.

Create random forecasts for each time series

Now we can create a forecasting model for each time series in the aggregation matrix df_S. In this minimal example, we’ll just use random forecasts.

[8]:
# Set target, time_index and split of train and test.
target = 'Count'
time_index = 'Date'
end_train = '2015-12-31'
start_test = '2016-01-01'
[9]:
# Create random forecasts - in this case we just do random poisson sampling of the actual values
rng = np.random.default_rng(seed=0)
df[f'{target}_predicted'] = rng.poisson(lam=df[f'{target}'])
[10]:
# Add bottom_timeseries identifier and create actuals & forecasts dataframe for all aggregations
df['bottom_timeseries'] = df[aggregation_cols].agg('-'.join, axis=1)
actuals_bottom_timeseries = df.set_index(['bottom_timeseries', time_index])[target]\
                              .unstack(1)\
                              .loc[df_S.columns]
forecasts_bottom_timeseries = df.set_index(['bottom_timeseries', time_index])[f'{target}_predicted']\
                                .unstack(1)\
                                .loc[df_S.columns]
actuals = df_S @ actuals_bottom_timeseries
forecasts = df_S @ forecasts_bottom_timeseries

Reconciliation

We can now reconcile the forecasts.

[11]:
# Create forecast test set and apply a set of reconciliation methods.
forecasts_test = forecasts.loc[:, start_test:]
forecasts_reconciled = apply_reconciliation_methods(forecasts_test, df_S, \
                                                    actuals.loc[:, :end_train],\
                                                    forecasts.loc[:, :end_train],\
                                                    methods=['ols', 'wls_var', 'mint_shrink'])
Method ols, reconciliation time: 0.0024s
Method wls_var, reconciliation time: 0.0006s
Method mint_shrink, reconciliation time: 3.7461s

Finally, we can compute the root mean-squared error on all of our methods.

[12]:
# Calculate error for all levels and methods. We set bottom-up as the base method to compare against
# in the relative rmse
rmse, rel_rmse = calc_level_method_rmse(forecasts_reconciled, actuals, base='base')

Let’s look at the errors:

[13]:
rmse
[13]:
Method base ols wls_var mint_shrink
Aggregation
Total 200.393987 200.392359 200.392720 200.393987
Gender-Legal 71.493444 71.493115 71.493200 71.493444
State 70.355570 70.355378 70.355157 70.355570
State-Indigenous 50.534487 50.534313 50.534269 50.534487
State-Legal 48.516911 48.516884 48.516749 48.516911
State-Gender 47.778624 47.778658 47.778282 47.778624
bottom_timeseries 24.704899 24.704902 24.704829 24.704899
All series 45.105233 45.105116 45.105014 45.105233

In this case, the reconciliation methods don’t do very much. This example is just to illustrate what constitutes a minimal working example to produce reconciled forecasts on a given set of existing forecasts.