Skip to main content
Aggregating Exogenous Variables for Hierarchical Forecasting
When building forecasting models with exogenous (external) features, it is important that these features are also coherently aggregated across the hierarchy — just like the target variable. For example, if marketing_spend is available at the bottom level, the aggregated series at the state level should contain the sum of marketing spend across its child regions; while a rate-like variable such as cpi should be averaged. The aggregate function in HierarchicalForecast supports this through its exog_vars parameter: a dictionary mapping column names to their aggregation strategy (e.g., 'sum', 'mean', 'min', 'max'). This lets you aggregate exogenous variables alongside the target in a single call, and then use them as inputs to models that support exogenous regressors (e.g., AutoARIMA). In this notebook we demonstrate this workflow on the Australian Domestic Tourism dataset and compare forecasting performance with and without exogenous variables. You can run these experiments using CPU or GPU with Google Colab. Open In Colab
!pip install hierarchicalforecast statsforecast

1. Load Data and Add Exogenous Variables

We start from the Tourism dataset and add two synthetic exogenous variables that illustrate different aggregation needs:
  • cpi — Consumer Price Index. This is a national-level economic indicator, identical across regions within each quarter. Because it is a rate (not a count), it should be averaged when aggregating.
  • marketing_spend — Regional marketing budget. This varies by state and grows over time. Because it represents a total amount, it should be summed when aggregating.
import numpy as np
import pandas as pd
Y_df = pd.read_csv('https://raw.githubusercontent.com/Nixtla/transfer-learning-time-series/main/datasets/tourism.csv')
Y_df = Y_df.rename({'Trips': 'y', 'Quarter': 'ds'}, axis=1)
Y_df.insert(0, 'Country', 'Australia')
Y_df = Y_df[['Country', 'Region', 'State', 'Purpose', 'ds', 'y']]
Y_df['ds'] = Y_df['ds'].str.replace(r'(\d+) (Q\d)', r'\1-\2', regex=True)
Y_df['ds'] = pd.PeriodIndex(Y_df['ds'], freq='Q').to_timestamp()
Y_df.head()
CountryRegionStatePurposedsy
0AustraliaAdelaideSouth AustraliaBusiness1998-01-01135.077690
1AustraliaAdelaideSouth AustraliaBusiness1998-04-01109.987316
2AustraliaAdelaideSouth AustraliaBusiness1998-07-01166.034687
3AustraliaAdelaideSouth AustraliaBusiness1998-10-01127.160464
4AustraliaAdelaideSouth AustraliaBusiness1999-01-01137.448533
np.random.seed(42)

# CPI: a national-level index, same across all bottom-level series per quarter
dates = Y_df['ds'].unique()
cpi_values = 100 + np.cumsum(np.random.normal(0.5, 0.3, len(dates)))
cpi_map = dict(zip(dates, cpi_values))
Y_df['cpi'] = Y_df['ds'].map(cpi_map)

# Marketing spend: varies by state, grows over time
state_spend_base = {s: np.random.uniform(50, 200) for s in Y_df['State'].unique()}
Y_df['marketing_spend'] = Y_df.apply(
    lambda r: state_spend_base[r['State']] * (1 + 0.02 * (r['ds'].year - 1998)) + np.random.normal(0, 5), 
    axis=1
).round(1)

Y_df.head()
CountryRegionStatePurposedsycpimarketing_spend
0AustraliaAdelaideSouth AustraliaBusiness1998-01-01135.077690100.649014190.7
1AustraliaAdelaideSouth AustraliaBusiness1998-04-01109.987316101.107535187.8
2AustraliaAdelaideSouth AustraliaBusiness1998-07-01166.034687101.801842183.5
3AustraliaAdelaideSouth AustraliaBusiness1998-10-01127.160464102.758750188.7
4AustraliaAdelaideSouth AustraliaBusiness1999-01-01137.448533103.188504190.3

2. Aggregate with Exogenous Variables

