DATA 624 Project 1

library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr     1.1.4     ✔ readr     2.1.6
## ✔ forcats   1.0.1     ✔ stringr   1.6.0
## ✔ ggplot2   4.0.1     ✔ tibble    3.3.0
## ✔ lubridate 1.9.4     ✔ tidyr     1.3.1
## ✔ purrr     1.2.0     
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag()    masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(fpp3)
## Registered S3 methods overwritten by 'ggtime':
##   method           from      
##   autolayer.fbl_ts fabletools
##   autolayer.tbl_ts fabletools
##   autoplot.dcmp_ts fabletools
##   autoplot.fbl_ts  fabletools
##   autoplot.tbl_ts  fabletools
##   fortify.fbl_ts   fabletools
## ── Attaching packages ──────────────────────────────────────────── fpp3 1.0.2 ──
## ✔ tsibble     1.2.0     ✔ feasts      0.5.0
## ✔ tsibbledata 0.4.1     ✔ fable       0.5.0
## ── Conflicts ───────────────────────────────────────────────── fpp3_conflicts ──
## ✖ lubridate::date()    masks base::date()
## ✖ dplyr::filter()      masks stats::filter()
## ✖ tsibble::intersect() masks base::intersect()
## ✖ tsibble::interval()  masks lubridate::interval()
## ✖ dplyr::lag()         masks stats::lag()
## ✖ tsibble::setdiff()   masks base::setdiff()
## ✖ tsibble::union()     masks base::union()
library(readxl)
library(writexl)

PART A

atm_raw <- read_excel("ATM624Data.xlsx",
                      col_types = c("date", "text", "numeric")) |>
  rename(date = DATE, atm = ATM, cash = Cash)

glimpse(atm_raw)
## Rows: 1,474
## Columns: 3
## $ date <dttm> 2009-05-01, 2009-05-01, 2009-05-02, 2009-05-02, 2009-05-03, 2009…
## $ 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, …
summary(atm_raw)
##       date                         atm                 cash        
##  Min.   :2009-05-01 00:00:00   Length:1474        Min.   :    0.0  
##  1st Qu.:2009-08-01 00:00:00   Class :character   1st Qu.:    0.5  
##  Median :2009-11-01 00:00:00   Mode  :character   Median :   73.0  
##  Mean   :2009-10-31 19:11:48                      Mean   :  155.6  
##  3rd Qu.:2010-02-01 00:00:00                      3rd Qu.:  114.0  
##  Max.   :2010-05-14 00:00:00                      Max.   :10919.8  
##                                                   NA's   :19
head(atm_raw)
## # A tibble: 6 × 3
##   date                atm    cash
##   <dttm>              <chr> <dbl>
## 1 2009-05-01 00:00:00 ATM1     96
## 2 2009-05-01 00:00:00 ATM2    107
## 3 2009-05-02 00:00:00 ATM1     82
## 4 2009-05-02 00:00:00 ATM2     89
## 5 2009-05-03 00:00:00 ATM1     85
## 6 2009-05-03 00:00:00 ATM2     90
#How many unique ATMs, and what is the date range?
atm_raw |>
  filter(!is.na(atm)) |>
  group_by(atm) |>
  summarise(
    n         = n(),
    start     = min(date),
    end       = max(date),
    n_missing = sum(is.na(cash)),
    n_zero    = sum(cash == 0, na.rm = TRUE),
    max_cash  = max(cash, na.rm = TRUE)
  )
## # A tibble: 4 × 7
##   atm       n start               end                 n_missing n_zero max_cash
##   <chr> <int> <dttm>              <dttm>                  <int>  <int>    <dbl>
## 1 ATM1    365 2009-05-01 00:00:00 2010-04-30 00:00:00         3      0     180 
## 2 ATM2    365 2009-05-01 00:00:00 2010-04-30 00:00:00         2      2     147 
## 3 ATM3    365 2009-05-01 00:00:00 2010-04-30 00:00:00         0    362      96 
## 4 ATM4    365 2009-05-01 00:00:00 2010-04-30 00:00:00         0      0   10920.
#What do the NA rows look like?
atm_raw |> filter(is.na(atm))
## # A tibble: 14 × 3
##    date                atm    cash
##    <dttm>              <chr> <dbl>
##  1 2010-05-01 00:00:00 <NA>     NA
##  2 2010-05-02 00:00:00 <NA>     NA
##  3 2010-05-03 00:00:00 <NA>     NA
##  4 2010-05-04 00:00:00 <NA>     NA
##  5 2010-05-05 00:00:00 <NA>     NA
##  6 2010-05-06 00:00:00 <NA>     NA
##  7 2010-05-07 00:00:00 <NA>     NA
##  8 2010-05-08 00:00:00 <NA>     NA
##  9 2010-05-09 00:00:00 <NA>     NA
## 10 2010-05-10 00:00:00 <NA>     NA
## 11 2010-05-11 00:00:00 <NA>     NA
## 12 2010-05-12 00:00:00 <NA>     NA
## 13 2010-05-13 00:00:00 <NA>     NA
## 14 2010-05-14 00:00:00 <NA>     NA
#Convert to tsibble
atm_raw <- atm_raw |>
  mutate(date = as.Date(date)) |>
  as_tsibble(index = date, key = atm)

There are 14 rows at the bottom with no ATM or Cash. These are the May 2010 forecast placeholder rows embedded in the file. I also see some missing Cash values and one extreme max value in ATM4. I will investigate these after visualising the raw series.

atm_raw |>
  filter(!is.na(atm)) |>
  mutate(date = as.Date(date)) |>
  ggplot(aes(x = date, y = cash)) +
  geom_line() +
  facet_wrap(~atm, scales = "free_y", ncol = 1) +
  labs(
    title    = "Daily ATM Cash Withdrawals - Raw Data", y = "Cash ($100s)"
  )

