The goal of this project was to forecast cash withdrawals from four ATMs (part A) and residential power usage (part B). For each time series, I first visualized the data and checked for missing observations, outliers, and non-zero values, which is a requirement for Box-Cox transformation and multiplicative ETS models. Missing data and outliers were interpolated using an ARIMA model. Then, I characterized the time series (eg, trend, seasonality) using STL decomposition. I also assessed whether variance was non-constant to determine the need for Box-Cox transformation and performed KPSS unit root tests to determine whether differencing was needed to achieve stationarity.
For all time series except ATM3, which only had 3 observations, I split the data into training and test sets and compared the performance of ETS and ARIMA models with a baseline naive model using four out-of-sample accuracy metrics (RMSE, MAE, MPE, and MAPE). I used residual plots and Ljung-Box tests to determine whether the residuals were white noise, which indicates that the model fit the data well. For part B, finding an appropriate model required two iterations of this process.
Key operations and the best model for each dataset are summarized below:
ATM1: Box-Cox transformation, first-order seasonal differencing, \(ETS(A,A,A)\) model
ATM2: Box-Cox transformation, first-order seasonal differencing, \(ARIMA(0,0,0)(1,1,1)_7 + drift\) model
ATM3: \(Mean\) model
ATM4: Box-Cox transformation, \(ARIMA(2,0,0)(2,0,0)_7 + mean\) model
Residential power: First-order seasonal differencing, \(ARIMA(1,0,1)(2,1,0)_{12} + drift\) model
Finally, I refit each model to the entire time series (ie, training + test) to forecast future values and saved the forecasts as Excel files.
filename <- 'ATM624Data.xlsx'
url <- paste0(domain, path, filename)
atm_df <- read.xlsx(url, sheet = 1)
The raw dataset is a tibble (dataframe) with 3 variables and 1474 observations, which is composed of 365 observations for each of the 4 ATMs.
class(atm_df)
## [1] "data.frame"
glimpse(atm_df)
## Rows: 1,474
## Columns: 3
## $ DATE <dbl> 39934, 39934, 39935, 39935, 39936, 39936, 39937, 39937, 39938, 39…
## $ ATM <chr> "ATM1", "ATM2", "ATM1", "ATM2", "ATM1", "ATM2", "ATM1", "ATM2", "…
## $ Cash <dbl> 96, 107, 82, 89, 85, 90, 90, 55, 99, 79, 88, 19, 8, 2, 104, 103, …
table(atm_df$ATM)
##
## ATM1 ATM2 ATM3 ATM4
## 365 365 365 365
The date variable is numeric and requires conversion to a date-time data type.
# Origin date for Excel
# https://www.r-bloggers.com/2024/02/taming-excel-dates-in-r-from-numbers-to-meaningful-dates/
atm_df <- atm_df %>%
mutate(
Date = as.Date(DATE, origin = '1899-12-30'),
.after = DATE
) %>%
select(-c(DATE))
Finally, I subset the main dataframe into 4 dataframes by ATM number and converted each to a tsibble.
# Split main dataframe into list of dataframes by ATM number
atm_df_list <- split(atm_df, atm_df$ATM)
# Convert each dataframe to tsibble
atm_ts_list <- lapply(atm_df_list, function(df) as_tsibble(df, index = Date))
# Remove ATM variable (no longer needed) from each tsibble
atm_ts_list <- lapply(atm_ts_list, function(ts) select(ts, -ATM))
# Create individual objects for each element in list
# This creates 4 tsibbles (ATM1, ATM2, ATM3, ATM4)
invisible(list2env(atm_ts_list, envir = .GlobalEnv))
Glimpsing each ATM dataframe revealed that the Cash
values for ATM4 are floats, whereas the values for the other ATMs are
whole numbers. For consistency, and because ATMs do not dispense coins,
I rounded down all Cash values for ATM4 to the nearest
whole number. However, in the real world, it would be best to contact
the data provider to understand why the format of the Cash
values are different.
glimpse(ATM1)
## Rows: 365
## Columns: 2
## $ Date <date> 2009-05-01, 2009-05-02, 2009-05-03, 2009-05-04, 2009-05-05, 2009…
## $ Cash <dbl> 96, 82, 85, 90, 99, 88, 8, 104, 87, 93, 86, 111, 75, 6, 102, 73, …
glimpse(ATM2)
## Rows: 365
## Columns: 2
## $ Date <date> 2009-05-01, 2009-05-02, 2009-05-03, 2009-05-04, 2009-05-05, 2009…
## $ Cash <dbl> 107, 89, 90, 55, 79, 19, 2, 103, 107, 118, 75, 111, 25, 16, 137, …
glimpse(ATM3)
## Rows: 365
## Columns: 2
## $ Date <date> 2009-05-01, 2009-05-02, 2009-05-03, 2009-05-04, 2009-05-05, 2009…
## $ Cash <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
glimpse(ATM4)
## Rows: 365
## Columns: 2
## $ Date <date> 2009-05-01, 2009-05-02, 2009-05-03, 2009-05-04, 2009-05-05, 2009…
## $ Cash <dbl> 776.99342, 524.41796, 792.81136, 908.23846, 52.83210, 52.20845, 5…
# Change ATM4 cash values to whole numbers
ATM4 <- ATM4 %>%
mutate(
Cash = floor(Cash)
)
The facet plot below shows that ATM3 and ATM4 have different data characteristics than ATM1 and ATM2 that affect overall data analysis strategies.
Both ATM1 and ATM2 show time-dependent changes in cash withdrawal activity, which can be analyzed using either simple (eg, naive) or advanced forecasting methods (eg, ETS, ARIMA).
ATM3 only had activity during the last three days of the observation period (perhaps it is a recently installed ATM). Because of this, there is insufficient data to use exponential smoothing or ARIMA, as well as to split the data into training and test sets to evaluate model performance. So, only simple methods such as the mean or naive method can be used for forecasting.
filter(ATM3, Cash > 0)
## # A tsibble: 3 x 2 [1D]
## Date Cash
## <date> <dbl>
## 1 2010-04-28 96
## 2 2010-04-29 82
## 3 2010-04-30 85
ATM4 has at least one extreme outlier. In practice, it would be a good idea to double check the data source (ie, perhaps there was a typo). However, for this exercise, the outlier(s) can be removed and replaced with interpolated values using an ARIMA model before model development.
# Combined ATM data and coerce ATM as factor
ATM_combined <- bind_rows(atm_df_list)
ATM_combined$ATM <- factor(ATM_combined$ATM, levels = c('ATM1', 'ATM2', 'ATM3', 'ATM4'))
# Facet plot
ggplot(ATM_combined, aes(x = Date, y = Cash)) +
geom_line() +
facet_wrap(~ ATM, scales = 'free_y', ncol = 2, nrow = 2) +
labs(
y = 'Amount (USD, x100)',
title = 'ATM cash withdrawals, May 2009 to Apr 2010'
)
There were three missing data points, all from June 2009.
(ATM1_missing <- filter(ATM1, is.na(Cash)))
## # A tsibble: 3 x 2 [1D]
## Date Cash
## <date> <dbl>
## 1 2009-06-13 NA
## 2 2009-06-16 NA
## 3 2009-06-22 NA
There were no outliers that exceeded 3 interquartile ranges.
# Boundaries for outlier detection
lower_fence <- quantile(ATM1$Cash, 0.25, na.rm = TRUE) - 3 * IQR(ATM1$Cash, na.rm = TRUE)
upper_fence <- quantile(ATM1$Cash, 0.75, na.rm = TRUE) + 3 * IQR(ATM1$Cash, na.rm = TRUE)
ATM1_outliers <- ATM1 %>%
filter(Cash < lower_fence | Cash > upper_fence)
ATM1_outliers
## # A tsibble: 0 x 2 [?]
## # ℹ 2 variables: Date <date>, Cash <dbl>
All Cash values were positive, which satisfies the
requirement for Box-Cox transformation if it is needed.
filter(ATM1, Cash <= 0)
## # A tsibble: 0 x 2 [?]
## # ℹ 2 variables: Date <date>, Cash <dbl>
The plot below shows the ATM1 time series with missing observations and boundaries for outlier detection.
ATM1_plot <- ggtime::autoplot(ATM1, Cash) +
# Highlight outlier boundaries
geom_hline(yintercept = lower_fence, linetype = 'dashed', color = 'goldenrod') +
geom_hline(yintercept = upper_fence, linetype = 'dashed', color = 'goldenrod') +
labs(
y = 'Amount (USD, x100)',
title = 'ATM1 cash withdrawals, May 2009 to Apr 2010',
subtitle = 'Raw data has 3 missing observations and no outliers'
) +
theme_classic() +
theme(
axis.title = element_text(face = 'bold'),
plot.title = element_text(face = 'bold')
)
# Add vertical lines to highlight missing data points
for (i in 1:nrow(ATM1_missing)) {
ATM1_plot <- ATM1_plot +
geom_vline(xintercept = ATM1_missing[[i, 'Date']],
linetype = 'dashed', color = 'steelblue')
}
ATM1_plot
I replaced the missing data with estimates interpolated with an ARIMA model.
ATM1_interpolated <- ATM1 %>%
# Fit ARIMA model to data containing missing values
model(ARIMA(Cash)) %>%
# Estimate Cash for all time points
interpolate(ATM1)
STL decomposition of the interpolated time series showed that cash withdrawals from ATM1 have a downward trend that appears to increase with the level of the series as well as seasonality with a weekly period. Together, this indicates that the time series is not stationary, and that an additive or multiplicative ETS or ARIMA model could be suitable.
Of note, the remainder component shows an increase in noise after mid-February 2010. This suggests that the trend and seasonal components do not fully account for changes in the data after this point. This may also indicate a need for a multiplicative error component in an ETS model.
ATM1_decomp <- ATM1_interpolated %>%
model(STL(Cash ~ season(window = 'periodic'), robust = TRUE)) %>%
components()
ATM1_decomp %>%
ggtime::autoplot() +
# Unomment next line to view a more limited time period
# coord_cartesian(xlim = as.Date(c('2010-01-01', '2010-04-30'))) +
ggtitle('STL decomposition of interpolated ATM1 withdrawals')
The time series plot (top) shows non-constant variance and the cash withdrawal amounts are all positive, so Box-Cox transformation could be helpful.
The ACF plot (lower left) appears to be somewhat sinusoidal and has three significant spikes that decrease in magnitude as lag increases, which is consistent with seasonality and non-stationarity.
The partial ACF plot (lower right) has several significant spikes up to lag=14, which suggests that a higher-order seasonal ARIMA model may be needed.
ATM1_interpolated %>%
ggtime::gg_tsdisplay(Cash, plot_type = 'partial') +
# I plotted a limited time period to make it easier to see changes in variance
# To view the entire time series, comment out the next line
coord_cartesian(xlim = as.Date(c('2009-10-01', '2010-01-31'))) +
ggtitle('ATM1 withdrawals (interpolated)')
The Guerrero method indicated that a Box-Cox transformation with \(\lambda = 0.26\) would help stabilize the variance.
# Determine optimal value of lambda for Box-Cox transformation
ATM1_lambda <- ATM1_interpolated %>%
features(Cash, features = guerrero) %>%
pull(lambda_guerrero)
ATM1_lambda
## [1] 0.2622969
The KPSS unit root test indicated that first-order seasonal differencing of the transformed data is sufficient to achieve stationarity.
ATM1_interpolated %>%
features(box_cox(Cash, ATM1_lambda), unitroot_nsdiffs) %>%
pull(nsdiffs)
## [1] 1
The data characteristics described above suggest that additive or multiplicative ETS and seasonal ARIMA models can be fit to the transformed data. I compared the performance of these models with the performance of a seasonal naive model as baseline.
# Create training and test datasets to develop models and assess performance
# I split by year for convenience. By observations, the split ratio is ~65:35.
ATM1_train <- ATM1_interpolated %>%
filter(year(Date) == 2009)
ATM1_test <- ATM1_interpolated %>%
filter(year(Date) == 2010)
# ETS
ATM1_train_ets_fit <- ATM1_train %>%
# Automatically determine the best method from the specified options
# The transformation is specified inside the model so fable will automatically
# reverse it during forecasting
model(ETS = ETS(box_cox(Cash, ATM1_lambda) ~
error(method = c('A', 'M')) +
trend(method = c('A', 'Ad', 'M')) +
season(method = c('A', 'M'))))
# ARIMA
ATM1_train_arima_fit <- ATM1_train %>%
# specify first-order difference (D), automatic determination of P and Q
model(ARIMA = ARIMA(box_cox(Cash, ATM1_lambda) ~ PDQ(D = 1)))
# Baseline seasonal naive model
ATM1_train_snaive_fit = ATM1_train %>%
model(SNAIVE = SNAIVE(box_cox(Cash, ATM1_lambda)))
model_list <- list(ATM1_train_ets_fit,
ATM1_train_arima_fit,
ATM1_train_snaive_fit)
compare_forecast_accuracy(model_list, ATM1_test) %>%
arrange(RMSE)
## # A tibble: 3 × 5
## model RMSE MAE MPE MAPE
## <chr> <dbl> <dbl> <dbl> <dbl>
## 1 ETS 44.0 32.9 -211. 261.
## 2 ARIMA 44.1 30.1 -283. 314.
## 3 SNAIVE 86.3 73.1 -331. 368.
The \(ETS(A,A,A)\) model had the lowest RMSE, MPE, and MAPE, while the \(ARIMA(0,0,1)(0,1,1)_7\) model had the lowest MAE. Due to the different model classes, comparisons using AIC or BIC are not valid.1
Details of each model are shown below.
report(ATM1_train_arima_fit)
## Series: Cash
## Model: ARIMA(0,0,1)(0,1,1)[7]
## Transformation: box_cox(Cash, ATM1_lambda)
##
## Coefficients:
## ma1 sma1
## 0.1331 -0.9243
## s.e. 0.0652 0.0453
##
## sigma^2 estimated as 1.486: log likelihood=-390.57
## AIC=787.13 AICc=787.23 BIC=797.55
report(ATM1_train_ets_fit)
## Series: Cash
## Model: ETS(A,A,A)
## Transformation: box_cox(Cash, ATM1_lambda)
## Smoothing parameters:
## alpha = 0.05922859
## beta = 0.0001000784
## gamma = 0.0001362675
##
## Initial states:
## l[0] b[0] s[0] s[-1] s[-2] s[-3] s[-4]
## 7.933366 -0.002664309 -4.160148 0.00941567 1.206887 0.5304338 0.9595673
## s[-5] s[-6]
## 0.8724516 0.581393
##
## sigma^2: 1.4368
##
## AIC AICc BIC
## 1449.346 1450.691 1491.361
report(ATM1_train_snaive_fit)
## Series: Cash
## Model: SNAIVE
## Transformation: box_cox(Cash, ATM1_lambda)
##
## sigma^2: 2.6367
The residual plots of the \(ETS(A,A,A)\) model shows several negative spikes, but they do not appear to have any pattern. All of the ACF spikes are non-significant. The distribution of residuals is close to normal (although there are a few outliers), and the mean is near zero. In addition, the Ljung-Box test P-value is not significant, so the null hypothesis that the residuals are white noise is not rejected. Collectively, these results suggest that this model is a good fit to the data.
ggtime::gg_tsresiduals(ATM1_train_ets_fit)
# Ljung-Box test of residuals
# The time series is seasonal with a weekly period, so l = 2m = 2*7 = 14.
# Degrees of freedom = number of parameters in model, so dof = 3
ATM1_train_ets_fit %>%
residuals() %>%
features(.resid, ljung_box, lag = 14, dof = 3)
## # A tibble: 1 × 3
## .model lb_stat lb_pvalue
## <chr> <dbl> <dbl>
## 1 ETS 4.26 0.962
The residual plots of the \(ARIMA(0,0,1)(0,1,1)_7\) model shows several negative spikes, but they do not appear to have any pattern. All of the ACF spikes are non-significant. The distribution of residuals is close to normal (although there are a few outliers), and the mean is approximately zero. In addition, the Ljung-Box test P-value is not significant, so the null hypothesis that the residuals are white noise is not rejected. Collectively, these results suggest that this model is a good fit to the data.
ggtime::gg_tsresiduals(ATM1_train_arima_fit)
# Ljung-Box test of residuals
# The time series is seasonal with a weekly period, so l = 2m = 2*7 = 14.
# Degrees of freedom = number of parameters in model, so dof = 2
ATM1_train_arima_fit %>%
residuals() %>%
features(.resid, ljung_box, lag = 14, dof = 2)
## # A tibble: 1 × 3
## .model lb_stat lb_pvalue
## <chr> <dbl> <dbl>
## 1 ARIMA 6.54 0.887
Although both the ETS and ARIMA models were deemed appropriate, I selected the seasonal \(ETS(A,A,A)\) model as the final model because it had the lowest of three accuracy metrics (including RMSE, which is a key metric) whereas the ARIMA model only had one. In addition, the ETS model is simpler.
To forecast future values, I refit this model to the entire dataset (ie, train + test), and then used it to forecast cash withdrawals 1 month into the future.
# Refit the training model to the full dataset
ATM1_final_ets_fit <- ATM1_train_ets_fit %>%
fabletools::refit(ATM1_interpolated)
# Forecast future values
ATM1_forecast <- ATM1_final_ets_fit %>%
forecast(h = '1 month')
The forecast looks reasonable, although the upper range of the 95% prediction interval is a little high relative to the historical data. This may reflect the increased noise in the data near the end of the observation period, which was noted in the STL decomposition. More advanced techniques, such as machine learning, may be needed to improve the forecast.
# Plot forecast
ATM1_forecast %>%
ggtime::autoplot(ATM1_interpolated) +
labs(
y = 'Amount (USD, x100)',
title = 'ATM1 cash withdrawals, May 2009 to Apr 2010 + Forecast',
subtitle = 'ETS(A,A,A) model'
) +
theme_classic() +
theme(
axis.title = element_text(face = 'bold'),
plot.title = element_text(face = 'bold')
)
# Note this block only creates a dataframe with forecasts;
# these will be consolidated and exported at the end of the project
ATM1_forecast <- ATM1_forecast %>%
as.data.frame() %>%
select(Date, Cash_forecast = `.mean`) %>%
# Round forecasts since ATMs don't dispense coins
mutate(
Cash_forecast = floor(Cash_forecast)
)
# Workaround because Excel doesn't read date field correctly
ATM1_forecast$Date <- as.character(ATM1_forecast$Date)
There were two missing data points, both from June 2009.
(ATM2_missing <- filter(ATM2, is.na(Cash)))
## # A tsibble: 2 x 2 [1D]
## Date Cash
## <date> <dbl>
## 1 2009-06-18 NA
## 2 2009-06-24 NA
There were no outliers that exceeded 3 interquartile ranges.
# Boundaries for outlier detection
lower_fence <- quantile(ATM2$Cash, 0.25, na.rm = TRUE) - 3 * IQR(ATM2$Cash, na.rm = TRUE)
upper_fence <- quantile(ATM2$Cash, 0.75, na.rm = TRUE) + 3 * IQR(ATM2$Cash, na.rm = TRUE)
ATM2_outliers <- ATM2 %>%
filter(Cash < lower_fence | Cash > upper_fence)
ATM2_outliers
## # A tsibble: 0 x 2 [?]
## # ℹ 2 variables: Date <date>, Cash <dbl>
Two Cash values were zero. This precludes Box-Cox
transformation as well as multiplicative ETS methods; however, a simple
workaround is to shift the values by adding a constant (this will be
done during model development).
filter(ATM2, Cash <= 0)
## # A tsibble: 2 x 2 [1D]
## Date Cash
## <date> <dbl>
## 1 2009-10-25 0
## 2 2010-03-30 0
The plot below shows the ATM2 time series with missing observations and boundaries for outlier detection.
ATM2_plot <- ggtime::autoplot(ATM2, Cash) +
# Highlight outlier boundaries
geom_hline(yintercept = lower_fence, linetype = 'dashed', color = 'goldenrod') +
geom_hline(yintercept = upper_fence, linetype = 'dashed', color = 'goldenrod') +
labs(
y = 'Amount (USD, x100)',
title = 'ATM2 cash withdrawals, May 2009 to Apr 2010',
subtitle = 'Raw data has 2 missing observations and no outliers'
) +
theme_classic() +
theme(
axis.title = element_text(face = 'bold'),
plot.title = element_text(face = 'bold')
)
# Add vertical lines to highlight missing data points
for (i in 1:nrow(ATM2_missing)) {
ATM2_plot <- ATM2_plot +
geom_vline(xintercept = ATM2_missing[[i, 'Date']],
linetype = 'dashed', color = 'steelblue')
}
ATM2_plot
I replaced the missing data with estimates interpolated with an ARIMA model.
ATM2_interpolated <- ATM2 %>%
# Fit ARIMA model to data containing missing values
model(ARIMA(Cash)) %>%
# Estimate Cash for all time points
interpolate(ATM2)
STL decomposition of the interpolated time series showed that cash withdrawals from ATM2 do not appear to have a trend, but does have seasonality with a weekly period. Together, this indicates that the time series is not stationary, and that an ETS or ARIMA model could be suitable.
Of note, the remainder component shows some residual structure (ie, not white noise) after mid-February, which suggests that the trend and seasonal components do not fully account for changes in the data after this time point.
ATM2_decomp <- ATM2_interpolated %>%
model(STL(Cash ~ season(window = 'periodic'), robust = TRUE)) %>%
components()
ATM2_decomp %>%
ggtime::autoplot() +
# Uncomment next line to view a more limited period
# coord_cartesian(xlim = as.Date(c('2010-01-01', '2010-04-30'))) +
ggtitle('STL decomposition of interpolated ATM2 withdrawals')
The time series plot (top) shows non-constant variance and the (shifted) cash withdrawal amounts are all positive, so Box-Cox transformation could be helpful.
The ACF plot (lower left) has several significant spikes that slowly decrease in magnitude as lag increases, which is consistent with seasonality and non-stationarity.
The partial ACF plot (lower right) has several significant spikes up to lag=14, which suggests that a higher-order seasonal ARIMA model may be needed.
ATM2_interpolated %>%
ggtime::gg_tsdisplay(Cash, plot_type = 'partial') +
# I plotted a limited time period to make it easier to see changes in variance
# To view the entire time series, comment out the next line
coord_cartesian(xlim = as.Date(c('2009-10-01', '2010-01-31'))) +
ggtitle('ATM2 withdrawals (interpolated)')
The Guerrero method indicated that a Box-Cox transformation with \(\lambda = 0.62\) would help stabilize the variance.
# Determine optimal value of lambda for Box-Cox transformation
ATM2_lambda <- ATM2_interpolated %>%
features(Cash, features = guerrero) %>%
pull(lambda_guerrero)
ATM2_lambda
## [1] 0.6746523
The KPSS unit root test indicated that first-order seasonal differencing of the transformed data is sufficient to achieve stationarity.
ATM2_interpolated %>%
features(box_cox(Cash, ATM2_lambda), unitroot_nsdiffs) %>%
pull(nsdiffs)
## [1] 1
The data characteristics described above suggest that ETS and seasonal ARIMA models can be fit to the transformed data. I compared the performance of these models with the performance of a seasonal naive model as baseline.
# Create training and test datasets to develop models and assess performance
ATM2_train <- ATM2_interpolated %>%
filter(year(Date) == 2009)
ATM2_test <- ATM2_interpolated %>%
filter(year(Date) == 2010)
# ETS
ATM2_train_ets_fit <- ATM2_train %>%
# Automatically determine the best method from the specified options
# A constant is added to the data to ensure that Box-Cox transformation is
# performed on positive values
model(ETS = ETS(box_cox((Cash + 1), ATM2_lambda) ~
error(method = c('A', 'M')) +
trend(method = c('A', 'Ad', 'N')) +
season('A')))
# ARIMA
ATM2_train_arima_fit <- ATM2_train %>%
# specify first-order difference (D), automatic determination of P and Q
model(ARIMA = ARIMA(box_cox((Cash + 1), ATM2_lambda) ~ PDQ(D = 1)))
# Baseline seasonal naive model
ATM2_train_snaive_fit = ATM2_train %>%
model(SNAIVE = SNAIVE(box_cox((Cash + 1), ATM2_lambda)))
model_list <- list(ATM2_train_ets_fit,
ATM2_train_arima_fit,
ATM2_train_snaive_fit)
compare_forecast_accuracy(model_list, ATM2_test) %>%
arrange(RMSE)
## # A tibble: 3 × 5
## model RMSE MAE MPE MAPE
## <chr> <dbl> <dbl> <dbl> <dbl>
## 1 ARIMA 48.9 39.2 -Inf Inf
## 2 ETS 49.2 39.3 -Inf Inf
## 3 SNAIVE 51.6 41.1 -Inf Inf
MPE and MAPE are not usable metrics, most likely because the original data had two zero values. The model with the lowest RMSE and MAE is the \(ARIMA(0,0,0)(1,1,1)_7 + drift\) model. Due to the different model classes, comparisons using AIC or BIC are not valid.
Details of each model are shown below.
report(ATM2_train_arima_fit)
## Series: Cash
## Model: ARIMA(0,0,0)(1,1,1)[7] w/ drift
## Transformation: box_cox((Cash + 1), ATM2_lambda)
##
## Coefficients:
## sar1 sma1 constant
## -0.0731 -0.8956 -0.1824
## s.e. 0.0729 0.0380 0.0551
##
## sigma^2 estimated as 34.42: log likelihood=-763.42
## AIC=1534.84 AICc=1535.01 BIC=1548.73
report(ATM2_train_ets_fit)
## Series: Cash
## Model: ETS(A,A,A)
## Transformation: box_cox((Cash + 1), ATM2_lambda)
## Smoothing parameters:
## alpha = 0.0001000339
## beta = 0.0001000003
## gamma = 0.0001000695
##
## Initial states:
## l[0] b[0] s[0] s[-1] s[-2] s[-3] s[-4] s[-5]
## 24.6899 -0.02201866 -17.38408 -8.869069 7.778726 1.990136 1.43907 4.962735
## s[-6]
## 10.08249
##
## sigma^2: 33.847
##
## AIC AICc BIC
## 2223.407 2224.752 2265.422
report(ATM2_train_snaive_fit)
## Series: Cash
## Model: SNAIVE
## Transformation: box_cox((Cash + 1), ATM2_lambda)
##
## sigma^2: 65.0693
The residual plots of the \(ARIMA(0,0,0)(1,1,1)_7 + drift\) model shows several negative spikes, but they do not appear to have a clear pattern. All of the ACF spikes are non-significant. The distribution of residuals is close to normal (although there are a few outliers), and the mean is approximately zero. In addition, the Ljung-Box test P-value is not significant, so the null hypothesis that the residuals are white noise is not rejected. Collectively, these results suggest that this model is a good fit to the data.
ggtime::gg_tsresiduals(ATM2_train_arima_fit)
# Ljung-Box test of residuals
# The time series is seasonal with a weekly period, so l = 2m = 2*7 = 14.
# Degrees of freedom = number of parameters in model, so dof = 2
ATM2_train_arima_fit %>%
residuals() %>%
features(.resid, ljung_box, lag = 14, dof = 2)
## # A tibble: 1 × 3
## .model lb_stat lb_pvalue
## <chr> <dbl> <dbl>
## 1 ARIMA 8.39 0.754
On the basis of the above results, I selected the seasonal \(ARIMA(0,0,0)(1,1,1)_7 + drift\) model as the final model. To forecast future values, I refit this model to the entire dataset (ie, train + test), and then used it to forecast cash withdrawals 1 month into the future.
# Refit the training model to the full dataset
ATM2_final_arima_fit <- ATM2_train_arima_fit %>%
fabletools::refit(ATM2_interpolated)
# Forecast future values
ATM2_forecast <- ATM2_final_arima_fit %>%
forecast(h = '1 month')
The forecast looks reasonable.
# Plot forecast
ATM2_forecast %>%
ggtime::autoplot(ATM2_interpolated) +
labs(
y = 'Amount (USD, x100)',
title = 'ATM2 cash withdrawals, May 2009 to Apr 2010 + Forecast',
subtitle = latex2exp::TeX('ARIMA(0,0,0)(1,1,1)$_{7}$ model')
) +
theme_classic() +
theme(
axis.title = element_text(face = 'bold'),
plot.title = element_text(face = 'bold')
)
ATM2_forecast <- ATM2_forecast %>%
as.data.frame() %>%
select(Date, Cash_forecast = `.mean`) %>%
# Round forecasts since ATMs don't dispense coins
mutate(
Cash_forecast = floor(Cash_forecast)
)
# Workaround because Excel doesn't read date field correctly
ATM2_forecast$Date <- as.character(ATM2_forecast$Date)
There is no missing data point in this time series.
filter(ATM3, is.na(Cash))
## # A tsibble: 0 x 2 [?]
## # ℹ 2 variables: Date <date>, Cash <dbl>
As noted earlier and shown below, this ATM only had 3 days of cash withdrawal activity. Given that the withdrawal amounts are similar, forecasting with a simple mean is sensible.
filter(ATM3, Cash > 0)
## # A tsibble: 3 x 2 [1D]
## Date Cash
## <date> <dbl>
## 1 2010-04-28 96
## 2 2010-04-29 82
## 3 2010-04-30 85
# Mean model
ATM3_mean_fit <- ATM3 %>%
filter(Cash > 0) %>%
model(MEAN(Cash))
# Forecast 1 month into future
ATM3_mean_forecast <- ATM3_mean_fit %>%
forecast(h = '1 month')
# Visualize forecast
ATM3_mean_forecast %>%
ggtime::autoplot(ATM3) +
# Only plot subset of time period for visibility
coord_cartesian(xlim = as.Date(c('2010-04-20', '2010-05-30'))) +
labs(
y = 'Amount (USD, x100)',
title = 'ATM3 cash withdrawals, Apr 28-30 + Forecast',
subtitle = 'MEAN model'
) +
theme_classic() +
theme(
plot.title = element_text(face = 'bold'),
axis.title = element_text(face = 'bold')
)
Alternatively, a naive model, which uses the value of the last observation, could be used. This approach is also reasonable because the amount of the last withdrawal is close to the mean of the three withdrawals. However, the prediction interval is much wider than the forecast using the mean model.
# last withdrawal amount
slice_tail(ATM3, n = 1)
## # A tsibble: 1 x 2 [1D]
## Date Cash
## <date> <dbl>
## 1 2010-04-30 85
# mean withdrawal amount
ATM3 %>%
as.data.frame() %>%
filter(Cash > 0) %>%
summarise(
mean = round(mean(Cash), 1)
)
## mean
## 1 87.7
# Naive model
ATM3_naive_fit <- ATM3 %>%
filter(Cash > 0) %>%
model(NAIVE(Cash))
# Forecast 1 month into future
ATM3_naive_forecast <- ATM3_naive_fit %>%
forecast(h = '1 month')
# Visualize forecast
ATM3_naive_forecast %>%
ggtime::autoplot(ATM3) +
# Only plot subset of data for visibility
coord_cartesian(xlim = as.Date(c('2010-04-20', '2010-05-30'))) +
labs(
y = 'Amount (USD, x100)',
title = 'ATM3 cash withdrawals, Apr 28-30 + Forecast',
subtitle = 'NAIVE model'
) +
theme_classic() +
theme(
plot.title = element_text(face = 'bold'),
axis.title = element_text(face = 'bold')
)
With only three observations, neither model would likely be very reliable for long-term forecasts. However, for short-term forecasts, I would use the mean forecast.
ATM3_forecast <- ATM3_mean_forecast %>%
as.data.frame() %>%
select(Date, Cash_forecast = `.mean`) %>%
# Round forecasts since ATMs don't dispense coins
mutate(
Cash_forecast = floor(Cash_forecast)
)
# Workaround because Excel doesn't read date field correctly
ATM3_forecast$Date <- as.character(ATM3_forecast$Date)
There is no missing data in this time series.
(ATM4_missing <- filter(ATM4, is.na(Cash)))
## # A tsibble: 0 x 2 [?]
## # ℹ 2 variables: Date <date>, Cash <dbl>
Cash withdrawals from ATM4 had one outlier that exceeded 3 interquartile ranges.
# Boundaries for outlier detection
lower_fence <- quantile(ATM4$Cash, 0.25, na.rm = TRUE) - 3 * IQR(ATM4$Cash, na.rm = TRUE)
upper_fence <- quantile(ATM4$Cash, 0.75, na.rm = TRUE) + 3 * IQR(ATM4$Cash, na.rm = TRUE)
ATM4_outliers <- ATM4 %>%
filter(Cash < lower_fence | Cash > upper_fence)
ATM4_outliers
## # A tsibble: 1 x 2 [1D]
## Date Cash
## <date> <dbl>
## 1 2010-02-09 10919
All Cash values are positive, which satisfies the
requirement for Box-Cox transformation if it is needed.
filter(ATM4, Cash <= 0)
## # A tsibble: 0 x 2 [?]
## # ℹ 2 variables: Date <date>, Cash <dbl>
ATM4_plot <- ggtime::autoplot(ATM4, Cash) +
# Highlight outlier boundaries
geom_hline(yintercept = lower_fence, linetype = 'dashed', color = 'goldenrod') +
geom_hline(yintercept = upper_fence, linetype = 'dashed', color = 'goldenrod') +
labs(
y = 'Amount (USD, x100)',
title = 'ATM4 cash withdrawals, May 2009 to Apr 2010',
subtitle = 'Raw data has no missing observations and 1 outlier'
) +
theme_classic() +
theme(
axis.title = element_text(face = 'bold'),
plot.title = element_text(face = 'bold')
)
ATM4_plot
I replaced the outlier with an estimate interpolated with an ARIMA model.
ATM4_fill <- ATM4 %>%
# Remove outlier
anti_join(ATM4_outliers) %>%
# Replace with NA
fill_gaps()
## Joining with `by = join_by(Date, Cash)`
ATM4_interpolated <- ATM4_fill %>%
# Fit ARIMA model to data containing missing values
model(ARIMA(Cash)) %>%
# Estimate Cash for all time points
interpolate(ATM4_fill)
STL decomposition of the interpolated time series showed that cash withdrawals from ATM4 do not have a trend, but there is seasonality with a weekly period. Together, this indicates that the time series is not stationary, and that an additive ETS or ARIMA model could be suitable. The remainder component appears to be white noise.
ATM4_decomp <- ATM4_interpolated %>%
model(STL(Cash ~ season(window = 'periodic'), robust = TRUE)) %>%
components()
ATM4_decomp %>%
ggtime::autoplot() +
# Uncomment next line to view a more limited period
# coord_cartesian(xlim = as.Date(c('2009-07-01', '2009-07-31'))) +
ggtitle('STL decomposition of interpolated ATM4 withdrawals')
The time series plot (top) shows non-constant variance and the cash withdrawal amounts are all positive, so Box-Cox transformation could be helpful.
The ACF plot (lower left) appears to be somewhat sinusoidal and has three significant spikes that decrease in magnitude as lag increases, which is consistent with seasonality and non-stationarity.
The partial ACF plot (lower right) has several significant spikes up to lag=19, which suggests that a higher-order seasonal ARIMA model may be needed.
ATM4_interpolated %>%
ggtime::gg_tsdisplay(Cash, plot_type = 'partial') +
# I plotted a limited time period to make it easier to see changes in variance
# To view the entire time series, comment out the next line
coord_cartesian(xlim = as.Date(c('2009-10-01', '2010-01-31'))) +
ggtitle('ATM4 withdrawals (interpolated)')
The Guerrero method indicated that a Box-Cox transformation with \(\lambda = 0.45\) would help stabilize the variance, which I confirmed visually.
# Determine optimal value of lambda for Box-Cox transformation
ATM4_lambda <- ATM4_interpolated %>%
features(Cash, features = guerrero) %>%
pull(lambda_guerrero)
ATM4_lambda
## [1] 0.4504502
The KPSS unit root test indicated that no seasonal differencing of the transformed data is needed to achieve stationarity. I found this result surprising given the clear seasonality in the STL decomposition. Because of this discrepancy, I tested seasonal ARIMA models with or without differencing.
ATM4_interpolated %>%
features(box_cox(Cash, ATM4_lambda), unitroot_nsdiffs) %>%
pull(nsdiffs)
## [1] 0
The data characteristics described above suggest that ETS and seasonal ARIMA models can be fit to the transformed data. I compared the performance of these models with the performance of a seasonal naive model as baseline.
# Create training and test datasets to develop models and assess performance
ATM4_train <- ATM4_interpolated %>%
filter(year(Date) == 2009)
ATM4_test <- ATM4_interpolated %>%
filter(year(Date) == 2010)
# ETS
ATM4_train_ets_fit <- ATM4_train %>%
# Automatically determine the best method from the specified options
model(ETS = ETS(box_cox(Cash, ATM4_lambda) ~
error(method = c('A', 'M')) +
trend(method = c('A', 'Ad', 'N')) +
season('A')))
# ARIMA
ATM4_train_arima_fit <- ATM4_train %>%
# Due to unclear need for differencing, I let D = 0 or 1;
# PDQ parameters are determined automatically
model(ARIMA = ARIMA(box_cox(Cash, ATM4_lambda) ~ PDQ(D = 0:1)))
# Baseline seasonal naive model
ATM4_train_snaive_fit = ATM4_train %>%
model(SNAIVE = SNAIVE(box_cox(Cash, ATM4_lambda)))
model_list <- list(ATM4_train_ets_fit,
ATM4_train_arima_fit,
ATM4_train_snaive_fit)
compare_forecast_accuracy(model_list, ATM4_test) %>%
arrange(RMSE)
## # A tibble: 3 × 5
## model RMSE MAE MPE MAPE
## <chr> <dbl> <dbl> <dbl> <dbl>
## 1 ARIMA 328. 269. -613. 643.
## 2 ETS 347. 284. -617. 652.
## 3 SNAIVE 1537. 1277. -2902. 2907.
The \(ARIMA(2,0,0)(2,0,0)_7 + mean\) model has the lowest RMSE, MAE, MPE, and MAPE. Due to the different model classes, comparisons using AIC or BIC are not valid.
Details of each model are shown below.
report(ATM4_train_arima_fit)
## Series: Cash
## Model: ARIMA(2,0,0)(2,0,0)[7] w/ mean
## Transformation: box_cox(Cash, ATM4_lambda)
##
## Coefficients:
## ar1 ar2 sar1 sar2 constant
## 0.0886 0.0335 0.2020 0.177 15.6554
## s.e. 0.0650 0.0642 0.0638 0.065 0.8530
##
## sigma^2 estimated as 191.5: log likelihood=-989.3
## AIC=1990.61 AICc=1990.96 BIC=2011.61
report(ATM4_train_ets_fit)
## Series: Cash
## Model: ETS(A,N,A)
## Transformation: box_cox(Cash, ATM4_lambda)
## Smoothing parameters:
## alpha = 0.0001000831
## gamma = 0.0001026479
##
## Initial states:
## l[0] s[0] s[-1] s[-2] s[-3] s[-4] s[-5] s[-6]
## 28.79997 -17.78852 -0.3709192 1.746823 3.157084 4.371296 3.884638 4.999602
##
## sigma^2: 156.3077
##
## AIC AICc BIC
## 2596.336 2597.276 2631.349
report(ATM4_train_snaive_fit)
## Series: Cash
## Model: SNAIVE
## Transformation: box_cox(Cash, ATM4_lambda)
##
## sigma^2: 313.0914
The residual plot of the \(ARIMA(2,0,0)(2,0,0)_7 + mean\) model does not appear to have a pattern. All of the ACF spikes are non-significant. The distribution of residuals is not quite normal and has a mean greater than zero. However, the Ljung-Box test P-value is not significant, so the null hypothesis that the residuals are white noise is not rejected. Collectively, these results suggest that this model is a good fit to the data.
ggtime::gg_tsresiduals(ATM4_train_arima_fit)
# Ljung-Box test of residuals
# The time series is seasonal with a weekly period, so l = 2m = 2*7 = 14.
# Degrees of freedom = number of parameters in model, so dof = 2
ATM4_train_arima_fit %>%
residuals() %>%
features(.resid, ljung_box, lag = 14, dof = 2)
## # A tibble: 1 × 3
## .model lb_stat lb_pvalue
## <chr> <dbl> <dbl>
## 1 ARIMA 12.1 0.436
On the basis of the above results, I selected the seasonal \(ARIMA(2,0,0)(2,0,0)_7 + mean\) model as the final model. To forecast future values, I refit this model to the entire dataset (ie, train + test), and then used it to forecast cash withdrawals 1 month into the future.
# Refit the training model to the full dataset
ATM4_final_arima_fit <- ATM4_train_arima_fit %>%
fabletools::refit(ATM4_interpolated)
# Forecast future values
ATM4_forecast <- ATM4_final_arima_fit %>%
forecast(h = '1 month')
The forecast looks reasonable.
# Plot forecast
ATM4_forecast %>%
ggtime::autoplot(ATM4_interpolated) +
labs(
y = 'Amount (USD, x100)',
title = 'ATM4 cash withdrawals, May 2009 to Apr 2010 + Forecast',
subtitle = latex2exp::TeX('ARIMA(2,0,0)(2,0,0)$_{7}$ + mean model')
) +
theme_classic() +
theme(
axis.title = element_text(face = 'bold'),
plot.title = element_text(face = 'bold')
)
ATM4_forecast <- ATM4_forecast %>%
as.data.frame() %>%
select(Date, Cash_forecast = `.mean`) %>%
# Round forecasts since ATMs don't dispense coins
mutate(
Cash_forecast = floor(Cash_forecast)
)
# Workaround because Excel doesn't read date field correctly
ATM4_forecast$Date <- as.character(ATM4_forecast$Date)
filename <- 'ResidentialCustomerForecastLoad-624.xlsx'
url <- paste0(domain, path, filename)
power_df <- read.xlsx(url, sheet = 1)
The raw dataset is a tibble (dataframe) with 3 variables and 192 observations. For time series analysis, the dataframe needs to be converted to a tsibble, which in turn, requires the time variable to be converted a year-month data type and defined as the index variable.
class(power_df)
## [1] "data.frame"
glimpse(power_df)
## Rows: 192
## Columns: 3
## $ CaseSequence <dbl> 733, 734, 735, 736, 737, 738, 739, 740, 741, 742, 743, 74…
## $ `YYYY-MMM` <chr> "1998-Jan", "1998-Feb", "1998-Mar", "1998-Apr", "1998-May…
## $ KWH <dbl> 6862583, 5838198, 5420658, 5010364, 4665377, 6467147, 891…
power_ts <- power_df %>%
# Rename date column to be more meaningful
rename('Date' = 'YYYY-MMM') %>%
# Coerce date as year-month data type
mutate(
Date = yearmonth(Date)
) %>%
# convert to tsibble
as_tsibble(index = Date)
There is one missing observation for September 2008.
(power_missing <- filter(power_ts, is.na(KWH)))
## # A tsibble: 1 x 3 [1M]
## CaseSequence Date KWH
## <dbl> <mth> <dbl>
## 1 861 2008 Sep NA
There were no outliers that exceeded 3 interquartile ranges; however, one exceeded 1.5 IQRs. Based on the time series plot below, which shows that this data point is clearly lower than other data points, I decided to use the less stringent outlier criteria and conclude that there is one outlier.
# Boundaries for outlier detection
lower_fence <- quantile(power_ts$KWH, 0.25, na.rm = TRUE) -
3 * IQR(power_ts$KWH, na.rm = TRUE)
upper_fence <- quantile(power_ts$KWH, 0.75, na.rm = TRUE) +
3 * IQR(power_ts$KWH, na.rm = TRUE)
power_outliers <- power_ts %>%
filter(KWH < lower_fence | KWH > upper_fence)
power_outliers
## # A tsibble: 0 x 3 [?]
## # ℹ 3 variables: CaseSequence <dbl>, Date <mth>, KWH <dbl>
# Less stringent boundaries for outlier detection
lower_fence <- quantile(power_ts$KWH, 0.25, na.rm = TRUE) -
1.5 * IQR(power_ts$KWH, na.rm = TRUE)
upper_fence <- quantile(power_ts$KWH, 0.75, na.rm = TRUE) +
1.5 * IQR(power_ts$KWH, na.rm = TRUE)
power_outliers <- power_ts %>%
filter(KWH < lower_fence | KWH > upper_fence)
power_outliers
## # A tsibble: 1 x 3 [1M]
## CaseSequence Date KWH
## <dbl> <mth> <dbl>
## 1 883 2010 Jul 770523
All KWH values were positive, which satisfies
requirement for Box-Cox transformation if it is needed.
filter(power_ts, KWH <= 0)
## # A tsibble: 0 x 3 [?]
## # ℹ 3 variables: CaseSequence <dbl>, Date <mth>, KWH <dbl>
The overall time series plot shows the presence of seasonality (monthly period), one missing observation, and one outlier.
power_plot <- ggtime::autoplot(power_ts, KWH) +
# Highlight outlier boundaries
geom_hline(yintercept = lower_fence, linetype = 'dashed', color = 'goldenrod') +
geom_hline(yintercept = upper_fence, linetype = 'dashed', color = 'goldenrod') +
labs(
y = 'Power usage (kWH)',
title = 'Residential power usage, 1998-2013',
subtitle = 'Raw data has one missing observation and one outlier'
) +
theme_classic() +
theme(
axis.title = element_text(face = 'bold'),
plot.title = element_text(face = 'bold'),
)
# Add vertical lines to highlight missing data points
for (i in 1:nrow(power_missing)) {
power_plot <- power_plot +
geom_vline(xintercept = power_missing[[i, 'Date']],
linetype = 'dashed', color = 'steelblue')
}
power_plot
I replaced the missing observation and outlier with estimates interpolated with an ARIMA model.
power_fill <- power_ts %>%
# Remove outlier
anti_join(power_outliers) %>%
# Replace with NA
fill_gaps()
## Joining with `by = join_by(CaseSequence, Date, KWH)`
power_interpolated <- power_fill %>%
# Fit ARIMA model to data containing missing values
model(ARIMA(KWH)) %>%
# Estimate KWH for all time points
interpolate(power_fill)
STL decomposition of the interpolated time series showed that power usage has a upward trend and seasonality, which indicate that the time series is not stationary. A seasonal plot further revealed that power usage peaked during winter and summer (ie, 6-month period). The remainder component appears to be white noise.
Of note, the trend component is relatively flat before 2009 and then subsequently trends upward. This suggests that either an additive or multiplicative ETS model could be suitable. In addition, the trend flattens out at the end of the time series, which may indicate a need for dampening in an ETS model.
power_interpolated %>%
# Uncomment next line to view seasonality for a more limited period
# filter(year(Date) >= 2005 & year(Date) <= 2010) %>%
model(STL(KWH ~ season(window = 'periodic'), robust = TRUE)) %>%
components() %>%
ggtime::autoplot() +
ggtitle('STL decomposition of interpolated power usage data')
ggtime::gg_season(power_interpolated, KWH) +
labs(
x = 'Month',
y = 'Power usage (kWH)',
title = 'Seasonality of residential power usage',
subtitle = 'Interpolated data'
) +
theme_classic() +
theme(
axis.title = element_text(face = 'bold'),
plot.title = element_text(face = 'bold')
)
The time series plot (top) shows some evidence of non-constant variance and the power usage data are all positive, so Box-Cox transformation could be helpful.
The ACF plot (lower left) has a sinusoidal pattern of significant spikes, which is consistent with seasonality and non-stationarity.
The partial ACF plot (lower right) shows an exponential decay of spikes after lag=12,, which suggests that a higher-order seasonal ARIMA model may be needed.
power_interpolated %>%
ggtime::gg_tsdisplay(KWH, plot_type = 'partial') +
ggtitle('Residential power usage')
The Guerrero method indicated that a Box-Cox transformation with \(\lambda = -0.21\) would help stabilize the variance, which I confirmed visually.
# Determine optimal value of lambda for Box-Cox transformation
power_lambda <- power_interpolated %>%
features(KWH, features = guerrero) %>%
pull(lambda_guerrero)
power_lambda
## [1] -0.2057366
The KPSS unit root test indicated that first-order seasonal differencing of the transformed data is sufficient to achieve stationarity.
power_interpolated %>%
features(box_cox(KWH, power_lambda), unitroot_nsdiffs) %>%
pull(nsdiffs)
## [1] 1
The data characteristics described above suggest that ETS and seasonal ARIMA models can be fit to the transformed data. I compared the performance of these models with the performance of a seasonal naive model as baseline.
# Create training and test datasets to develop models and assess performance
power_train <- power_interpolated %>%
filter(year(Date) < 2011)
power_test <- power_interpolated %>%
filter(year(Date) == 2010)
# ETS
power_train_ets_fit <- power_train %>%
# Automatically determine the best method from the specified options
model(ETS = ETS(box_cox(KWH, power_lambda) ~
error(method = c('A', 'M')) +
trend(method = c('A', 'Ad', 'M')) +
season(method = c('A', 'M'))))
# ARIMA
power_train_arima_fit <- power_train %>%
model(ARIMA = ARIMA(box_cox(KWH, power_lambda) ~ PDQ(D = 1)))
# Baseline seasonal naive model
power_train_snaive_fit = power_train %>%
model(SNAIVE = SNAIVE(box_cox(KWH, power_lambda)))
# This block takes a few seconds to run
model_list <- list(power_train_ets_fit,
power_train_arima_fit,
power_train_snaive_fit)
compare_forecast_accuracy(model_list, power_test) %>%
arrange(RMSE)
## # A tibble: 3 × 5
## model RMSE MAE MPE MAPE
## <chr> <dbl> <dbl> <dbl> <dbl>
## 1 SNAIVE 58042. 55941. -0.797 0.797
## 2 ARIMA 487284. 381902. 1.35 5.30
## 3 ETS 652414. 457557. 3.45 6.01
The seasonal naive model had the lowest RMSE, MAE, MPE, and MAPE. Due to the different model classes, comparisons using AIC or BIC are not valid.
Details of each model are shown below.
report(power_train_arima_fit)
## Series: KWH
## Model: ARIMA(0,0,1)(2,1,0)[12] w/ drift
## Transformation: box_cox(KWH, power_lambda)
##
## Coefficients:
## ma1 sar1 sar2 constant
## 0.1699 -0.7387 -0.3878 8e-04
## s.e. 0.0962 0.0842 0.0840 4e-04
##
## sigma^2 estimated as 1.398e-05: log likelihood=608.86
## AIC=-1207.73 AICc=-1207.29 BIC=-1192.88
report(power_train_ets_fit)
## Series: KWH
## Model: ETS(M,M,M)
## Transformation: box_cox(KWH, power_lambda)
## Smoothing parameters:
## alpha = 0.0001461708
## beta = 0.0001251217
## gamma = 0.0003705203
##
## Initial states:
## l[0] b[0] s[0] s[-1] s[-2] s[-3] s[-4] s[-5]
## 4.663809 1.000006 0.9994214 0.9973231 0.9990265 1.00168 1.002111 1.001629
## s[-6] s[-7] s[-8] s[-9] s[-10] s[-11]
## 0.9998 0.9981181 0.9986163 0.9995482 1.000767 1.001961
##
## sigma^2: 0
##
## AIC AICc BIC
## -960.0912 -955.6564 -908.2436
report(power_train_snaive_fit)
## Series: KWH
## Model: SNAIVE
## Transformation: box_cox(KWH, power_lambda)
##
## sigma^2: 0
As explained below, none of the three models had residuals consistent with white noise, which indicated that they are not a good fit to the data.
The residual plot of the seasonal naive model does not appear to have a pattern, and the distribution of residuals appears to be close to normal with a mean near zero. However, the ACF plot has a sinusoidal pattern and there is a significant spike at lag=12, which is consistent with the presence of residual seasonality. In addition, the Ljung-Box test P-value was significant, so the null hypothesis that the residuals are white noise was rejected. Collectively, these results suggest that this model is not a good fit to the data.
ggtime::gg_tsresiduals(power_train_snaive_fit)
# Ljung-Box test of residuals
# The time series is seasonal with a semi-annual period, so l = 2m = 2*6 = 12
# Degrees of freedom = number of parameters in model, so dof = 0
power_train_snaive_fit %>%
residuals() %>%
features(.resid, ljung_box, lag = 12, dof = 0)
## # A tibble: 1 × 3
## .model lb_stat lb_pvalue
## <chr> <dbl> <dbl>
## 1 SNAIVE 68.9 5.08e-10
The residual plot of the \(ARIMA(0,0,1)(2,1,0)_{12} + drift\) model does not appear to have a pattern. All but one of the ACF spikes are non-significant. The distribution of residuals is not quite normal and has a mean greater than zero. In addition, the Ljung-Box test P-value was significant, so the null hypothesis that the residuals are white noise was rejected. Collectively, these results suggest that this model is not a good fit to the data.
ggtime::gg_tsresiduals(power_train_arima_fit)
# Ljung-Box test of residuals
# The time series is seasonal with a semi-annual period, so l = 2m = 2*6 = 12
# Degrees of freedom = number of parameters in model, so dof = 2
power_train_arima_fit %>%
residuals() %>%
features(.resid, ljung_box, lag = 12, dof = 2)
## # A tibble: 1 × 3
## .model lb_stat lb_pvalue
## <chr> <dbl> <dbl>
## 1 ARIMA 30.1 0.000825
The residual plot of the ETS model does not appear to have a pattern. However, the ACF plot shows a decaying sinusoidal pattern with several significant spikes, which suggests residual seasonality. The distribution of residuals appears to be close to normal, although the mean appears to be less than zero. The Ljung-Box test P-value was significant, so the null hypothesis that the residuals are white noise was rejected. Collectively, these results suggest that this model is not a good fit to the data.
ggtime::gg_tsresiduals(power_train_ets_fit)
# Ljung-Box test of residuals
# The time series is seasonal with a semi-annual period, so l = 2m = 2*6 = 12
# Degrees of freedom = number of parameters in model, so dof = 6 + (m-1) = 11
power_train_ets_fit %>%
residuals() %>%
features(.resid, ljung_box, lag = 12, dof = 11)
## # A tibble: 1 × 3
## .model lb_stat lb_pvalue
## <chr> <dbl> <dbl>
## 1 ETS 36.0 0.00000000197
Next, I decided to try modeling without Box-Cox transformation. My rationale for this was to undo changes to the original data to determine whether this improves the fit.
The KPSS unit root test indicated that first-order seasonal differencing of the un-transformed data is sufficient to achieve stationarity.
power_interpolated %>%
features(KWH, unitroot_nsdiffs) %>%
pull(nsdiffs)
## [1] 1
# Create training and test datasets to develop models and assess performance
power_train <- power_interpolated %>%
filter(year(Date) < 2011)
power_test <- power_interpolated %>%
filter(year(Date) == 2010)
# ETS
power_train_ets_fit <- power_train %>%
# Automatically determine the best method from the specified options
model(ETS = ETS(KWH ~ error(method = c('A', 'M')) +
trend(method = c('A', 'Ad', 'M')) +
season(method = c('A', 'M'))))
# ARIMA
power_train_arima_fit <- power_train %>%
model(ARIMA = ARIMA(KWH ~ PDQ(D = 1)))
# Baseline seasonal naive model
power_train_snaive_fit = power_train %>%
model(SNAIVE = SNAIVE(KWH))
# This block takes a few seconds to run
model_list <- list(power_train_ets_fit,
power_train_arima_fit,
power_train_snaive_fit)
compare_forecast_accuracy(model_list, power_test) %>%
arrange(RMSE)
## # A tibble: 3 × 5
## model RMSE MAE MPE MAPE
## <chr> <dbl> <dbl> <dbl> <dbl>
## 1 SNAIVE 0 0 0 0
## 2 ARIMA 474883. 366900. 1.85 5.07
## 3 ETS 689525. 474174. 3.62 6.22
I was not sure why all the accuracy metrics for the seasonal naive model are zero. I will examine its residuals below before jumping to the conclusion that this model is a perfect fit.
Compared with the \(ETS(M,M,M)\) model, the \(ARIMA(1,0,1)(2,1,0)_{12} + drift\) model has lower RMSE, MAE, MPE, and MAPE.
Due to the different model classes, comparisons using AIC or BIC are not valid. Details of each model are shown below.
report(power_train_arima_fit)
## Series: KWH
## Model: ARIMA(1,0,1)(2,1,0)[12] w/ drift
##
## Coefficients:
## ar1 ma1 sar1 sar2 constant
## -0.4992 0.7717 -0.7303 -0.4233 187101.25
## s.e. 0.1332 0.0923 0.0845 0.0829 83347.93
##
## sigma^2 estimated as 2.901e+11: log likelihood=-2106.45
## AIC=4224.89 AICc=4225.51 BIC=4242.71
report(power_train_ets_fit)
## Series: KWH
## Model: ETS(M,M,M)
## Smoothing parameters:
## alpha = 0.06281518
## beta = 0.0001000195
## gamma = 0.0001017283
##
## Initial states:
## l[0] b[0] s[0] s[-1] s[-2] s[-3] s[-4] s[-5]
## 6218746 1.000604 0.9190375 0.7458877 0.8983215 1.200753 1.24708 1.205941
## s[-6] s[-7] s[-8] s[-9] s[-10] s[-11]
## 0.9757765 0.7731204 0.8219729 0.9322194 1.058363 1.221527
##
## sigma^2: 0.0077
##
## AIC AICc BIC
## 4926.469 4930.904 4978.316
report(power_train_snaive_fit)
## Series: KWH
## Model: SNAIVE
##
## sigma^2: 512316892917.58
The residual plot of the seasonal naive model does not appear to have a pattern. Several spikes in the ACF plot exceed the critical values. The distribution of residuals is not normal and the mean is less than zero. In addition, the Ljung-Box test P-value was significant, so the null hypothesis that the residuals are white noise was rejected. Collectively, these results suggest that this model is not a good fit to the data.
ggtime::gg_tsresiduals(power_train_snaive_fit)
# Ljung-Box test of residuals
# The time series is seasonal with a semi-annual period, so l = 2m = 2*6 = 12
# Degrees of freedom = number of parameters in model, so dof = 0
power_train_snaive_fit %>%
residuals() %>%
features(.resid, ljung_box, lag = 12, dof = 0)
## # A tibble: 1 × 3
## .model lb_stat lb_pvalue
## <chr> <dbl> <dbl>
## 1 SNAIVE 62.1 0.00000000939
The residual plot of the \(ARIMA(1,0,1)(2,1,0)_{12} + drift\) model does not appear to have a pattern. All but one of the ACF spikes are non-significant (and the other only slightly exceeds the upper critical value). The distribution of residuals is close to normal, although the mean is greater than zero. The Ljung-Box test P-value was not significant, so the null hypothesis that the residuals are white noise was not rejected. Collectively, these results suggest that this model is a good fit to the data.
ggtime::gg_tsresiduals(power_train_arima_fit)
# Ljung-Box test of residuals
# The time series is seasonal with a semi-annual period, so l = 2m = 2*6 = 12
# Degrees of freedom = number of parameters in model, so dof = 3
power_train_arima_fit %>%
residuals() %>%
features(.resid, ljung_box, lag = 12, dof = 3)
## # A tibble: 1 × 3
## .model lb_stat lb_pvalue
## <chr> <dbl> <dbl>
## 1 ARIMA 14.1 0.118
The residual plot of the ETS model does not appear to have a pattern. However, the ACF plot has several significant spikes. The distribution of residuals is not quite normal, and the mean is less than zero. The Ljung-Box test P-value was significant, so the null hypothesis that the residuals are white noise was rejected. Collectively, these results suggest that this model is not a good fit to the data.
ggtime::gg_tsresiduals(power_train_ets_fit)
# Ljung-Box test of residuals
# The time series is seasonal with a semi-annual period, so l = 2m = 2*6 = 12
# Degrees of freedom = number of parameters in model, so dof = 6 + (m-1) = 11
power_train_ets_fit %>%
residuals() %>%
features(.resid, ljung_box, lag = 12, dof = 11)
## # A tibble: 1 × 3
## .model lb_stat lb_pvalue
## <chr> <dbl> <dbl>
## 1 ETS 37.7 8.04e-10
On the basis of the above results, I selected the seasonal \(ARIMA(1,0,1)(2,1,0)_{12} + drift\) model of the un-transformed data as the final model. To forecast future values, I refit this model to the entire dataset (ie, train + test), and then used it to forecast power usage 1 year into the future.
# Refit the training model to the full dataset
power_final_arima_fit <- power_train_arima_fit %>%
fabletools::refit(power_interpolated)
# Forecast future values
power_forecast <- power_final_arima_fit %>%
forecast(h = '1 year')
The forecast looks reasonable.
# Plot forecast
power_forecast %>%
ggtime::autoplot(power_interpolated) +
labs(
y = 'Power usage (kWH)',
title = 'Residential power usage, 1998-2013 + Forecast',
subtitle = latex2exp::TeX('ARIMA(1,0,1)(2,1,0)$_{12}$ + drift model')
) +
theme_classic() +
theme(
axis.title = element_text(face = 'bold'),
plot.title = element_text(face = 'bold')
)
power_forecast <- power_forecast %>%
as.data.frame() %>%
select(Date, KWH_forecast = `.mean`)
# Workaround because Excel doesn't read date field correctly
power_forecast$Date <- as.character(power_forecast$Date)
# List of forecast dataframes
forecasts_list <- list('ATM1' = ATM1_forecast,
'ATM2' = ATM2_forecast,
'ATM3' = ATM3_forecast,
'ATM4' = ATM4_forecast,
'power' = power_forecast)
# Save to a single Excel file (1 forecast per sheet)
write.xlsx(forecasts_list, file = 'project1_forecasts.xlsx')
pander::pander(sessionInfo())
R version 4.5.2 (2025-10-31)
Platform: aarch64-apple-darwin20
locale: en_US.UTF-8||en_US.UTF-8||en_US.UTF-8||C||en_US.UTF-8||en_US.UTF-8
attached base packages: stats, graphics, grDevices, utils, datasets, methods and base
other attached packages: pander(v.0.6.6), latex2exp(v.0.9.8), forecast(v.9.0.0), fable(v.0.5.0), feasts(v.0.4.2), fabletools(v.0.6.1), ggtime(v.0.2.0), tsibble(v.1.2.0), openxlsx(v.4.2.8.1), lubridate(v.1.9.5), forcats(v.1.0.1), stringr(v.1.6.0), dplyr(v.1.2.0), purrr(v.1.2.1), readr(v.2.1.6), tidyr(v.1.3.2), tibble(v.3.3.1), ggplot2(v.4.0.2) and tidyverse(v.2.0.0)
loaded via a namespace (and not attached): gtable(v.0.3.6), anytime(v.0.3.12), xfun(v.0.56), bslib(v.0.10.0), lattice(v.0.22-7), numDeriv(v.2016.8-1.1), tzdb(v.0.5.0), quadprog(v.1.5-8), vctrs(v.0.7.2), tools(v.4.5.2), generics(v.0.1.4), curl(v.7.0.0), parallel(v.4.5.2), xts(v.0.14.1), pkgconfig(v.2.0.3), RColorBrewer(v.1.1-3), S7(v.0.2.1), distributional(v.0.7.0), lifecycle(v.1.0.5), compiler(v.4.5.2), farver(v.2.1.2), htmltools(v.0.5.9), sass(v.0.4.10), yaml(v.2.3.12), pillar(v.1.11.1), jquerylib(v.0.1.4), cachem(v.1.1.0), nlme(v.3.1-168), fracdiff(v.1.5-3), tidyselect(v.1.2.1), zip(v.2.3.3), digest(v.0.6.39), stringi(v.1.8.7), labeling(v.0.4.3), tseries(v.0.10-60), fastmap(v.1.2.0), grid(v.4.5.2), colorspace(v.2.1-2), cli(v.3.6.5), magrittr(v.2.0.4), utf8(v.1.2.6), withr(v.3.0.2), scales(v.1.4.0), timechange(v.0.4.0), TTR(v.0.24.4), rmarkdown(v.2.30), quantmod(v.0.4.28), otel(v.0.2.0), nnet(v.7.3-20), timeDate(v.4052.112), progressr(v.0.18.0), zoo(v.1.8-15), hms(v.1.1.4), urca(v.1.3-4), evaluate(v.1.0.5), knitr(v.1.51), ggdist(v.3.3.3), lmtest(v.0.9-40), rlang(v.1.1.7), Rcpp(v.1.1.1), glue(v.1.8.0), rstudioapi(v.0.18.0), jsonlite(v.2.0.0) and R6(v.2.6.1)
Hyndman Chapter 9, section 9.10.↩︎