Reconciliation of structured time series forecasts with graphs

8th Sept 2023 @ IIF Reconciliation Workshop

Mitchell O’Hara-Wild, Monash University

Supervised by Rob Hyndman and George Athanasopolous

Hierarchical coherence

Each aggregate has a single constraint

The basic constraint shown before is ‘hierarchical

Hierarchical coherence

Each aggregate has a single constraint

Hierarchical series often have multiple layers

In graph terms, this is known as a polytree.

Grouped coherence

Considering origin and workplace

Attendance can be disaggregated by both origin and workplace

Grouped coherence

Considering origin and workplace

and then further disaggregated by the other.

A grouped structure has the same top and bottom series.

Grouped coherence

Considering origin and workplace

The grouped structure can be plotted in a single graph.

In graph terms, this is a directed acyclical graph (DAG).

Temporal coherence

A time series can be disaggregated by temporal granularity

Temporal reconciliation is described in Athanasopoulos et al. (2017).

What type of coherence structure is this?

This is a polytree, so this structure is hierarchical.

Temporal coherence

What type of coherence structure is this?

This structure has the same top and bottom series, so

temporal coherence is a grouped constraint.

Temporal coherence

Temporal coherence constraints are grouped can also be represented with directed acyclical graphs (DAGs).

Cross-temporal coherence

Since both grouped and temporal coherence are DAGs, the interaction between them is a single DAG.

Graph coherence

A directed acyclical graph does not require a common top and bottom series.


What happens if we relax this definition of grouped coherence, and use the more general DAG structure.

Is it reasonable to leverage the full generality of DAGs?

Yes! Let’s see why.

Unbalanced graphs

What if the coherency structure had different bottom series?


This often occurs in these circumstances:

  1. Cross-temporal with series observed at different granularities.
  2. Multiple different approaches to calculating the top series.
  3. Partial coherency by ‘trimming’ sparse disaggregations.

  1. Cross-temporal with series observed at different granularities.

Example

Suppose Sales is reported quarterly, but Profit and Costs twice yearly.

  1. Cross-temporal with series observed at different granularities.

Example

This allows the higher frequency Sales data to be used with the less frequent Profit and Costs data!

  1. Multiple different approaches to calculating the top series.

Example

Australian GDP is calculated with 3 approaches:

  • Income approach (I)
  • Expenditure approach (E)
  • Production approach (P)

For simplicity consider a small part of these graphs. The complete graph structure has many more disaggregates.

This example is used in Athanasopoulos et al. (2020).

  1. Multiple different approaches to calculating the top series.
  • Income approach (I)
  • Expenditure approach (E)

  1. Multiple different approaches to calculating the top series.
  • Combined approach (I & E)

  1. Partial coherency by ‘trimming’ sparse disaggregations.

If the bottom level is too sparse, it is more complicated to model and reconciliation can worsen forecast accuracy.

Incomplete graphs

What if the coherency structure doesn’t completely aggregate, so that there are multiple top series.


This can occur for many reasons:

  1. Cross-validation with reconciliation
  2. Partial/local coherency
    (e.g. not including worldwide total)

  1. Cross-validation with reconciliation

It makes no sense to aggregate folds of cross-validation.

A suitable DAG for cross-validated hierarchies is

Each disjoint graph can be reconciled separately.

  1. Partial/local coherency

Motivated by discussion with Zeynepz yesterday

  1. Partial/local coherency

Adding Meta as the single parent results in a hierarchy

Note that this isn’t a polytree, due to common/shared leafs.

General linearly constrained time series (Girolimetto and Di Fonzo 2023)

Generalisation from zero-constrained representation

For linear reconciliation, both graph coherence and ‘general linearly constrained’ coherence are equivalent.

Implementation in fable

All currently experimental, but functional. Code on GitHub in various branches.

Useful functions

  • Create aggregates from bottom series with aggregate_key()
    (and someday aggregate_index() for temporal)
  • Or get artisanal with agg_vec() and graph_vec() for complex aggregation structures and graphs.
  • Add coherency constraints to models/forecasts with reconcile() and min_trace()

Planned functionality

  • Tools for identifying sub-graphs for exploring series
  • Tools for changing the graph without removing data
  • Tools for validating operations which change the graph

Examples with fable