The exog_vars parameter of aggregate takes a dictionary where: - keys are the column names of the exogenous variables. - values are the aggregation function(s) to apply — any valid Pandas aggregation function name such as 'sum', 'mean', 'min', 'max', 'std'. The aggregated columns are named {column}_{function} (e.g., cpi_mean, marketing_spend_sum).
spec = [
    ['Country'],
    ['Country', 'State'], 
    ['Country', 'Purpose'], 
    ['Country', 'State', 'Region'], 
    ['Country', 'State', 'Purpose'], 
    ['Country', 'State', 'Region', 'Purpose']
]
from hierarchicalforecast.utils import aggregate

Y_agg, S_df, tags = aggregate(
    Y_df, 
    spec, 
    exog_vars={
        'cpi': 'mean',              # rate variable -> average
        'marketing_spend': 'sum',   # additive variable -> sum
    }
)
The result is a Y_agg DataFrame that contains both the aggregated target (y) and the aggregated exogenous variables (cpi_mean, marketing_spend_sum) for every level of the hierarchy.
# Top level: all of Australia
Y_agg[Y_agg['unique_id'] == 'Australia'].head()
unique_iddsycpi_meanmarketing_spend_sum
0Australia1998-01-0123182.197269100.64901436528.4
1Australia1998-04-0120323.380067101.10753536438.2
2Australia1998-07-0119826.640511101.80184236613.1
3Australia1998-10-0120830.129891102.75875036648.8
4Australia1999-01-0122087.353380103.18850437338.3
# State level: Victoria
Y_agg[Y_agg['unique_id'] == 'Australia/Victoria'].head()
unique_iddsycpi_meanmarketing_spend_sum
560Australia/Victoria1998-01-016010.424491100.64901413711.9
561Australia/Victoria1998-04-014795.246755101.10753513640.1
562Australia/Victoria1998-07-014316.845170101.80184213742.9
563Australia/Victoria1998-10-014674.829118102.75875013768.8
564Australia/Victoria1999-01-015304.334195103.18850414047.9
# Bottom level: a specific Region/Purpose combination
Y_agg[Y_agg['unique_id'] == 'Australia/Victoria/Melbourne/Holiday'].head()
unique_iddsycpi_meanmarketing_spend_sum
29920Australia/Victoria/Melbourne/Holiday1998-01-01427.827800100.649014160.2
29921Australia/Victoria/Melbourne/Holiday1998-04-01424.191925101.107535171.2
29922Australia/Victoria/Melbourne/Holiday1998-07-01449.551508101.801842168.8
29923Australia/Victoria/Melbourne/Holiday1998-10-01450.699582102.758750172.7
29924Australia/Victoria/Melbourne/Holiday1999-01-01412.358542103.188504175.8
Notice that: - cpi_mean is the same at every level (since CPI is identical across bottom series within each quarter). - marketing_spend_sum grows as we move up the hierarchy, because it sums the spend of all child series.

Multiple aggregations per variable

You can also apply multiple aggregation functions to a single variable by passing a list of strings.
Y_multi, _, _ = aggregate(
    Y_df, 
    spec, 
    exog_vars={
        'marketing_spend': ['sum', 'mean', 'std'],
    }
)

Y_multi[Y_multi['unique_id'] == 'Australia/Victoria'][
    ['unique_id', 'ds', 'y', 'marketing_spend_sum', 'marketing_spend_mean', 'marketing_spend_std']
].head()
unique_iddsymarketing_spend_summarketing_spend_meanmarketing_spend_std
560Australia/Victoria1998-01-016010.42449113711.9163.2369054.878618
561Australia/Victoria1998-04-014795.24675513640.1162.3821435.116737
562Australia/Victoria1998-07-014316.84517013742.9163.6059524.361285
563Australia/Victoria1998-10-014674.82911813768.8163.9142865.038578
564Australia/Victoria1999-01-015304.33419514047.9167.2369055.759074

