If any packages are missing, run this chunk once in the R
Console before knitting.
It is intentionally set to eval=FALSE so knitting does not
try to install packages.
install.packages(
c("fpp3", "rugarch", "knitr", "rmarkdown"),
repos = "https://cloud.r-project.org",
dependencies = TRUE
)
For this section, I use the tourism dataset from
fpp3. The data contain quarterly Australian domestic
tourism trips. A natural geographic hierarchy is:
Australia to State to Region
The aggregate_key() function creates all levels of the
hierarchy while preserving the relationship between State and
Region.
data("tourism", package = "tsibbledata")
tourism_hts <- tourism |>
aggregate_key(
State / Region,
Trips = sum(Trips)
)
tourism_hts
## # A tsibble: 6,800 x 4 [1Q]
## # Key: State, Region [85]
## Quarter State Region Trips
## <qtr> <chr*> <chr*> <dbl>
## 1 1998 Q1 <aggregated> <aggregated> 23182.
## 2 1998 Q2 <aggregated> <aggregated> 20323.
## 3 1998 Q3 <aggregated> <aggregated> 19827.
## 4 1998 Q4 <aggregated> <aggregated> 20830.
## 5 1999 Q1 <aggregated> <aggregated> 22087.
## 6 1999 Q2 <aggregated> <aggregated> 21458.
## 7 1999 Q3 <aggregated> <aggregated> 19914.
## 8 1999 Q4 <aggregated> <aggregated> 20028.
## 9 2000 Q1 <aggregated> <aggregated> 22339.
## 10 2000 Q2 <aggregated> <aggregated> 19941.
## # ℹ 6,790 more rows
The following visualization focuses on the national and state levels.
tourism_hts |>
filter(is_aggregated(Region)) |>
autoplot(Trips) +
labs(
title = "Australian Tourism: National and State-Level Trips",
subtitle = "Quarterly overnight trips",
x = "Quarter",
y = "Trips ('000)"
) +
facet_wrap(vars(State), scales = "free_y", ncol = 3) +
theme(legend.position = "none")
To inspect a lower level of the hierarchy, the next graph shows regional tourism series for one state.
tourism |>
filter(State == "New South Wales") |>
autoplot(Trips) +
labs(
title = "Regional Tourism within New South Wales",
x = "Quarter",
y = "Trips ('000)",
colour = "Region"
) +
facet_wrap(vars(Region), scales = "free_y")
The hierarchy contains a national total at the highest level, state totals at the middle level, and individual regions at the bottom level. The plots show that different states and regions have distinct levels, trends, and seasonal patterns. This is important because a single national forecast may hide meaningful local behavior.
I reserve the final eight quarters (two years) as a test set.
tourism_cutoff <- max(tourism$Quarter) - 8
tourism_train <- tourism |>
filter(Quarter <= tourism_cutoff) |>
aggregate_key(
State / Region,
Trips = sum(Trips)
)
tourism_test <- tourism |>
filter(Quarter > tourism_cutoff) |>
aggregate_key(
State / Region,
Trips = sum(Trips)
)
range(tourism_train$Quarter)
## <yearquarter[2]>
## [1] "1998 Q1" "2015 Q4"
## # Year starts on: January
range(tourism_test$Quarter)
## <yearquarter[2]>
## [1] "2016 Q1" "2017 Q4"
## # Year starts on: January
An ETS model is fitted to each series. I then reconcile the forecasts using:
tourism_fit <- tourism_train |>
model(
base = ETS(Trips)
) |>
reconcile(
bottom_up = bottom_up(base),
top_down = top_down(base, method = "forecast_proportions"),
middle_out = middle_out(base, split = 1)
)
tourism_fit
## # A mable: 85 x 6
## # Key: State, Region [85]
## State Region base bottom_up top_down middle_out
## <chr*> <chr*> <model> <model> <model> <model>
## 1 ACT … Canberra … <ETS(A,N,N)> <ETS(A,N,N)> <ETS(A,N,N)> <ETS(A,N,N)>
## 2 ACT … <aggregat… <ETS(A,N,N)> <ETS(A,N,N)> <ETS(A,N,N)> <ETS(A,N,N)>
## 3 New South Wal… Blue Moun… <ETS(M,N,M)> <ETS(M,N,M)> <ETS(M,N,M)> <ETS(M,N,M)>
## 4 New South Wal… Capital C… <ETS(M,N,A)> <ETS(M,N,A)> <ETS(M,N,A)> <ETS(M,N,A)>
## 5 New South Wal… Central C… <ETS(A,N,A)> <ETS(A,N,A)> <ETS(A,N,A)> <ETS(A,N,A)>
## 6 New South Wal… Central N… <ETS(M,N,M)> <ETS(M,N,M)> <ETS(M,N,M)> <ETS(M,N,M)>
## 7 New South Wal… Hunter … <ETS(A,N,A)> <ETS(A,N,A)> <ETS(A,N,A)> <ETS(A,N,A)>
## 8 New South Wal… New Engla… <ETS(M,N,N)> <ETS(M,N,N)> <ETS(M,N,N)> <ETS(M,N,N)>
## 9 New South Wal… North Coa… <ETS(A,N,A)> <ETS(A,N,A)> <ETS(A,N,A)> <ETS(A,N,A)>
## 10 New South Wal… Outback N… <ETS(M,N,A)> <ETS(M,N,A)> <ETS(M,N,A)> <ETS(M,N,A)>
## # ℹ 75 more rows
tourism_fc <- tourism_fit |>
forecast(h = 8)
tourism_fc
## # A fable: 2,720 x 6 [1Q]
## # Key: State, Region, .model [340]
## State Region .model Quarter
## <chr*> <chr*> <chr> <qtr>
## 1 ACT Canberra base 2016 Q1
## 2 ACT Canberra base 2016 Q2
## 3 ACT Canberra base 2016 Q3
## 4 ACT Canberra base 2016 Q4
## 5 ACT Canberra base 2017 Q1
## 6 ACT Canberra base 2017 Q2
## 7 ACT Canberra base 2017 Q3
## 8 ACT Canberra base 2017 Q4
## 9 ACT Canberra bottom_up 2016 Q1
## 10 ACT Canberra bottom_up 2016 Q2
## # ℹ 2,710 more rows
## # ℹ 2 more variables: Trips <dist>, .mean <dbl>
The national-level forecasts from all methods can be compared visually.
tourism_fc |>
filter(is_aggregated(State), is_aggregated(Region)) |>
autoplot(
tourism_hts |>
filter(is_aggregated(State), is_aggregated(Region))
) +
labs(
title = "National Tourism Forecasts by Reconciliation Method",
x = "Quarter",
y = "Trips ('000)",
colour = "Method"
)
The following plot compares state-level forecasts.
tourism_fc |>
filter(!is_aggregated(State), is_aggregated(Region)) |>
autoplot(
tourism_hts |>
filter(!is_aggregated(State), is_aggregated(Region))
) +
labs(
title = "State-Level Tourism Forecasts",
x = "Quarter",
y = "Trips ('000)",
colour = "Method"
) +
facet_wrap(vars(State), scales = "free_y")
Forecast accuracy is evaluated against the test data.
tourism_accuracy <- tourism_fc |>
accuracy(
tourism_test,
measures = list(
RMSE = RMSE,
MAPE = MAPE
)
)
tourism_accuracy |>
select(.model, State, Region, RMSE, MAPE) |>
arrange(.model, RMSE) |>
head(20) |>
kable(
digits = 2,
caption = "Sample of Hierarchical Forecast Accuracy Results"
)
| .model | State | Region | RMSE | MAPE |
|---|---|---|---|---|
| base | Tasmania | Wilderness West | 7.69 | 18.83 |
| base | South Australia | Clare Valley | 7.85 | 13.38 |
| base | South Australia | Eyre Peninsula | 10.36 | 9.56 |
| base | South Australia | Barossa | 10.93 | 14.52 |
| base | Northern Territory | MacDonnell | 11.07 | 58.99 |
| base | South Australia | Kangaroo Island | 11.15 | 29.91 |
| base | Northern Territory | Barkly | 12.30 | 43.41 |
| base | Northern Territory | Kakadu Arnhem | 13.93 | 18.27 |
| base | Northern Territory | Lasseter | 14.31 | 34.00 |
| base | Victoria | Murray East | 15.87 | 28.61 |
| base | South Australia | Murraylands | 16.34 | 18.45 |
| base | South Australia | Adelaide Hills | 18.85 | 40.89 |
| base | Northern Territory | Alice Springs | 19.45 | 21.10 |
| base | Victoria | Wimmera | 20.81 | 35.59 |
| base | New South Wales | Outback NSW | 21.12 | 14.06 |
| base | South Australia | Limestone Coast | 22.25 | 14.15 |
| base | Victoria | Macedon | 22.53 | 20.90 |
| base | Northern Territory | Darwin | 23.09 | 10.50 |
| base | Victoria | Western Grampians | 23.17 | 24.16 |
| base | South Australia | Riverland | 23.64 | 14.48 |
To make the comparison easier, I classify each series into a hierarchy level.
tourism_accuracy_by_level <- tourism_accuracy |>
mutate(
Level = case_when(
is_aggregated(State) & is_aggregated(Region) ~ "National",
!is_aggregated(State) & is_aggregated(Region) ~ "State",
!is_aggregated(State) & !is_aggregated(Region) ~ "Region",
TRUE ~ "Other"
)
) |>
group_by(.model, Level) |>
summarise(
RMSE = mean(RMSE, na.rm = TRUE),
MAPE = mean(MAPE, na.rm = TRUE),
.groups = "drop"
)
tourism_accuracy_by_level |>
arrange(Level, RMSE) |>
kable(
digits = 2,
caption = "Average RMSE and MAPE by Hierarchical Level"
)
| .model | Level | RMSE | MAPE |
|---|---|---|---|
| base | National | 1720.72 | 5.22 |
| top_down | National | 1720.72 | 5.22 |
| middle_out | National | 2026.98 | 6.48 |
| bottom_up | National | 2514.19 | 8.73 |
| top_down | Region | 44.87 | 16.66 |
| middle_out | Region | 46.97 | 16.91 |
| base | Region | 52.64 | 17.54 |
| bottom_up | Region | 52.64 | 17.54 |
| top_down | State | 278.88 | 9.06 |
| middle_out | State | 306.85 | 9.83 |
| base | State | 306.85 | 9.83 |
| bottom_up | State | 388.67 | 11.34 |
tourism_accuracy_by_level |>
ggplot(aes(x = .model, y = RMSE, fill = .model)) +
geom_col(show.legend = FALSE) +
facet_wrap(vars(Level), scales = "free_y") +
labs(
title = "RMSE by Reconciliation Method and Hierarchical Level",
x = "Forecast Method",
y = "Average RMSE"
)
For grouped forecasting, I use aus_retail. Australian
retail turnover can be grouped across two dimensions:
Unlike a strict hierarchy, State and Industry are crossed dimensions. For example, an industry can appear in many states, and each state contains many industries.
data("aus_retail", package = "tsibbledata")
aus_retail
## # A tsibble: 64,532 x 5 [1M]
## # Key: State, Industry [152]
## State Industry `Series ID` Month Turnover
## <chr> <chr> <chr> <mth> <dbl>
## 1 Australian Capital Territory Cafes, restaurant… A3349849A 1982 Apr 4.4
## 2 Australian Capital Territory Cafes, restaurant… A3349849A 1982 May 3.4
## 3 Australian Capital Territory Cafes, restaurant… A3349849A 1982 Jun 3.6
## 4 Australian Capital Territory Cafes, restaurant… A3349849A 1982 Jul 4
## 5 Australian Capital Territory Cafes, restaurant… A3349849A 1982 Aug 3.6
## 6 Australian Capital Territory Cafes, restaurant… A3349849A 1982 Sep 4.2
## 7 Australian Capital Territory Cafes, restaurant… A3349849A 1982 Oct 4.8
## 8 Australian Capital Territory Cafes, restaurant… A3349849A 1982 Nov 5.4
## 9 Australian Capital Territory Cafes, restaurant… A3349849A 1982 Dec 6.9
## 10 Australian Capital Territory Cafes, restaurant… A3349849A 1983 Jan 3.8
## # ℹ 64,522 more rows
The following plot compares total retail turnover by state.
aus_retail |>
group_by(State) |>
index_by(Month) |>
summarise(
Turnover = sum(Turnover, na.rm = TRUE)
) |>
autoplot(Turnover) +
labs(
title = "Australian Retail Turnover by State",
x = "Month",
y = "Turnover"
)
The next plot compares total turnover by industry.
aus_retail |>
group_by(Industry) |>
index_by(Month) |>
summarise(
Turnover = sum(Turnover, na.rm = TRUE)
) |>
autoplot(Turnover) +
labs(
title = "Australian Retail Turnover by Industry",
x = "Month",
y = "Turnover"
) +
facet_wrap(vars(Industry), scales = "free_y")
The expression State * Industry creates aggregate series
for State, Industry, State-by-Industry combinations, and the overall
total.
retail_grouped <- aus_retail |>
aggregate_key(
State * Industry,
Turnover = sum(Turnover)
)
retail_grouped
## # A tsibble: 77,249 x 4 [1M]
## # Key: State, Industry [181]
## Month State Industry Turnover
## <mth> <chr*> <chr*> <dbl>
## 1 1982 Apr <aggregated> <aggregated> 6225.
## 2 1982 May <aggregated> <aggregated> 6382.
## 3 1982 Jun <aggregated> <aggregated> 6162.
## 4 1982 Jul <aggregated> <aggregated> 6399.
## 5 1982 Aug <aggregated> <aggregated> 6163.
## 6 1982 Sep <aggregated> <aggregated> 6331.
## 7 1982 Oct <aggregated> <aggregated> 6535.
## 8 1982 Nov <aggregated> <aggregated> 7022.
## 9 1982 Dec <aggregated> <aggregated> 9322.
## 10 1983 Jan <aggregated> <aggregated> 6286.
## # ℹ 77,239 more rows
The final 24 months are reserved for testing.
retail_cutoff <- max(aus_retail$Month) - 24
retail_train <- aus_retail |>
filter(Month <= retail_cutoff) |>
aggregate_key(
State * Industry,
Turnover = sum(Turnover)
)
retail_test <- aus_retail |>
filter(Month > retail_cutoff) |>
aggregate_key(
State * Industry,
Turnover = sum(Turnover)
)
range(retail_train$Month)
## <yearmonth[2]>
## [1] "1982 Apr" "2016 Dec"
range(retail_test$Month)
## <yearmonth[2]>
## [1] "2017 Jan" "2018 Dec"
I fit an ETS model separately to every series in the grouped structure.
retail_fit <- retail_train |>
model(
ets = ETS(Turnover)
) |>
reconcile(
bottom_up = bottom_up(ets),
ols = min_trace(ets, method = "ols")
)
retail_fit
## # A mable: 181 x 5
## # Key: State, Industry [181]
## State Industry ets bottom_up ols
## <chr*> <chr*> <model> <model> <model>
## 1 Australian Capital Territo… Cafes, re… <ETS(M,A,A)> <ETS(M,A,A)> <ETS(M,A,A)>
## 2 Australian Capital Territo… Cafes, re… <ETS(M,A,M)> <ETS(M,A,M)> <ETS(M,A,M)>
## 3 Australian Capital Territo… Clothing … <ETS(M,A,M)> <ETS(M,A,M)> <ETS(M,A,M)>
## 4 Australian Capital Territo… Clothing,… <ETS(M,A,M)> <ETS(M,A,M)> <ETS(M,A,M)>
## 5 Australian Capital Territo… Departmen… <ETS(M,A,M)> <ETS(M,A,M)> <ETS(M,A,M)>
## 6 Australian Capital Territo… Electrica… <ETS(M,A,M)> <ETS(M,A,M)> <ETS(M,A,M)>
## 7 Australian Capital Territo… Food reta… <ETS(M,A,M)> <ETS(M,A,M)> <ETS(M,A,M)>
## 8 Australian Capital Territo… Footwear … <ETS(M,A,M)> <ETS(M,A,M)> <ETS(M,A,M)>
## 9 Australian Capital Territo… Furniture… <ETS(M,A,M)> <ETS(M,A,M)> <ETS(M,A,M)>
## 10 Australian Capital Territo… Hardware,… <ETS(M,N,M)> <ETS(M,N,M)> <ETS(M,N,M)>
## # ℹ 171 more rows
Here, the unreconciled ets forecasts act as the
flat / independently modeled forecasts, while
mint produces forecasts that are coherent across the State
and Industry grouping structure.
retail_fc <- retail_fit |>
forecast(h = 24)
retail_fc
## # A fable: 13,032 x 6 [1M]
## # Key: State, Industry, .model [543]
## State Industry .model Month
## <chr*> <chr*> <chr> <mth>
## 1 Australian Capital Territory Cafes, restaurants and catering… ets 2017 Jan
## 2 Australian Capital Territory Cafes, restaurants and catering… ets 2017 Feb
## 3 Australian Capital Territory Cafes, restaurants and catering… ets 2017 Mar
## 4 Australian Capital Territory Cafes, restaurants and catering… ets 2017 Apr
## 5 Australian Capital Territory Cafes, restaurants and catering… ets 2017 May
## 6 Australian Capital Territory Cafes, restaurants and catering… ets 2017 Jun
## 7 Australian Capital Territory Cafes, restaurants and catering… ets 2017 Jul
## 8 Australian Capital Territory Cafes, restaurants and catering… ets 2017 Aug
## 9 Australian Capital Territory Cafes, restaurants and catering… ets 2017 Sep
## 10 Australian Capital Territory Cafes, restaurants and catering… ets 2017 Oct
## # ℹ 13,022 more rows
## # ℹ 2 more variables: Turnover <dist>, .mean <dbl>
The overall Australian retail forecast can be viewed below.
retail_fc |>
filter(is_aggregated(State), is_aggregated(Industry)) |>
autoplot(
retail_grouped |>
filter(is_aggregated(State), is_aggregated(Industry))
) +
labs(
title = "Total Australian Retail Turnover Forecast",
x = "Month",
y = "Turnover",
colour = "Model"
)
State-level grouped forecasts are shown below.
retail_fc |>
filter(!is_aggregated(State), is_aggregated(Industry)) |>
autoplot(
retail_grouped |>
filter(!is_aggregated(State), is_aggregated(Industry))
) +
facet_wrap(vars(State), scales = "free_y") +
labs(
title = "Retail Forecasts by State",
x = "Month",
y = "Turnover"
)
retail_accuracy <- retail_fc |>
accuracy(
retail_test,
measures = list(
RMSE = RMSE,
MAPE = MAPE
)
)
retail_accuracy |>
select(.model, State, Industry, RMSE, MAPE) |>
arrange(.model, RMSE) |>
head(20) |>
kable(
digits = 2,
caption = "Sample of Grouped Forecast Accuracy Results"
)
| .model | State | Industry | RMSE | MAPE |
|---|---|---|---|---|
| bottom_up | Northern Territory | Newspaper and book retailing | 0.34 | 23.03 |
| bottom_up | Northern Territory | Footwear and other personal accessory retailing | 0.48 | 5.72 |
| bottom_up | Australian Capital Territory | Liquor retailing | 0.66 | 4.11 |
| bottom_up | Northern Territory | Clothing retailing | 0.81 | 8.61 |
| bottom_up | Northern Territory | Furniture, floor coverings, houseware and textile goods retailing | 0.83 | 8.73 |
| bottom_up | Tasmania | Footwear and other personal accessory retailing | 0.87 | 9.37 |
| bottom_up | South Australia | Newspaper and book retailing | 0.90 | 7.03 |
| bottom_up | Northern Territory | Other recreational goods retailing | 1.06 | 16.75 |
| bottom_up | Northern Territory | Pharmaceutical, cosmetic and toiletry goods retailing | 1.25 | 12.35 |
| bottom_up | Australian Capital Territory | Newspaper and book retailing | 1.32 | 20.08 |
| bottom_up | Northern Territory | Clothing, footwear and personal accessory retailing | 1.32 | 8.27 |
| bottom_up | Northern Territory | Hardware, building and garden supplies retailing | 1.33 | 5.65 |
| bottom_up | Northern Territory | Electrical and electronic goods retailing | 1.48 | 9.67 |
| bottom_up | Australian Capital Territory | Other recreational goods retailing | 1.52 | 13.24 |
| bottom_up | Australian Capital Territory | Department stores | 1.55 | 3.39 |
| bottom_up | Australian Capital Territory | Other specialised food retailing | 1.57 | 10.61 |
| bottom_up | Australian Capital Territory | Furniture, floor coverings, houseware and textile goods retailing | 1.61 | 5.03 |
| bottom_up | Australian Capital Territory | Footwear and other personal accessory retailing | 1.69 | 8.28 |
| bottom_up | Tasmania | Takeaway food services | 1.85 | 5.78 |
| bottom_up | Australian Capital Territory | Hardware, building and garden supplies retailing | 1.96 | 5.48 |
Classify each series according to its grouping level.
retail_accuracy_by_level <- retail_accuracy |>
mutate(
Level = case_when(
is_aggregated(State) & is_aggregated(Industry) ~ "Total",
!is_aggregated(State) & is_aggregated(Industry) ~ "State",
is_aggregated(State) & !is_aggregated(Industry) ~ "Industry",
!is_aggregated(State) & !is_aggregated(Industry) ~ "State x Industry",
TRUE ~ "Other"
)
) |>
group_by(.model, Level) |>
summarise(
RMSE = mean(RMSE, na.rm = TRUE),
MAPE = mean(MAPE, na.rm = TRUE),
.groups = "drop"
)
retail_accuracy_by_level |>
arrange(Level, RMSE) |>
kable(
digits = 2,
caption = "Average Accuracy by Grouping Level"
)
| .model | Level | RMSE | MAPE |
|---|---|---|---|
| ets | Industry | 61.08 | 2.99 |
| ols | Industry | 66.44 | 3.55 |
| bottom_up | Industry | 73.44 | 5.13 |
| ets | State | 140.60 | 1.97 |
| ols | State | 155.25 | 3.10 |
| bottom_up | State | 171.59 | 2.47 |
| bottom_up | State x Industry | 14.70 | 6.92 |
| ets | State x Industry | 14.70 | 6.92 |
| ols | State x Industry | 16.39 | 13.09 |
| bottom_up | Total | 693.42 | 1.25 |
| ols | Total | 877.40 | 1.44 |
| ets | Total | 913.39 | 1.49 |
retail_accuracy_by_level |>
ggplot(aes(x = .model, y = RMSE, fill = .model)) +
geom_col(show.legend = FALSE) +
facet_wrap(vars(Level), scales = "free_y") +
labs(
title = "Flat ETS vs Reconciled MinT Forecasts",
subtitle = "Lower RMSE indicates better forecast accuracy",
x = "Model",
y = "Average RMSE"
)
The following table directly compares the unreconciled ETS forecasts with the MinT-reconciled forecasts.
retail_comparison <- retail_accuracy_by_level |>
select(.model, Level, RMSE, MAPE) |>
pivot_wider(
names_from = .model,
values_from = c(RMSE, MAPE)
)
retail_comparison |>
kable(
digits = 2,
caption = "Comparison of Flat and Reconciled Grouped Forecasts"
)
| Level | RMSE_bottom_up | RMSE_ets | RMSE_ols | MAPE_bottom_up | MAPE_ets | MAPE_ols |
|---|---|---|---|---|---|---|
| Industry | 73.44 | 61.08 | 66.44 | 5.13 | 2.99 | 3.55 |
| State | 171.59 | 140.60 | 155.25 | 2.47 | 1.97 | 3.10 |
| State x Industry | 14.70 | 14.70 | 16.39 | 6.92 | 6.92 | 13.09 |
| Total | 693.42 | 913.39 | 877.40 | 1.25 | 1.49 | 1.44 |
Volatility clustering refers to the tendency for periods of large changes to be followed by other large changes, while calmer periods tend to be followed by additional calm periods. Financial return series often display this behavior even when the mean return itself is difficult to predict.
To keep this code reproducible, I did a GARCH-type return series with changing conditional variance.
set.seed(123)
n <- 1200
omega_true <- 0.05
alpha_true <- 0.12
beta_true <- 0.82
returns <- numeric(n)
variance <- numeric(n)
# Start at the theoretical unconditional variance.
variance[1] <- omega_true / (1 - alpha_true - beta_true)
returns[1] <- sqrt(variance[1]) * rnorm(1)
for (t in 2:n) {
variance[t] <- omega_true +
alpha_true * returns[t - 1]^2 +
beta_true * variance[t - 1]
returns[t] <- sqrt(variance[t]) * rnorm(1)
}
vol_data <- tibble(
Time = 1:n,
Return = returns,
TrueVariance = variance
)
head(vol_data)
## # A tibble: 6 × 3
## Time Return TrueVariance
## <int> <dbl> <dbl>
## 1 1 -0.512 0.833
## 2 2 -0.201 0.765
## 3 3 1.29 0.682
## 4 4 0.0634 0.808
## 5 5 0.109 0.713
## 6 6 1.37 0.636
ggplot(vol_data, aes(x = Time, y = Return)) +
geom_line() +
labs(
title = "Simulated Return Series with Volatility Clustering",
x = "Time",
y = "Return"
)
The magnitude of returns changes over time. There are visible clusters of relatively high and low volatility.
Squared returns provide a simple visual indicator of changing variance.
vol_data |>
mutate(SquaredReturn = Return^2) |>
ggplot(aes(x = Time, y = SquaredReturn)) +
geom_line() +
labs(
title = "Squared Returns",
subtitle = "Clusters of large squared returns suggest time-varying volatility",
x = "Time",
y = "Squared Return"
)
A GARCH(1,1) model specifies the conditional variance as:
\[ \sigma_t^2 = \omega + \alpha_1 \epsilon_{t-1}^2 + \beta_1 \sigma_{t-1}^2 \]
where:
garch_spec <- ugarchspec(
variance.model = list(
model = "sGARCH",
garchOrder = c(1, 1)
),
mean.model = list(
armaOrder = c(0, 0),
include.mean = TRUE
),
distribution.model = "norm"
)
garch_fit <- ugarchfit(
spec = garch_spec,
data = returns,
solver = "hybrid"
)
garch_fit
##
## *---------------------------------*
## * GARCH Model Fit *
## *---------------------------------*
##
## Conditional Variance Dynamics
## -----------------------------------
## GARCH Model : sGARCH(1,1)
## Mean Model : ARFIMA(0,0,0)
## Distribution : norm
##
## Optimal Parameters
## ------------------------------------
## Estimate Std. Error t value Pr(>|t|)
## mu 0.013222 0.023211 0.56963 0.568928
## omega 0.073182 0.030446 2.40367 0.016232
## alpha1 0.098617 0.025395 3.88331 0.000103
## beta1 0.804396 0.057694 13.94238 0.000000
##
## Robust Standard Errors:
## Estimate Std. Error t value Pr(>|t|)
## mu 0.013222 0.020448 0.64658 0.517901
## omega 0.073182 0.027586 2.65287 0.007981
## alpha1 0.098617 0.025744 3.83065 0.000128
## beta1 0.804396 0.053498 15.03600 0.000000
##
## LogLikelihood : -1504.86
##
## Information Criteria
## ------------------------------------
##
## Akaike 2.5148
## Bayes 2.5317
## Shibata 2.5147
## Hannan-Quinn 2.5212
##
## Weighted Ljung-Box Test on Standardized Residuals
## ------------------------------------
## statistic p-value
## Lag[1] 1.839 0.1751
## Lag[2*(p+q)+(p+q)-1][2] 2.505 0.1913
## Lag[4*(p+q)+(p+q)-1][5] 3.151 0.3801
## d.o.f=0
## H0 : No serial correlation
##
## Weighted Ljung-Box Test on Standardized Squared Residuals
## ------------------------------------
## statistic p-value
## Lag[1] 0.6809 0.4093
## Lag[2*(p+q)+(p+q)-1][5] 4.6427 0.1841
## Lag[4*(p+q)+(p+q)-1][9] 7.4821 0.1620
## d.o.f=2
##
## Weighted ARCH LM Tests
## ------------------------------------
## Statistic Shape Scale P-Value
## ARCH Lag[3] 3.469 0.500 2.000 0.06252
## ARCH Lag[5] 6.014 1.440 1.667 0.05961
## ARCH Lag[7] 7.029 2.315 1.543 0.08562
##
## Nyblom stability test
## ------------------------------------
## Joint Statistic: 0.4475
## Individual Statistics:
## mu 0.03021
## omega 0.32200
## alpha1 0.30225
## beta1 0.31752
##
## Asymptotic Critical Values (10% 5% 1%)
## Joint Statistic: 1.07 1.24 1.6
## Individual Statistic: 0.35 0.47 0.75
##
## Sign Bias Test
## ------------------------------------
## t-value prob sig
## Sign Bias 1.1224 0.2619
## Negative Sign Bias 0.4781 0.6327
## Positive Sign Bias 1.0756 0.2823
## Joint Effect 1.5817 0.6635
##
##
## Adjusted Pearson Goodness-of-Fit Test:
## ------------------------------------
## group statistic p-value(g-1)
## 1 20 9.267 0.9688
## 2 30 24.050 0.7265
## 3 40 31.733 0.7891
## 4 50 38.333 0.8641
##
##
## Elapsed time : 0.1433499
garch_coef <- as.data.frame(garch_fit@fit$matcoef)
garch_coef |>
kable(
digits = 4,
caption = "GARCH(1,1) Parameter Estimates"
)
| Estimate | Std. Error | t value | Pr(>|t|) | |
|---|---|---|---|---|
| mu | 0.0132 | 0.0232 | 0.5696 | 0.5689 |
| omega | 0.0732 | 0.0304 | 2.4037 | 0.0162 |
| alpha1 | 0.0986 | 0.0254 | 3.8833 | 0.0001 |
| beta1 | 0.8044 | 0.0577 | 13.9424 | 0.0000 |
coef_est <- coef(garch_fit)
alpha_hat <- unname(coef_est["alpha1"])
beta_hat <- unname(coef_est["beta1"])
omega_hat <- unname(coef_est["omega"])
persistence <- alpha_hat + beta_hat
persistence
## [1] 0.9030125
The fitted conditional standard deviation is extracted with
sigma(). Squaring it gives the estimated conditional
variance.
conditional_sd <- as.numeric(sigma(garch_fit))
conditional_variance <- conditional_sd^2
unconditional_sample_variance <- var(returns)
garch_variance_df <- tibble(
Time = 1:length(conditional_variance),
ConditionalVariance = conditional_variance,
UnconditionalVariance = unconditional_sample_variance
)
ggplot(garch_variance_df, aes(x = Time)) +
geom_line(aes(y = ConditionalVariance)) +
geom_hline(
yintercept = unconditional_sample_variance,
linetype = "dashed"
) +
labs(
title = "Conditional vs Unconditional Variance",
subtitle = "Solid line = GARCH conditional variance; dashed line = constant sample variance",
x = "Time",
y = "Variance"
)
The GARCH model allows variance to rise and fall through time, whereas the unconditional sample variance assumes one constant value for the entire series.
For a stationary GARCH(1,1), the estimated long-run variance is:
\[ \frac{\omega}{1-\alpha_1-\beta_1} \]
if (persistence < 1) {
estimated_long_run_variance <- omega_hat / (1 - persistence)
} else {
estimated_long_run_variance <- NA_real_
}
variance_comparison <- tibble(
Measure = c(
"Sample unconditional variance",
"Estimated GARCH long-run variance",
"Mean fitted conditional variance"
),
Variance = c(
unconditional_sample_variance,
estimated_long_run_variance,
mean(conditional_variance, na.rm = TRUE)
)
)
variance_comparison |>
kable(
digits = 4,
caption = "Variance Comparison"
)
| Measure | Variance |
|---|---|
| Sample unconditional variance | 0.7529 |
| Estimated GARCH long-run variance | 0.7546 |
| Mean fitted conditional variance | 0.7531 |
One way to motivate ARCH/GARCH modeling is to test whether squared returns are serially correlated. The Ljung-Box test below is applied to squared returns.
Box.test(
returns^2,
lag = 12,
type = "Ljung-Box"
)
##
## Box-Ljung test
##
## data: returns^2
## X-squared = 103.14, df = 12, p-value < 2.2e-16
fabletools documentation: aggregate_key(),
bottom_up(), top_down(),
middle_out(), and min_trace().rugarch: Univariate GARCH models in
R.