library(tsibble)
library(fable)
tourism
# A tsibble: 24,320 x 5 [1Q]
# Key:       Region, State, Purpose [304]
   Quarter Region   State           Purpose  Trips
     <qtr> <chr>    <chr>           <chr>    <dbl>
 1 1998 Q1 Adelaide South Australia Business  135.
 2 1998 Q2 Adelaide South Australia Business  110.
 3 1998 Q3 Adelaide South Australia Business  166.
 4 1998 Q4 Adelaide South Australia Business  127.
 5 1999 Q1 Adelaide South Australia Business  137.
 6 1999 Q2 Adelaide South Australia Business  200.
 7 1999 Q3 Adelaide South Australia Business  169.
 8 1999 Q4 Adelaide South Australia Business  134.
 9 2000 Q1 Adelaide South Australia Business  154.
10 2000 Q2 Adelaide South Australia Business  169.
# i 24,310 more rows

Examples with fable

tourism |> 
  aggregate_key(
    Purpose * (State / Region),
    Trips = sum(Trips)
  )
# A tsibble: 34,000 x 5 [1Q]
# Key:       Purpose, State, Region [425]
   Quarter Purpose      State        Region        Trips
     <qtr> <chr*>       <chr*>       <chr*>        <dbl>
 1 1998 Q1 <aggregated> <aggregated> <aggregated> 23182.
 2 1998 Q2 <aggregated> <aggregated> <aggregated> 20323.
 3 1998 Q3 <aggregated> <aggregated> <aggregated> 19827.
 4 1998 Q4 <aggregated> <aggregated> <aggregated> 20830.
 5 1999 Q1 <aggregated> <aggregated> <aggregated> 22087.
 6 1999 Q2 <aggregated> <aggregated> <aggregated> 21458.
 7 1999 Q3 <aggregated> <aggregated> <aggregated> 19914.
 8 1999 Q4 <aggregated> <aggregated> <aggregated> 20028.
 9 2000 Q1 <aggregated> <aggregated> <aggregated> 22339.
10 2000 Q2 <aggregated> <aggregated> <aggregated> 19941.
# i 33,990 more rows

Examples with fable

tourism |> 
  aggregate_key(
    Purpose * (State / Region),
    Trips = sum(Trips)
  ) |> 
  key_data()
# A tibble: 425 x 4
   Purpose  State           Region                 .rows
   <chr*>   <chr*>          <chr*>                 <lis>
 1 Business ACT             Canberra                [80]
 2 Business ACT             <aggregated>            [80]
 3 Business New South Wales Blue Mountains          [80]
 4 Business New South Wales Capital Country         [80]
 5 Business New South Wales Central Coast           [80]
 6 Business New South Wales Central NSW             [80]
 7 Business New South Wales Hunter                  [80]
 8 Business New South Wales New England North West  [80]
 9 Business New South Wales North Coast NSW         [80]
10 Business New South Wales Outback NSW             [80]
# i 415 more rows

Examples with fable

tourism |> 
  aggregate_key(
    Purpose * State,
    Trips = sum(Trips)
  ) |>  
  key_data()
# A tibble: 45 x 3
   Purpose      State                    .rows
   <chr*>       <chr*>             <list<int>>
 1 Business     ACT                       [80]
 2 Business     New South Wales           [80]
 3 Business     Northern Territory        [80]
 4 Business     Queensland                [80]
 5 Business     South Australia           [80]
 6 Business     Tasmania                  [80]
 7 Business     Victoria                  [80]
 8 Business     Western Australia         [80]
 9 Business     <aggregated>              [80]
10 Holiday      ACT                       [80]
11 Holiday      New South Wales           [80]
12 Holiday      Northern Territory        [80]
13 Holiday      Queensland                [80]
14 Holiday      South Australia           [80]
15 Holiday      Tasmania                  [80]
16 Holiday      Victoria                  [80]
17 Holiday      Western Australia         [80]
18 Holiday      <aggregated>              [80]
19 Other        ACT                       [80]
20 Other        New South Wales           [80]
21 Other        Northern Territory        [80]
22 Other        Queensland                [80]
23 Other        South Australia           [80]
24 Other        Tasmania                  [80]
25 Other        Victoria                  [80]
26 Other        Western Australia         [80]
27 Other        <aggregated>              [80]
28 Visiting     ACT                       [80]
29 Visiting     New South Wales           [80]
30 Visiting     Northern Territory        [80]
31 Visiting     Queensland                [80]
32 Visiting     South Australia           [80]
33 Visiting     Tasmania                  [80]
34 Visiting     Victoria                  [80]
35 Visiting     Western Australia         [80]
36 Visiting     <aggregated>              [80]
37 <aggregated> ACT                       [80]
38 <aggregated> New South Wales           [80]
39 <aggregated> Northern Territory        [80]
40 <aggregated> Queensland                [80]
41 <aggregated> South Australia           [80]
42 <aggregated> Tasmania                  [80]
43 <aggregated> Victoria                  [80]
44 <aggregated> Western Australia         [80]
45 <aggregated> <aggregated>              [80]