3. Train/Test Split

We use the final two years (8 quarters) as test set. We also aggregate a version without exogenous variables to compare later.
Y_agg_no_exog, S_df_no_exog, tags_no_exog = aggregate(Y_df, spec)
horizon = 8

# With exogenous
Y_test_df = Y_agg.groupby('unique_id', as_index=False).tail(horizon)
Y_train_df = Y_agg.drop(Y_test_df.index)
X_test_df = Y_test_df[['unique_id', 'ds', 'cpi_mean', 'marketing_spend_sum']]

# Without exogenous
Y_test_no_exog = Y_agg_no_exog.groupby('unique_id', as_index=False).tail(horizon)
Y_train_no_exog = Y_agg_no_exog.drop(Y_test_no_exog.index)

print(f'Train: {Y_train_df.shape[0]:,} rows, Test: {Y_test_df.shape[0]:,} rows')
Train: 30,600 rows, Test: 3,400 rows

4. Base Forecasts

We use AutoARIMA from StatsForecast, which supports exogenous regressors. Any column in the training data besides unique_id, ds, and y is treated as an exogenous feature. Future values are passed via X_df. We produce base forecasts with and without the exogenous variables for comparison.
from statsforecast.models import AutoARIMA
from statsforecast.core import StatsForecast
/home/osprangers/Repositories/hierarchicalforecast/.venv/lib/python3.10/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
# With exogenous variables
fcst_exog = StatsForecast(models=[AutoARIMA(season_length=4)], freq='QS', n_jobs=-1)
Y_hat_exog = fcst_exog.forecast(df=Y_train_df, h=horizon, X_df=X_test_df, fitted=True)
Y_fitted_exog = fcst_exog.forecast_fitted_values()

Y_hat_exog.head()
unique_iddsAutoARIMA
0Australia2016-01-0126166.873073
1Australia2016-04-0124907.791984
2Australia2016-07-0124537.521142
3Australia2016-10-0125631.143759
4Australia2017-01-0126987.070921
# Without exogenous variables
fcst_no_exog = StatsForecast(models=[AutoARIMA(season_length=4)], freq='QS', n_jobs=-1)
Y_hat_no_exog = fcst_no_exog.forecast(df=Y_train_no_exog, h=horizon, fitted=True)
Y_fitted_no_exog = fcst_no_exog.forecast_fitted_values()

Y_hat_no_exog.head()
unique_iddsAutoARIMA
0Australia2016-01-0126212.553553
1Australia2016-04-0125033.667125
2Australia2016-07-0124507.027198
3Australia2016-10-0125598.928613
4Australia2017-01-0126982.576796

5. Reconcile Forecasts

Reconciliation works exactly the same way regardless of whether exogenous variables were used to produce the base forecasts. The reconciliation step adjusts the base forecasts to be coherent across the hierarchy.
from hierarchicalforecast.methods import BottomUp, MinTrace
from hierarchicalforecast.core import HierarchicalReconciliation

reconcilers = [
    BottomUp(),
    MinTrace(method='mint_shrink'),
]
# With exogenous
hrec_exog = HierarchicalReconciliation(reconcilers=reconcilers)
Y_rec_exog = hrec_exog.reconcile(
    Y_hat_df=Y_hat_exog, Y_df=Y_fitted_exog, S_df=S_df, tags=tags
)

Y_rec_exog.head()
unique_iddsAutoARIMAAutoARIMA/BottomUpAutoARIMA/MinTrace_method-mint_shrink
0Australia2016-01-0126166.87307324461.29207225436.706581
1Australia2016-04-0124907.79198422795.93181724076.317511
2Australia2016-07-0124537.52114222151.77243523272.424691
3Australia2016-10-0125631.14375922936.26885024404.217321
4Australia2017-01-0126987.07092124292.35393225663.420421
# Without exogenous
hrec_no_exog = HierarchicalReconciliation(reconcilers=reconcilers)
Y_rec_no_exog = hrec_no_exog.reconcile(
    Y_hat_df=Y_hat_no_exog, Y_df=Y_fitted_no_exog, S_df=S_df_no_exog, tags=tags_no_exog
)