Since these initial plots indicated there might be some weekly seasonality, I decided to check for that. But before that, I removed the outlier in ATM4. I replaced it with the previous week’s value from the same day because I suspected said weekly seasonality. The plots below ended up confirming just that.

#Detecting the outlier
atm_raw |>
  filter(atm == "ATM4") |>
  arrange(desc(cash)) |>
  head(5)
## # A tsibble: 5 x 3 [1D]
## # Key:       atm [1]
##   date       atm     cash
##   <date>     <chr>  <dbl>
## 1 2010-02-09 ATM4  10920.
## 2 2009-09-22 ATM4   1712.
## 3 2010-01-29 ATM4   1575.
## 4 2009-09-04 ATM4   1495.
## 5 2009-06-09 ATM4   1484.
#Removing ATM4 outlier
atm_fixed <- atm_raw |>
  filter(!is.na(atm)) |>
  mutate(cash = if_else(atm == "ATM4" & date == as.Date("2010-02-09"),
                        153.24, cash))

#Season plot to visually check for weekly patterns
atm_fixed |>
  filter(atm %in% c("ATM1", "ATM2", "ATM4")) |>
  gg_season(cash, period = "week") +
  facet_wrap(~atm, scales = "free_y", ncol = 1) +
  labs(title = "Weekly Season Plot - ATM Cash Withdrawals", y = "Cash ($100s)")

#ACF, spikes at lags 7, 14, 21 indicate weekly seasonality
atm_fixed |>
  filter(atm %in% c("ATM1", "ATM2", "ATM4")) |>
  ACF(cash, lag_max = 28) |>
  autoplot() +
  facet_wrap(~atm, scales = "free_y") +
  labs(title = "ACF - ATM Cash Withdrawals")

There is a lot of useful information in the plots. The season plot shows consistent day-of-week patterns across all weeks for ATM1 and ATM2, most notably a pronounced dip on Thursday visible in nearly every week. ATM4 shows a similar but noisier pattern. This is strong visual evidence of weekly seasonality. The ACF confirms what the season plot showed. ATM1 and ATM2 have large, significant spikes at lags 7, 14, and 21, which is a sign of weekly seasonality. ATM4’s ACF is weaker but still shows spikes at the same lags, consistent with a noisier version of the same pattern. Both plots together give us sufficient evidence to proceed with weekly seasonal models.

The summarized findings: ATM1: Reasonably consistent withdrawals throughout the series with visible weekly seasonality. ATM2: Similar behavior to ATM1, maybe even a trend too. ATM3: Almost entirely zero for the whole series, then a sudden jump at the very end. This is abnormal and needs investigation. ATM4: One extreme spike that completely dominates the y-axis, making the rest of the series impossible to read. This is almost certainly an error because that’s way too much money for an ATM to hold, which I ended up removing, as explained.

#Investigate ATM3
atm_fixed |>
  filter(atm == "ATM3") |>
  filter(cash > 0)
## # A tsibble: 3 x 3 [1D]
## # Key:       atm [1]
##   date       atm    cash
##   <date>     <chr> <dbl>
## 1 2010-04-28 ATM3     96
## 2 2010-04-29 ATM3     82
## 3 2010-04-30 ATM3     85
#Compare ATM3 non-zero dates against ATM1 on same dates
atm_fixed |>
  filter(atm %in% c("ATM1", "ATM3")) |>
  filter(date >= as.Date("2010-04-28")) |>
  pivot_wider(names_from = atm, values_from = cash)
## # A tsibble: 3 x 3 [1D]
##   date        ATM1  ATM3
##   <date>     <dbl> <dbl>
## 1 2010-04-28    96    96
## 2 2010-04-29    82    82
## 3 2010-04-30    85    85
#Check remaining missing values
atm_fixed |>
  filter(is.na(cash))
## # A tsibble: 5 x 3 [1D]
## # Key:       atm [2]
##   date       atm    cash
##   <date>     <chr> <dbl>
## 1 2009-06-13 ATM1     NA
## 2 2009-06-16 ATM1     NA
## 3 2009-06-22 ATM1     NA
## 4 2009-06-18 ATM2     NA
## 5 2009-06-24 ATM2     NA

ATM3 has only 3 non-zero observations and they are identical to ATM1 on those same dates. This strongly suggests ATM3 was not operational during this period and its final 3 entries are just duplicates of ATM1. With only 3 real data points there is no meaningful basis for an independent model, so I will forecast ATM3 using ATM1’s model. ATM1 and ATM2 each have a small number of missing Cash values in June 2009. Now that weekly seasonality is confirmed, lag-7 imputation replacing each missing value with the observation from the same day the prior week can be done.

#Impute missing values using same day previous week, now we have no NA values at all
atm_clean <- atm_fixed |>
  group_by_key() |>
  mutate(cash = if_else(is.na(cash), lag(cash, 7), cash)) |>
  ungroup()

#Cleaned series
atm_clean |>
  filter(atm %in% c("ATM1", "ATM2", "ATM4")) |>
  autoplot(cash) +
  facet_wrap(~atm, scales = "free_y", ncol = 1) +
  labs(
    title    = "Daily ATM Cash Withdrawals- Cleaned", y = "Cash ($100s)"
  )

ATM 1 and 2 look great, but ATM4 still looks rather noisy, so I will attempt to see if it requires a BoxCox transformation by finding the optimal lambda.

#Check if variance is changing over time for ATM4
atm_clean |>
  filter(atm == "ATM4") |>
  autoplot(cash) +
  labs(title = "ATM4 - Check for changing variance", y = "Cash ($100s)")

#Guerrero method to find optimal lambda
lambda <- atm_clean |>
  filter(atm == "ATM4") |>
  features(cash, features = guerrero) |>
  pull(lambda_guerrero)

cat("Guerrero lambda for ATM4:", round(lambda, 4))
## Guerrero lambda for ATM4: 0.4489