Examples with fable

tourism |> 
  aggregate_key(
    Purpose : State,
    Trips = sum(Trips)
  ) |> 
  key_data()
# A tibble: 33 x 3
   Purpose      State                    .rows
   <chr*>       <chr*>             <list<int>>
 1 Business     ACT                       [80]
 2 Business     New South Wales           [80]
 3 Business     Northern Territory        [80]
 4 Business     Queensland                [80]
 5 Business     South Australia           [80]
 6 Business     Tasmania                  [80]
 7 Business     Victoria                  [80]
 8 Business     Western Australia         [80]
 9 Holiday      ACT                       [80]
10 Holiday      New South Wales           [80]
11 Holiday      Northern Territory        [80]
12 Holiday      Queensland                [80]
13 Holiday      South Australia           [80]
14 Holiday      Tasmania                  [80]
15 Holiday      Victoria                  [80]
16 Holiday      Western Australia         [80]
17 Other        ACT                       [80]
18 Other        New South Wales           [80]
19 Other        Northern Territory        [80]
20 Other        Queensland                [80]
21 Other        South Australia           [80]
22 Other        Tasmania                  [80]
23 Other        Victoria                  [80]
24 Other        Western Australia         [80]
25 Visiting     ACT                       [80]
26 Visiting     New South Wales           [80]
27 Visiting     Northern Territory        [80]
28 Visiting     Queensland                [80]
29 Visiting     South Australia           [80]
30 Visiting     Tasmania                  [80]
31 Visiting     Victoria                  [80]
32 Visiting     Western Australia         [80]
33 <aggregated> <aggregated>              [80]

Examples with fable

tourism |> 
  aggregate_key(
    Purpose + State,
    Trips = sum(Trips)
  ) |> 
  key_data()
# A tibble: 13 x 3
   Purpose      State                    .rows
   <chr*>       <chr*>             <list<int>>
 1 Business     <aggregated>              [80]
 2 Holiday      <aggregated>              [80]
 3 Other        <aggregated>              [80]
 4 Visiting     <aggregated>              [80]
 5 <aggregated> ACT                       [80]
 6 <aggregated> New South Wales           [80]
 7 <aggregated> Northern Territory        [80]
 8 <aggregated> Queensland                [80]
 9 <aggregated> South Australia           [80]
10 <aggregated> Tasmania                  [80]
11 <aggregated> Victoria                  [80]
12 <aggregated> Western Australia         [80]
13 <aggregated> <aggregated>              [80]

Examples with fable

tourism |> 
  aggregate_key(
    0 + Purpose + State,
    Trips = sum(Trips)
  ) |> 
  key_data()
# A tibble: 12 x 3
   Purpose      State                    .rows
   <chr*>       <chr*>             <list<int>>
 1 Business     <aggregated>              [80]
 2 Holiday      <aggregated>              [80]
 3 Other        <aggregated>              [80]
 4 Visiting     <aggregated>              [80]
 5 <aggregated> ACT                       [80]
 6 <aggregated> New South Wales           [80]
 7 <aggregated> Northern Territory        [80]
 8 <aggregated> Queensland                [80]
 9 <aggregated> South Australia           [80]
10 <aggregated> Tasmania                  [80]
11 <aggregated> Victoria                  [80]
12 <aggregated> Western Australia         [80]

Examples with fable

tourism |> 
  aggregate_key(
    Purpose,
    Trips = sum(Trips)
  ) |> 
  stretch_tsibble(.step = 4, .init = 60) |> 
  key_data()