Y_rec_no_exog.head()
unique_iddsAutoARIMAAutoARIMA/BottomUpAutoARIMA/MinTrace_method-mint_shrink
0Australia2016-01-0126212.55355324495.91998825322.734082
1Australia2016-04-0125033.66712522660.03626223873.068038
2Australia2016-07-0124507.02719822307.88750623396.655072
3Australia2016-10-0125598.92861322853.29486324303.043112
4Australia2017-01-0126982.57679624264.06507125520.638165

6. Evaluation

We compare RMSE and MASE across hierarchy levels for the models with and without exogenous variables.
from hierarchicalforecast.evaluation import evaluate
from utilsforecast.losses import rmse, mase
from functools import partial

eval_tags = {
    'Total': tags['Country'],
    'Purpose': tags['Country/Purpose'],
    'State': tags['Country/State'],
    'Regions': tags['Country/State/Region'],
    'Bottom': tags['Country/State/Region/Purpose'],
}
# Evaluate with exogenous (drop exog columns before merge to avoid them being treated as models)
df_exog = Y_rec_exog.merge(Y_test_df[['unique_id', 'ds', 'y']], on=['unique_id', 'ds'])
eval_exog = evaluate(
    df=df_exog,
    tags=eval_tags,
    train_df=Y_train_df[['unique_id', 'ds', 'y']],
    metrics=[rmse, partial(mase, seasonality=4)],
)
eval_exog.columns = ['level', 'metric', 'Base (w/ exog)', 'BottomUp (w/ exog)', 'MinTrace (w/ exog)']
# Evaluate without exogenous
df_no_exog = Y_rec_no_exog.merge(Y_test_no_exog, on=['unique_id', 'ds'])
eval_no_exog = evaluate(
    df=df_no_exog,
    tags=eval_tags,
    train_df=Y_train_no_exog,
    metrics=[rmse, partial(mase, seasonality=4)],
)
eval_no_exog.columns = ['level', 'metric', 'Base (no exog)', 'BottomUp (no exog)', 'MinTrace (no exog)']
comparison = eval_exog.merge(eval_no_exog, on=['level', 'metric'])
comparison = comparison[[
    'level', 'metric', 
    'Base (no exog)', 'Base (w/ exog)', 
    'BottomUp (no exog)', 'BottomUp (w/ exog)',
    'MinTrace (no exog)', 'MinTrace (w/ exog)',
]]

numeric_cols = comparison.select_dtypes(include='number').columns
comparison[numeric_cols] = comparison[numeric_cols].map('{:.2f}'.format).astype(np.float64)

RMSE

comparison.query('metric == "rmse"')
levelmetricBase (no exog)Base (w/ exog)BottomUp (no exog)BottomUp (w/ exog)MinTrace (no exog)MinTrace (w/ exog)
0Totalrmse773.97731.033247.043218.182029.461946.96
2Purposermse471.46516.06871.70859.13571.76545.22
4Statermse257.90251.32434.51431.99308.33298.49
6Regionsrmse56.5355.5358.1957.7946.4346.08
8Bottomrmse20.3920.4420.3920.4418.1318.22
10Overallrmse38.7238.7853.0052.7140.2639.58

MASE

comparison.query('metric == "mase"')
levelmetricBase (no exog)Base (w/ exog)BottomUp (no exog)BottomUp (w/ exog)MinTrace (no exog)MinTrace (w/ exog)
1Totalmase0.760.733.393.362.011.91
3Purposemase1.141.412.482.491.451.48
5Statemase1.461.261.971.931.421.31
7Regionsmase1.211.201.251.241.021.00
9Bottommase1.021.021.021.020.940.95
11Overallmase1.061.061.101.100.970.97

References