The Guerrero method returns a lambda of 0.45, which is close to a square root transformation. This is sufficiently far from 1 to justify applying a Box-Cox transformation to ATM4.

atm_clean |> features(cash, unitroot_kpss)
## # A tibble: 4 × 3
##   atm   kpss_stat kpss_pvalue
##   <chr>     <dbl>       <dbl>
## 1 ATM1      0.506      0.0403
## 2 ATM2      2.02       0.01  
## 3 ATM3      0.389      0.0818
## 4 ATM4      0.123      0.1
atm_clean |> features(cash, unitroot_ndiffs)  #number of differences needed
## # A tibble: 4 × 2
##   atm   ndiffs
##   <chr>  <int>
## 1 ATM1       1
## 2 ATM2       1
## 3 ATM3       0
## 4 ATM4       0

This essentially confirms that ATM1 and 2 are non-stationary, while the other 2 are.

#Pivot wide and convert to tsibble for modelling
atm_ts <- atm_clean |>
  pivot_wider(names_from = atm, values_from = cash) |>
  as_tsibble(index = date)
#STL decomposition to examine trend and seasonal components before model selection
atm_clean |>
  filter(atm %in% "ATM1") |>
  model(STL(cash)) |>
  components() |>
  autoplot() +
  labs(title = "STL Decomposition - ATM1") 

atm_clean |>
  filter(atm %in% "ATM2") |>
  model(STL(cash)) |>
  components() |>
  autoplot() +
  labs(title = "STL Decomposition - ATM2") 

atm_clean |>
  filter(atm %in% "ATM4") |>
  model(STL(cash)) |>
  components() |>
  autoplot() +
  labs(title = "STL Decomposition - ATM4") 

The STL decomposition confirms what I saw in the season plots. ATM1 and ATM2 show no meaningful trend, as the trend component is essentially flat across the full year. Although I initially thought there might be a trend in ATM2, and it might look like there is a downwards trend, it recovers towards the end of the series, so I will simply consider it as not having a trend. The seasonal component is strong and stable for all 3, consistent with the weekly pattern I saw earlier. The remainder is reasonably well-behaved with no obvious structure, which is a good sign for modelling. Though ATM4’s remainder is a bit noisier with more variance, but this is just more justification for the BoxCox transformation later on.

atm_ts |> gg_tsdisplay(ATM1, plot_type = "partial", lag_max = 28) +
  labs(title = "ATM1 - ACF and PACF")

atm_ts |> gg_tsdisplay(ATM2, plot_type = "partial", lag_max = 28) +
  labs(title = "ATM2 - ACF and PACF")    

atm_ts |> gg_tsdisplay(ATM4, plot_type = "partial", lag_max = 28) +
  labs(title = "ATM4 - ACF and PACF")

The ACF and PACF plots inform my model selection. ATM1 and ATM2 both show large, significant spikes at lags 7, 14, 21, and 28 in the ACF with a slow decay, and the PACF cuts off sharply after lag 7. This is the signature of seasonal autocorrelation at a weekly period, confirming that seasonal differencing at period 7 is indeed needed. ATM4 shows much weaker autocorrelation overall, as most spikes barely exceed the significance bounds, which is consistent with the high variance I observed. The BoxCox transformation should help expose the underlying structure more clearly. It already looks stationary, while the other 2 don’t. I will fit three models for each ATM: a seasonal NAIVE model as a benchmark, an automatically selected ARIMA constrained to weekly seasonality, and an ETS model. If ARIMA and ETS cannot beat the SNAIVE benchmark, then obviously added complexity is not justified.

fit_atm1 <- atm_ts |>
  model(
    snaive = SNAIVE(ATM1 ~ lag("week")),
    arima  = ARIMA(ATM1 ~ PDQ(period = 7)),
    ets    = ETS(ATM1)
  )

fit_atm2 <- atm_ts |>
  model(
    snaive = SNAIVE(ATM2 ~ lag("week")),
    arima  = ARIMA(ATM2 ~ PDQ(period = 7)),
    ets    = ETS(ATM2)
  )

#Fitting atm4 with the BoxCox transformation applied
fit_atm4 <- atm_ts |>
  model(
    snaive = SNAIVE(ATM4 ~ lag("week")),
    arima  = ARIMA(box_cox(ATM4, lambda) ~ PDQ(period = 7)),
    ets    = ETS(box_cox(ATM4, lambda))
  )