# A tibble: 30 x 3
     .id Purpose            .rows
   <int> <chr*>       <list<int>>
 1     1 Business            [60]
 2     1 Holiday             [60]
 3     1 Other               [60]
 4     1 Visiting            [60]
 5     1 <aggregated>        [60]
 6     2 Business            [64]
 7     2 Holiday             [64]
 8     2 Other               [64]
 9     2 Visiting            [64]
10     2 <aggregated>        [64]
11     3 Business            [68]
12     3 Holiday             [68]
13     3 Other               [68]
14     3 Visiting            [68]
15     3 <aggregated>        [68]
16     4 Business            [72]
17     4 Holiday             [72]
18     4 Other               [72]
19     4 Visiting            [72]
20     4 <aggregated>        [72]
21     5 Business            [76]
22     5 Holiday             [76]
23     5 Other               [76]
24     5 Visiting            [76]
25     5 <aggregated>        [76]
26     6 Business            [80]
27     6 Holiday             [80]
28     6 Other               [80]
29     6 Visiting            [80]
30     6 <aggregated>        [80]

Examples with fable

tourism |> 
  aggregate_key(
    Purpose,
    Trips = sum(Trips)
  ) |> 
  stretch_tsibble(.step = 4, .init = 60) |> 
  model(ets = ETS(Trips)) |> 
  reconcile(ets_coherent = min_trace(ets)) |> 
  forecast(h = "1 year")
# A fable: 240 x 6 [1Q]
# Key:     .id, Purpose, .model [60]
     .id Purpose  .model       Quarter            Trips
   <int> <chr*>   <chr>          <qtr>           <dist>
 1     1 Business ets          2013 Q1   N(3135, 46032)
 2     1 Business ets          2013 Q2   N(3832, 73712)
 3     1 Business ets          2013 Q3   N(4158, 93320)
 4     1 Business ets          2013 Q4   N(3781, 88006)
 5     1 Business ets_coherent 2013 Q1   N(3159, 45373)
 6     1 Business ets_coherent 2013 Q2   N(3848, 69353)
 7     1 Business ets_coherent 2013 Q3   N(4177, 86871)
 8     1 Business ets_coherent 2013 Q4   N(3795, 83437)
 9     1 Holiday  ets          2013 Q1 N(10442, 211377)
10     1 Holiday  ets          2013 Q2  N(8698, 146661)
# i 230 more rows
# i 1 more variable: .mean <dbl>

Examples with fable

Recap

Coherence and graph theory

  • Hierarchical coherence is a polytree.
  • Grouped coherence is a restricted DAG.
  • Graph coherence is an unrestricted DAG.

DAGs are a useful tool for representing structured time series and producing coherent forecasts.

Recap

Coherence and graph theory

  • Hierarchical coherence is a polytree.
  • Grouped coherence is a restricted DAG.
  • Graph coherence is an unrestricted DAG.

DAGs are a useful tool for representing structured time series and producing coherent forecasts.

What else?

Other benefits

  1. Access to efficient graph algorithms and ideas
  2. Exploration and description of structured time series
  3. Familiar computing grammar for coherent data

Future work

  1. Rework fable’s reconciliation to be built around graphs
  2. Investigate alternative graph reconciliation methods (non-linear constraints, faster at-scale reconciliation)

Thanks for your time!

Final remarks

  • I’m trying to build a user-friendly design framework for forecast reconciliation - it’s hard!
  • Great to hear the many different reconciliation techniques, and the many ideas for new methods.
  • Searching for strange coherency constraints, let’s chat!

Unsplash credits

Thanks to these Unsplash contributors for their photos

References

Athanasopoulos, George, Puwasala Gamakumara, Anastasios Panagiotelis, Rob J. Hyndman, and Mohamed Affan. 2020. “Hierarchical Forecasting.” In Macroeconomic Forecasting in the Era of Big Data: Theory and Practice, edited by Peter Fuleky, 689–719. Cham: Springer International Publishing. https://doi.org/10.1007/978-3-030-31150-6_21.
Athanasopoulos, George, Rob J. Hyndman, Nikolaos Kourentzes, and Fotios Petropoulos. 2017. “Forecasting with Temporal Hierarchies.” European Journal of Operational Research 262 (1): 60–74. https://doi.org/https://doi.org/10.1016/j.ejor.2017.02.046.
Girolimetto, Daniele, and Tommaso Di Fonzo. 2023. “Point and Probabilistic Forecast Reconciliation for General Linearly Constrained Multiple Time Series.” https://arxiv.org/abs/2305.05330.