#Reports
fit_atm1 |> select(arima) |> report()
## Series: ATM1 
## Model: ARIMA(0,0,1)(0,1,2)[7] 
## 
## Coefficients:
##          ma1     sma1     sma2
##       0.1868  -0.5762  -0.1096
## s.e.  0.0548   0.0507   0.0496
## 
## sigma^2 estimated as 556.4:  log likelihood=-1640.02
## AIC=3288.04   AICc=3288.16   BIC=3303.57
fit_atm1 |> select(ets)   |> report()
## Series: ATM1 
## Model: ETS(A,N,A) 
##   Smoothing parameters:
##     alpha = 0.0206385 
##     gamma = 0.3301984 
## 
##   Initial states:
##      l[0]      s[0]    s[-1]    s[-2]    s[-3]    s[-4]    s[-5]    s[-6]
##  79.39605 -62.73693 6.754848 13.60636 10.95823 12.94413 10.40877 8.064604
## 
##   sigma^2:  578.8824
## 
##      AIC     AICc      BIC 
## 4486.151 4486.772 4525.150
fit_atm2 |> select(arima) |> report()
## Series: ATM2 
## Model: ARIMA(2,0,2)(0,1,1)[7] 
## 
## Coefficients:
##           ar1      ar2     ma1     ma2     sma1
##       -0.4236  -0.9122  0.4590  0.7992  -0.7487
## s.e.   0.0548   0.0423  0.0848  0.0576   0.0388
## 
## sigma^2 estimated as 586:  log likelihood=-1648.63
## AIC=3309.25   AICc=3309.49   BIC=3332.54
fit_atm2 |> select(ets)   |> report()
## Series: ATM2 
## Model: ETS(A,N,A) 
##   Smoothing parameters:
##     alpha = 0.003771524 
##     gamma = 0.3621959 
## 
##   Initial states:
##      l[0]      s[0]     s[-1]   s[-2]    s[-3]    s[-4]    s[-5]   s[-6]
##  72.74555 -53.45142 -42.43341 16.7643 6.024302 26.48906 17.09206 29.5151
## 
##   sigma^2:  624.0025
## 
##      AIC     AICc      BIC 
## 4513.546 4514.168 4552.545
fit_atm4 |> select(arima) |> report()
## Series: ATM4 
## Model: ARIMA(0,0,1)(2,0,0)[7] w/ mean 
## Transformation: box_cox(ATM4, lambda) 
## 
## Coefficients:
##          ma1    sar1    sar2  constant
##       0.0801  0.2070  0.2037   16.8086
## s.e.  0.0527  0.0516  0.0524    0.7292
## 
## sigma^2 estimated as 174.9:  log likelihood=-1458.97
## AIC=2927.93   AICc=2928.1   BIC=2947.43
fit_atm4 |> select(ets)   |> report()
## Series: ATM4 
## Model: ETS(M,N,A) 
## Transformation: box_cox(ATM4, lambda) 
##   Smoothing parameters:
##     alpha = 0.01221965 
##     gamma = 0.1804067 
## 
##   Initial states:
##      l[0]      s[0]     s[-1]    s[-2]    s[-3]    s[-4]    s[-5]    s[-6]
##  26.99257 -19.19674 -4.763839 1.615992 2.809201 7.310503 8.141445 4.083436
## 
##   sigma^2:  0.2214
## 
##      AIC     AICc      BIC 
## 4018.952 4019.574 4057.951
#Information criteria comparison (ARIMA and ETS only becauseSNAIVE has no AIC)
glance(fit_atm1)
## # A tibble: 3 × 11
##   .model sigma2 log_lik   AIC  AICc   BIC ar_roots  ma_roots     MSE  AMSE   MAE
##   <chr>   <dbl>   <dbl> <dbl> <dbl> <dbl> <list>    <list>     <dbl> <dbl> <dbl>
## 1 snaive   767.     NA    NA    NA    NA  <NULL>    <NULL>       NA    NA   NA  
## 2 arima    556.  -1640. 3288. 3288. 3304. <cpl [0]> <cpl [15]>   NA    NA   NA  
## 3 ets      579.  -2233. 4486. 4487. 4525. <NULL>    <NULL>      565.  568.  15.1
glance(fit_atm2)
## # A tibble: 3 × 11
##   .model sigma2 log_lik   AIC  AICc   BIC ar_roots  ma_roots    MSE  AMSE   MAE
##   <chr>   <dbl>   <dbl> <dbl> <dbl> <dbl> <list>    <list>    <dbl> <dbl> <dbl>
## 1 snaive   882.     NA    NA    NA    NA  <NULL>    <NULL>      NA    NA   NA  
## 2 arima    586.  -1649. 3309. 3309. 3333. <cpl [2]> <cpl [9]>   NA    NA   NA  
## 3 ets      624.  -2247. 4514. 4514. 4553. <NULL>    <NULL>     609.  610.  17.2
glance(fit_atm4)
## # A tibble: 3 × 11
##   .model   sigma2 log_lik   AIC  AICc   BIC ar_roots ma_roots   MSE  AMSE    MAE
##   <chr>     <dbl>   <dbl> <dbl> <dbl> <dbl> <list>   <list>   <dbl> <dbl>  <dbl>
## 1 snaive  2.06e+5     NA    NA    NA    NA  <NULL>   <NULL>     NA    NA  NA    
## 2 arima   1.75e+2  -1459. 2928. 2928. 2947. <cpl>    <cpl>      NA    NA  NA    
## 3 ets     2.21e-1  -1999. 4019. 4020. 4058. <NULL>   <NULL>    164.  165.  0.369
#In-sample accuracy for all three models including SNAIVE
accuracy(fit_atm1)
## # A tibble: 3 × 10
##   .model .type         ME  RMSE   MAE    MPE  MAPE  MASE RMSSE     ACF1
##   <chr>  <chr>      <dbl> <dbl> <dbl>  <dbl> <dbl> <dbl> <dbl>    <dbl>
## 1 snaive Training -0.0363  27.7  17.5  -96.5  116. 1     1      0.180  
## 2 arima  Training -0.0736  23.3  14.6 -102.   117. 0.830 0.841 -0.00824
## 3 ets    Training -0.144   23.8  15.1 -106.   121. 0.858 0.859  0.138
accuracy(fit_atm2)
## # A tibble: 3 × 10
##   .model .type         ME  RMSE   MAE   MPE  MAPE  MASE RMSSE     ACF1
##   <chr>  <chr>      <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>    <dbl>
## 1 snaive Training  0.0223  29.6  20.3  -Inf   Inf 1     1      0.0483 
## 2 arima  Training -0.853   23.8  16.7  -Inf   Inf 0.824 0.803 -0.00207
## 3 ets    Training -0.626   24.7  17.2  -Inf   Inf 0.848 0.832  0.0106
accuracy(fit_atm4)
## # A tibble: 3 × 10
##   .model .type       ME  RMSE   MAE   MPE  MAPE  MASE RMSSE   ACF1
##   <chr>  <chr>    <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>  <dbl>
## 1 snaive Training -3.70  453.  346. -392.  447. 1     1     0.0628
## 2 arima  Training 85.0   352.  275. -367.  412. 0.793 0.777 0.0195
## 3 ets    Training 64.5   345.  263. -360.  405. 0.759 0.760 0.0894
#Residual diagnostics, checking that model residuals resemble white noise
fit_atm1 |> select(arima) |> gg_tsresiduals() +
  labs(title = "ATM1 - ARIMA residuals")

fit_atm1 |> select(ets) |> gg_tsresiduals() +
  labs(title = "ATM1 - ETS residuals")

fit_atm2 |> select(arima) |> gg_tsresiduals() +
  labs(title = "ATM2 - ARIMA residuals")

fit_atm2 |> select(ets) |> gg_tsresiduals() +
  labs(title = "ATM2 - ETS residuals")

fit_atm4 |> select(arima) |> gg_tsresiduals() +
  labs(title = "ATM4 - ARIMA residuals")

fit_atm4 |> select(ets) |> gg_tsresiduals() +
  labs(title = "ATM4 - ETS residuals")

#Ljung-Box test, the formal test for residual autocorrelation
#H0: residuals are white noise. p > 0.05 means fail to reject.
#lag = 14 covers two seasonal periods
augment(fit_atm1) |> features(.innov, ljung_box, lag = 14) |> select(.model, lb_stat, lb_pvalue)
## # A tibble: 3 × 3
##   .model lb_stat lb_pvalue
##   <chr>    <dbl>     <dbl>
## 1 arima     15.8  3.26e- 1
## 2 ets       29.9  7.98e- 3
## 3 snaive    82.6  9.33e-12
augment(fit_atm2) |> features(.innov, ljung_box, lag = 14) |> select(.model, lb_stat, lb_pvalue)
## # A tibble: 3 × 3
##   .model lb_stat lb_pvalue
##   <chr>    <dbl>     <dbl>
## 1 arima     9.00  8.31e- 1
## 2 ets      36.8   7.84e- 4
## 3 snaive  104.    8.88e-16
augment(fit_atm4) |> features(.innov, ljung_box, lag = 14) |> select(.model, lb_stat, lb_pvalue)
## # A tibble: 3 × 3
##   .model lb_stat lb_pvalue
##   <chr>    <dbl>     <dbl>
## 1 arima     17.1     0.252
## 2 ets       16.4     0.290
## 3 snaive   117.      0

Model Results and Diagnostics A good model should have residuals that resemble white noise - no patterns in the time plot, no significant spikes in the ACF, and a roughly symmetric histogram centred at zero Three models were fitted for each ATM: a SNAIVE benchmark, ARIMA with weekly seasonality, and ETS. For ATM1 and ATM2, ARIMA selected seasonal differencing models - ARIMA(0,0,1)(0,1,2)[7] and ARIMA(2,0,2)(0,1,1)[7] respectively. Both ETS models selected ETS(A,N,A), showing the flat trend and stable seasonality from the STL decomposition. ATM4’s models differ slightly. ARIMA used seasonal AR terms without differencing given its weaker autocorrelation structure, and ETS selected ETS(M,N,A) with multiplicative errors to account for the higher variance.

Residual diagnostics show that, for ATM1 and ATM2, ARIMA passes the Ljung-Box test comfortably (p = 0.33 and p = 0.83), while ETS fails for both (p = 0.008 and p = 0.0008) and SNAIVE fails completely. For ATM4 both ARIMA and ETS pass (p = 0.25 and p = 0.29), so diagnostics alone cannot separate them. In-sample RMSE follows the same pattern: ARIMA outperforms on ATM1 (23.3 vs 23.8 for ETS) and ATM2 (23.8 vs 24.7), while ATM4 is too close to call (ETS at 345 versus ARIMA at 352).

Since in-sample accuracy is evaluated on data the model already saw, I will use time series cross-validation with a rolling forecasting origin to get a true picture of out-of-sample forecast performance.

#CV

#Would have used .step = 1, but my computer exploded
atm1_stretch <- atm_ts |>
  slice(1:(n() - 31)) |>
  select(ATM1) |>
  stretch_tsibble(.init = 182, .step = 7)

cv_atm1 <- atm1_stretch |>
  model(
    snaive = SNAIVE(ATM1 ~ lag("week")),
    arima  = ARIMA(ATM1 ~ PDQ(period = 7)),
    ets    = ETS(ATM1)
  ) |>
  forecast(h = 30)

cv_atm1 |>
  group_by(.id, .model) |>
  mutate(h = row_number()) |>
  ungroup() |>
  as_fable(response = "ATM1", distribution = ATM1) |>
  accuracy(atm_ts, by = c("h", ".model")) |>
  select(h, .model, RMSE, MAE) |>
  ggplot(aes(x = h, y = RMSE, col = .model)) +
  geom_line() +
  labs(title = "ATM1 - Cross-validated RMSE by forecast horizon",
       x = "Horizon (days)", y = "RMSE")

atm2_stretch <- atm_ts |>
  slice(1:(n() - 31)) |>
  select(ATM2) |>
  stretch_tsibble(.init = 182, .step = 7)

cv_atm2 <- atm2_stretch |>
  model(
    snaive = SNAIVE(ATM2 ~ lag("week")),
    arima  = ARIMA(ATM2 ~ PDQ(period = 7)),
    ets    = ETS(ATM2)
  ) |>
  forecast(h = 30)

cv_atm2 |>
  group_by(.id, .model) |>
  mutate(h = row_number()) |>
  ungroup() |>
  as_fable(response = "ATM2", distribution = ATM2) |>
  accuracy(atm_ts, by = c("h", ".model")) |>
  select(h, .model, RMSE, MAE) |>
  ggplot(aes(x = h, y = RMSE, col = .model)) +
  geom_line() +
  labs(title = "ATM2 - Cross-validated RMSE by forecast horizon",
       x = "Horizon (days)", y = "RMSE")

atm4_stretch <- atm_ts |>
  slice(1:(n() - 31)) |>
  select(ATM4) |>
  stretch_tsibble(.init = 182, .step = 7)

cv_atm4 <- atm4_stretch |>
  model(
    snaive = SNAIVE(ATM4 ~ lag("week")),
    arima  = ARIMA(box_cox(ATM4, lambda) ~ PDQ(period = 7)),
    ets    = ETS(box_cox(ATM4, lambda))
  ) |>
  forecast(h = 30)

cv_atm4 |>
  group_by(.id, .model) |>
  mutate(h = row_number()) |>
  ungroup() |>
  as_fable(response = "ATM4", distribution = ATM4) |>
  accuracy(atm_ts, by = c("h", ".model")) |>
  select(h, .model, RMSE, MAE) |>
  ggplot(aes(x = h, y = RMSE, col = .model)) +
  geom_line() +
  labs(title = "ATM4 - Cross-validated RMSE by forecast horizon",
       x = "Horizon (days)", y = "RMSE")

Cross-validation confirms the residual diagnostics for ATM1 and ATM2 but tells a different story for ATM4. For ATM1, ARIMA consistently produces the lowest RMSE across nearly all forecast horizons, with ETS slightly worse and SNAIVE the weakest. The periodic dips in RMSE at multiples of 7 are expected, predicting 7, 14, 21 days ahead again aligns with the weekly seasonal cycle and makes those horizons naturally easier to forecast. ARIMA is the clear choice for ATM1, from my understanding. For ATM2, the picture is less clear because ARIMA and ETS track closely together across all horizons and both are comparable to SNAIVE for much of the range, with ARIMA pulling slightly ahead at longer horizons. However, ARIMA already passed the Ljung-Box test while ETS failed, so combined with the small CV advantage ARIMA is also my preferred model for ATM2. For ATM4, ARIMA and ETS are very close across all horizons and both substantially outperform SNAIVE. Given that both models pass the Ljung-Box test and CV does not clearly separate them, ETS is selected as the slightly more parsimonious model, because simplicity is preferred.

#Final forecasts - 31 days for May 2010
fc_atm1 <- fit_atm1 |>
  select(arima) |>
  forecast(h = 31)

fc_atm2 <- fit_atm2 |>
  select(arima) |>
  forecast(h = 31)

fc_atm4 <- fit_atm4 |>
  select(ets) |>
  forecast(h = 31)

fc_atm1 |>
  autoplot(atm_ts |> filter_index("2010-02-01" ~ .), level = 80) +
  labs(title = "ATM1 - May 2010 Forecast", y = "Cash ($100s)")

fc_atm2 |>
  autoplot(atm_ts |> filter_index("2010-02-01" ~ .), level = 80) +
  labs(title = "ATM2 - May 2010 Forecast", y = "Cash ($100s)")

fc_atm4 |>
  autoplot(atm_ts |> filter_index("2010-02-01" ~ .), level = 80) +
  labs(title = "ATM4 - May 2010 Forecast", y = "Cash ($100s)")

The ATM4 forecast has intervals so wide that it doesn’t really make sense. Since the ARIMA model also passed the tests here, I will just switch to that instead and see if it is any better.

#Checking the means to confirm whether the model is wrong, or the data simply has high variance and the model is accounting for it
fc_atm4 |>
  as_tibble() |>
  select(date, .mean)
## # A tibble: 31 × 2
##    date       .mean
##    <date>     <dbl>
##  1 2010-05-01 337. 
##  2 2010-05-02 460. 
##  3 2010-05-03 473. 
##  4 2010-05-04  43.2
##  5 2010-05-05 589. 
##  6 2010-05-06 385. 
##  7 2010-05-07 623. 
##  8 2010-05-08 342. 
##  9 2010-05-09 465. 
## 10 2010-05-10 474. 
## # ℹ 21 more rows

The point forecasts for all three ATMs are plausible and consistent with historical withdrawal patterns. ATM1 and ATM2 forecasts show the expected weekly cycle continuing into May 2010 with relatively tight prediction intervals. ATM4’s prediction intervals are notably wider, which was surprising to me. I considered using the ARIMA model, which also passed the tests, but ultimately decide against it. Because I believe they are simply reflecting the genuine high variance observed throughout that series, and the model is correctly acknowledging uncertainty rather than overstating precision. The point forecasts for ATM4 range between approximately $24000 and $51000 per day, which is consistent with the historical average of around $44500. ATM3 is just assigned ATM1’s forecast directly, given the evidence that the two machines recorded identical values on their only overlapping dates.

#ATM3 uses ATM1's forecast because no independent model possible
atm1_out <- fc_atm1 |>
  as_tibble() |>
  mutate(ATM = "ATM1") |>
  select(DATE = date, ATM, Cash = .mean)

atm3_out <- atm1_out |>
  mutate(ATM = "ATM3")

atm2_out <- fc_atm2 |>
  as_tibble() |>
  mutate(ATM = "ATM2") |>
  select(DATE = date, ATM, Cash = .mean)

atm4_out <- fc_atm4 |>
  as_tibble() |>
  mutate(ATM = "ATM4") |>
  select(DATE = date, ATM, Cash = .mean)

#Showing the forecast cash values
atm_forecast <- bind_rows(atm1_out, atm2_out, atm3_out, atm4_out) |>
  arrange(ATM, DATE)

write_xlsx(atm_forecast, "PartA_ATM_May2010_Forecast.xlsx")
print(atm_forecast)
## # A tibble: 124 × 3
##    DATE       ATM     Cash
##    <date>     <chr>  <dbl>
##  1 2010-05-01 ATM1   87.2 
##  2 2010-05-02 ATM1  104.  
##  3 2010-05-03 ATM1   73.1 
##  4 2010-05-04 ATM1    8.16
##  5 2010-05-05 ATM1  100.0 
##  6 2010-05-06 ATM1   80.9 
##  7 2010-05-07 ATM1   86.7 
##  8 2010-05-08 ATM1   88.0 
##  9 2010-05-09 ATM1  103.  
## 10 2010-05-10 ATM1   73.4 
## # ℹ 114 more rows

PART B

power_raw <- read_excel("ResidentialCustomerForecastLoad-624.xlsx",
                        col_types = c("numeric", "text", "numeric")) |>
  rename(seq_id = CaseSequence, period = `YYYY-MMM`, kwh = KWH)

glimpse(power_raw)
## Rows: 192
## Columns: 3
## $ seq_id <dbl> 733, 734, 735, 736, 737, 738, 739, 740, 741, 742, 743, 744, 745…
## $ period <chr> "1998-Jan", "1998-Feb", "1998-Mar", "1998-Apr", "1998-May", "19…
## $ kwh    <dbl> 6862583, 5838198, 5420658, 5010364, 4665377, 6467147, 8914755, …
summary(power_raw)
##      seq_id         period               kwh          
##  Min.   :733.0   Length:192         Min.   :  770523  
##  1st Qu.:780.8   Class :character   1st Qu.: 5429912  
##  Median :828.5   Mode  :character   Median : 6283324  
##  Mean   :828.5                      Mean   : 6502475  
##  3rd Qu.:876.2                      3rd Qu.: 7620524  
##  Max.   :924.0                      Max.   :10655730  
##                                     NA's   :1
#Convert to tsibble
power_ts <- power_raw |>
  mutate(month = yearmonth(period)) |>
  select(month, kwh) |>
  as_tsibble(index = month)

head(power_ts)
## # A tsibble: 6 x 2 [1M]
##      month     kwh
##      <mth>   <dbl>
## 1 1998 Jan 6862583
## 2 1998 Feb 5838198
## 3 1998 Mar 5420658
## 4 1998 Apr 5010364
## 5 1998 May 4665377
## 6 1998 Jun 6467147
#Initial plot
power_ts |>
  autoplot(kwh) +
  labs(title = "Monthly Residential Power Consumption",
       subtitle = "January 1998 - December 2013", y = "KWH")

We have a clear annual seasonality with summer and winter peaks, a slight upward trend from 1998 through roughly 2010, then a slightly leveling off. And there’s an obvious outlier around 2010 because that extreme dip is clearly wrong, normal values are in the 4-9 million range. There is also 1 missing value, just from observing the dataset, in Sep 2009. I’ll follow the same approach and impute both strange values, just with values from 12 months prior.

power_ts |> filter(is.na(kwh))
## # A tsibble: 1 x 2 [1M]
##      month   kwh
##      <mth> <dbl>
## 1 2008 Sep    NA
power_ts |> filter(kwh < 3000000)
## # A tsibble: 1 x 2 [1M]
##      month    kwh
##      <mth>  <dbl>
## 1 2010 Jul 770523
#Check prior year values for imputation
power_ts |> filter(month == yearmonth("2007 Sep") | month == yearmonth("2009 Jul"))
## # A tsibble: 2 x 2 [1M]
##      month     kwh
##      <mth>   <dbl>
## 1 2007 Sep 7666970
## 2 2009 Jul 7713260
power_clean <- power_ts |>
  mutate(kwh = case_when(
    month == yearmonth("2008 Sep") ~ power_ts$kwh[power_ts$month == yearmonth("2007 Sep")],
    month == yearmonth("2010 Jul") ~ power_ts$kwh[power_ts$month == yearmonth("2009 Jul")],
    TRUE ~ kwh
  ))

#Confirm no remaining NAs and outlier is fixed
power_clean |> filter(is.na(kwh))
## # A tsibble: 0 x 2 [?]
## # ℹ 2 variables: month <mth>, kwh <dbl>
power_clean |> filter(kwh < 3000000)
## # A tsibble: 0 x 2 [?]
## # ℹ 2 variables: month <mth>, kwh <dbl>
#Replot to see if it looks better
power_clean |>
  autoplot(kwh) +
  labs(title = "Monthly Residential Power Consumption - Cleaned",
       y = "KWH")

It now looks significantly better, and the upwards trend became more obvious.

#Decompose to inspect trends and seasonality
power_clean |> 
  model(STL(kwh ~ trend(window=7) + season(window="periodic"))) |>
  components() |>
  autoplot()

I already knew there was a monthly seasonal pattern, this is just additional confirmation. This confirms the upwards trend as well. Summer and winter peaks can be seen, which can be attributed to cooling and heating needs. I will try and fit candidate models, ETS, ARIMA and SNAIVE.

models <- power_ts |>
  model(
    ETS = ETS(kwh),
    ARIMA = ARIMA(kwh),
    SNAIVE = SNAIVE(kwh ~ lag("year"))
  )

#Compares in-sample accuracy
accuracy(models)
## # A tibble: 3 × 10
##   .model .type        ME     RMSE      MAE   MPE  MAPE  MASE RMSSE    ACF1
##   <chr>  <chr>     <dbl>    <dbl>    <dbl> <dbl> <dbl> <dbl> <dbl>   <dbl>
## 1 ETS    Training 89189. 1419998. 1184278. -6.28  21.8 1.71  1.20  0.380  
## 2 ARIMA  Training -9734.  845161.  506277. -5.06  11.6 0.731 0.717 0.00853
## 3 SNAIVE Training 94772. 1176118.  689275. -3.94  14.5 0.995 0.997 0.231

The chosen models were (M,N,N) for ETS, and ARIMA(0,0,2)(2,1,0)[12].Based on the in-sample accuracy, ARIMA clearly outperforms both ETS and SNAIVE, with the lowest RMSE (845160.5) and MAE, and near-zero autocorrelation in residuals. ETS struggles with the variability (RMSE 1419997.8), while SNAIVE captures seasonality partially, but misses the trend. Overall, ARIMA provides the most reliable fit for forecasting 2014 power consumption.

#ARIMA has the lowest RMSE and MAE, so I chose that
best_model <- models |> select(ARIMA)

#Forecast 12 months ahead (Jan-Dec 2014)
forecast_2014 <- best_model |> 
  forecast(h = "12 months")

#Plot forecast against historical data
autoplot(forecast_2014, power_clean) +
  labs(title = "Residential Power Consumption Forecast - 2014",
       y = "kwh")

#Check forecast values
forecast_2014 |> as_tibble()
## # A tibble: 12 × 4
##    .model    month
##    <chr>     <mth>
##  1 ARIMA  2014 Jan
##  2 ARIMA  2014 Feb
##  3 ARIMA  2014 Mar
##  4 ARIMA  2014 Apr
##  5 ARIMA  2014 May
##  6 ARIMA  2014 Jun
##  7 ARIMA  2014 Jul
##  8 ARIMA  2014 Aug
##  9 ARIMA  2014 Sep
## 10 ARIMA  2014 Oct
## 11 ARIMA  2014 Nov
## 12 ARIMA  2014 Dec
## # ℹ 2 more variables: kwh <dist>, .mean <dbl>

Based on in-sample accuracy, ARIMA was selected as the best model for forecasting residential power consumption in 2014. It achieved the lowest RMSE and MAE, meaning that it captures both the upward trend and the seasonal patterns more effectively than ETS or SNAIVE. The 12-month forecast for 2014 shows the expected seasonal peaks in summer and winter, with monthly kwh values ranging from roughly 5.9 million in October to 10 million in August. The forecast aligns closely with historical patterns, and the prediction intervals appropriately widen to reflect the uncertainty further into the year, thus providing a reliable estimate of monthly power usage for 2014.

#Export
forecast_2014 |> 
  as_tibble() |>
  select(month, .mean) |>
  rename(Forecast_KWH = .mean) |>
  write_excel_csv("PartB_ResidentialPowerForecast_2014.csv")

PART C

#Read Pipe 1 and Pipe 2
pipe1 <- read_excel("Waterflow_Pipe1.xlsx")
pipe2 <- read_excel("Waterflow_Pipe2.xlsx")

#Name columns consistently
colnames(pipe1) <- c("datetime", "flow")
colnames(pipe2) <- c("datetime", "flow")

Excel stores dates as serial numbers, so they must be converted into proper datetime objects in R for accurate time-based operations.

#Excel serial number to POSIXct (adjusting for Excel's 1900 origin)
pipe1 <- pipe1 |>
  mutate(datetime = as_datetime((datetime - 25569) * 86400))

pipe2 <- pipe2 |>
  mutate(datetime = as_datetime((datetime - 25569) * 86400))
#Aggregate based on hour and then take the mean, as suggested
pipe1_hourly <- pipe1 |>
  mutate(hour = floor_date(datetime, "hour")) |>
  group_by(hour) |>
  summarise(flow = mean(flow, na.rm = TRUE))

pipe2_hourly <- pipe2 |>
  mutate(hour = floor_date(datetime, "hour")) |>
  group_by(hour) |>
  summarise(flow = mean(flow, na.rm = TRUE))

#Convert to tsibble
pipe1_ts <- pipe1_hourly |> as_tsibble(index = hour)
pipe2_ts <- pipe2_hourly |> as_tsibble(index = hour)
pipe1_ts |>
  autoplot(flow) +
  labs(title = "Pipe 1 flow")

pipe2_ts |>
  autoplot(flow) +
  labs(title = "Pipe 2 flow")

No seasonality or trend to be obsevred, variance is normal.

pipe1_ts |> features(flow, unitroot_kpss)
## # A tibble: 1 × 2
##   kpss_stat kpss_pvalue
##       <dbl>       <dbl>
## 1     0.247         0.1
pipe1_ts |> features(flow, unitroot_ndiffs)  #number of differences needed
## # A tibble: 1 × 1
##   ndiffs
##    <int>
## 1      0
pipe2_ts |> features(flow, unitroot_kpss)
## # A tibble: 1 × 2
##   kpss_stat kpss_pvalue
##       <dbl>       <dbl>
## 1     0.420      0.0683
pipe2_ts |> features(flow, unitroot_ndiffs)
## # A tibble: 1 × 1
##   ndiffs
##    <int>
## 1      0

Stationarity is assessed using the KPSS test. Both series appear stationary (KPSS pvalues > 0.05, ndiffs = 0), so no differencing is required before forecasting.

#There are gaps in the series, so they need to be filled with NA
pipe1_ts <- pipe1_ts |> fill_gaps(flow = NA)
pipe2_ts <- pipe2_ts |> fill_gaps(flow = NA)

#Fit model
pipe1_fit <- pipe1_ts |> model(ARIMA(flow))
pipe2_fit <- pipe2_ts |> model(ARIMA(flow))

#Forecast a week ahead (168 hours) with intervals
pipe1_fc <- pipe1_fit |> forecast(h = "168 hours", level = c(80, 95))
pipe2_fc <- pipe2_fit |> forecast(h = "168 hours", level = c(80, 95))

#Plot with intervals
pipe1_fc |> autoplot(pipe1_ts) + labs(title = "Pipe 1 Forecast")

pipe2_fc |> autoplot(pipe2_ts) + labs(title = "Pipe 2 Forecast")

Visual inspection showed no clear trend or seasonality in either series. As a result, the forecasts reflect the underlying stability of the data. Pipe 1 produces a nearly flat wide forecast around its mean, while Pipe 2 shows wider intervals due to small fluctuations in the timestamps. The two datasets were analyzed separately.

#export
pipe1_fc |> as_tibble() |> write_xlsx("PartC_Pipe1_Forecast.xlsx")
pipe2_fc |> as_tibble() |> write_xlsx("PartC_Pipe2_Forecast.xlsx")